From 6b07f031074e5821915818fad792c3ef66e24baa Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 14 Oct 2026 18:02:02 +0530 Subject: [PATCH 1/6] 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 5/6] 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 6/6] 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'); +});