diff --git a/app/Enums/ApplicationRecommendation.php b/app/Enums/ApplicationRecommendation.php new file mode 100644 index 0000000000..e23e94fc96 --- /dev/null +++ b/app/Enums/ApplicationRecommendation.php @@ -0,0 +1,141 @@ + + */ + public static function openFor(Application $application): Collection + { + return self::unsatisfiedFor($application) + ->reject(fn (self $recommendation): bool => $recommendation->isDismissedFor($application)) + ->values(); + } + + /** + * @return Collection + */ + public static function dismissedFor(Application $application): Collection + { + return self::unsatisfiedFor($application) + ->filter(fn (self $recommendation): bool => $recommendation->isDismissedFor($application)) + ->values(); + } + + /** + * @return Collection + */ + private static function unsatisfiedFor(Application $application): Collection + { + return collect(self::cases()) + ->filter(fn (self $recommendation): bool => $recommendation->appliesTo($application) && ! $recommendation->isSatisfied($application)); + } + + public function title(): string + { + return match ($this) { + self::UseBuildSecrets => 'Use build secrets', + self::UseRailpack => 'Switch from Nixpacks to Railpack', + }; + } + + public function why(Application $application): string + { + return match ($this) { + self::UseBuildSecrets => collect([ + 'Build-time variables are passed to the build as build arguments. Docker saves their values in the image history, so anyone who can read the image can read them.', + filled($application->docker_registry_image_name) + ? "This application pushes its image to {$application->docker_registry_image_name}, so the values ship with the image." + : null, + 'With build secrets, the values are available only inside RUN instructions, as environment variables. A Dockerfile that uses them in FROM, COPY, WORKDIR, or other instructions gets empty values there.', + ])->filter()->implode(' '), + self::UseRailpack => config('deprecations.nixpacks').' Railpack reads RAILPACK_* variables and railpack.json, not NIXPACKS_* variables or nixpacks.toml. Check them before you redeploy.', + }; + } + + /** + * The callout type that shows the recommendation: danger or warning. + */ + public function severity(Application $application): string + { + return match ($this) { + self::UseBuildSecrets => filled($application->docker_registry_image_name) ? 'danger' : 'warning', + self::UseRailpack => 'warning', + }; + } + + public function appliesTo(Application $application): bool + { + return match ($this) { + self::UseBuildSecrets => in_array($application->build_pack, ['dockerfile', 'nixpacks', 'dockercompose'], true) + && ! ($application->build_pack === 'dockercompose' && $application->settings?->is_raw_compose_deployment_enabled) + && ($application->environment_variables()->withoutBuildpackControlVariables()->where('is_buildtime', true)->exists() + || $application->environment_variables_preview()->withoutBuildpackControlVariables()->where('is_buildtime', true)->exists()), + self::UseRailpack => $application->build_pack === 'nixpacks', + }; + } + + public function isSatisfied(Application $application): bool + { + return match ($this) { + self::UseBuildSecrets => (bool) $application->settings?->use_build_secrets, + self::UseRailpack => $application->build_pack === 'railpack', + }; + } + + public function isDismissedFor(Application $application): bool + { + return in_array($this->value, $application->settings?->dismissed_recommendations ?? [], true); + } + + public function fixLabel(): string + { + return match ($this) { + self::UseBuildSecrets => 'Turn on build secrets', + self::UseRailpack => 'Switch to Railpack', + }; + } + + public function fixNeedsConfirmation(): bool + { + return match ($this) { + self::UseBuildSecrets => false, + self::UseRailpack => true, + }; + } + + /** + * Changes the application and its settings in memory. The caller authorizes and saves both. + */ + public function applyFix(Application $application): void + { + match ($this) { + self::UseBuildSecrets => $application->settings->use_build_secrets = true, + self::UseRailpack => $this->switchToRailpack($application), + }; + } + + private function switchToRailpack(Application $application): void + { + $application->build_pack = 'railpack'; + + // Same as a build pack change on the General page: read-only labels follow the build pack. + if ($application->settings->is_container_label_readonly_enabled) { + $application->custom_labels = base64_encode(implode("\n", generateLabelsApplication($application))); + } + } +} diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 6e6e3018f0..f8dc556d59 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -464,7 +464,7 @@ class ApplicationsController extends Controller ], 'watch_paths' => ['type' => 'string', 'description' => 'Watch path patterns, one per line (newline-separated). Commas do not separate patterns.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], - 'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'use_build_secrets' => ['type' => 'boolean', 'default' => true, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], @@ -674,7 +674,7 @@ class ApplicationsController extends Controller ], 'watch_paths' => ['type' => 'string', 'description' => 'Watch path patterns, one per line (newline-separated). Commas do not separate patterns.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], - 'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'use_build_secrets' => ['type' => 'boolean', 'default' => true, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], @@ -884,7 +884,7 @@ class ApplicationsController extends Controller ], 'watch_paths' => ['type' => 'string', 'description' => 'Watch path patterns, one per line (newline-separated). Commas do not separate patterns.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], - 'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'use_build_secrets' => ['type' => 'boolean', 'default' => true, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], @@ -1064,7 +1064,7 @@ class ApplicationsController extends Controller 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], 'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], - 'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'use_build_secrets' => ['type' => 'boolean', 'default' => true, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], @@ -1240,7 +1240,7 @@ class ApplicationsController extends Controller 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], 'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], - 'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'use_build_secrets' => ['type' => 'boolean', 'default' => true, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index ae31fcfe56..6e40b83349 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -5478,6 +5478,35 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ]); } + /** + * Build secrets reach a build only as environment variables inside RUN, and the env= mount + * option needs Dockerfile frontend 1.10+. Warn about the two cases that then build without values. + * + * @param Collection $dockerfile + * @param Collection $variables + */ + private function warnAboutDockerfileBuildSecretsCompatibility(Collection $dockerfile, Collection $variables): void + { + if (preg_match('~^#\s*syntax\s*=\s*\S*docker/dockerfile:1\.(\d+)~i', (string) $dockerfile->first(), $matches) === 1 && (int) $matches[1] < 10) { + $this->application_deployment_queue->addLogEntry("⚠️ The Dockerfile pins docker/dockerfile:1.{$matches[1]}. Build secrets need docker/dockerfile:1.10 or later. Remove the syntax line or update it, or switch Build secrets off.", 'stderr'); + } + + $keysUsedOutsideRun = $variables->keys() + ->map(fn ($key): string => (string) $key) + ->filter(fn (string $key): bool => ValidationPatterns::isValidEnvironmentVariableKey($key)) + ->filter(fn (string $key): bool => $dockerfile->contains(function (string $line) use ($key): bool { + $trimmed = ltrim($line); + + return preg_match('/^(RUN|ARG|ENV|#)/i', $trimmed) !== 1 + && preg_match('/\$\{?'.preg_quote($key, '/').'\b/', $trimmed) === 1; + })) + ->values(); + + if ($keysUsedOutsideRun->isNotEmpty()) { + $this->application_deployment_queue->addLogEntry('⚠️ The Dockerfile uses build-time variables outside RUN instructions: '.$keysUsedOutsideRun->implode(', ').'. With build secrets, these values are only available inside RUN, so these instructions get empty values. Switch Build secrets off on the Environment Variables page if the build needs them.', 'stderr'); + } + } + private function modify_dockerfile_for_secrets($dockerfile_path) { // Only process if build secrets are enabled and we have secrets to mount @@ -5522,6 +5551,8 @@ 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'; + $this->warnAboutDockerfileBuildSecretsCompatibility($dockerfile, $variables); + $modified = false; $dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) { $trimmed = ltrim($line); diff --git a/app/Livewire/Project/Application/Recommendations.php b/app/Livewire/Project/Application/Recommendations.php new file mode 100644 index 0000000000..41286dcc28 --- /dev/null +++ b/app/Livewire/Project/Application/Recommendations.php @@ -0,0 +1,76 @@ +authorize('update', $this->application); + $recommendation = ApplicationRecommendation::from($recommendation); + + $recommendation->applyFix($this->application); + $this->application->save(); + $this->saveApplicationSettingsWithAudit($this->application); + + $this->dispatch('success', 'Settings saved. Redeploy the application to apply them.'); + $this->refreshConfiguration(); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function dismiss(string $recommendation): void + { + $this->setDismissed(ApplicationRecommendation::from($recommendation), true); + } + + public function restore(string $recommendation): void + { + $this->setDismissed(ApplicationRecommendation::from($recommendation), false); + } + + public function render(): View + { + return view('livewire.project.application.recommendations', [ + 'openRecommendations' => ApplicationRecommendation::openFor($this->application), + 'dismissedRecommendations' => ApplicationRecommendation::dismissedFor($this->application), + ]); + } + + private function setDismissed(ApplicationRecommendation $recommendation, bool $dismissed): void + { + try { + $this->authorize('update', $this->application); + + $settings = $this->application->settings; + $dismissedValues = collect($settings->dismissed_recommendations ?? [])->reject(fn ($value): bool => $value === $recommendation->value); + $settings->dismissed_recommendations = ($dismissed ? $dismissedValues->push($recommendation->value) : $dismissedValues)->values()->all(); + $this->saveApplicationSettingsWithAudit($this->application); + + $this->refreshConfiguration(); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + /** + * Only the parent page refreshes, so the sidebar count and the build pack dependent menu items update. + */ + private function refreshConfiguration(): void + { + $this->dispatch('refresh')->to(Configuration::class); + } +} diff --git a/app/Models/Application.php b/app/Models/Application.php index 674f51b273..35c13cb90e 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -399,8 +399,11 @@ class Application extends BaseModel } }); static::created(function ($application) { + // New applications keep build-time values out of image layers. Clones and server transfer + // imports overwrite this row with the settings of their source application. ApplicationSetting::create([ 'application_id' => $application->id, + 'use_build_secrets' => true, ]); $application->compose_parsing_version = self::$parserVersion; $application->save(); diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index 7a7054e3ee..59a6341c22 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -74,6 +74,7 @@ class ApplicationSetting extends Model 'is_container_label_escape_enabled' => 'boolean', 'is_container_label_readonly_enabled' => 'boolean', 'use_build_secrets' => 'boolean', + 'dismissed_recommendations' => 'array', 'inject_build_args_to_dockerfile' => 'boolean', 'include_source_commit_in_build' => 'boolean', 'is_auto_deploy_enabled' => 'boolean', @@ -140,6 +141,7 @@ class ApplicationSetting extends Model 'is_git_shallow_clone_enabled', 'is_pr_deployments_public_enabled', 'use_build_secrets', + 'dismissed_recommendations', 'redact_all_env_values_in_logs', 'inject_build_args_to_dockerfile', 'include_source_commit_in_build', diff --git a/database/migrations/2026_10_26_112440_add_dismissed_recommendations_to_application_settings.php b/database/migrations/2026_10_26_112440_add_dismissed_recommendations_to_application_settings.php new file mode 100644 index 0000000000..a35699c3a3 --- /dev/null +++ b/database/migrations/2026_10_26_112440_add_dismissed_recommendations_to_application_settings.php @@ -0,0 +1,22 @@ +json('dismissed_recommendations')->nullable(); + }); + } + + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('dismissed_recommendations'); + }); + } +}; diff --git a/docs/server-transfer-full-coverage.md b/docs/server-transfer-full-coverage.md index 3c39e0e15d..2f0d4af044 100644 --- a/docs/server-transfer-full-coverage.md +++ b/docs/server-transfer-full-coverage.md @@ -96,7 +96,7 @@ When you add a model or a setting to the transfer, add it to this file too. | ✅ | Limits | memory, swap, swappiness, reservation, CPUs, cpuset, CPU shares | | ➕ | Advanced | custom labels, custom docker run options, pre/post deployment command + container, manual webhook secrets | | ✅ | Maintenance | maintenance mode on + custom maintenance page | -| ✅ | All application settings that are not the default | git submodules, LFS, shallow clone, auto deploy off, force HTTPS, debug, preview deployments on, PR deployments public, log drain, GPU (driver, count, device IDs, options), include timestamps, swarm-only worker nodes, build server, consistent container name, gzip off, strip prefix off, connect to Docker network, custom internal name, container label escape off, env sorting, read-only labels, preserve repository, disable build cache, use build secrets, stop grace period 30, rolling update drain time 12, inject build args off, include source commit, Docker images to keep 5, custom container name prefix, redact env values in logs, Coolify default variables off, Traefik certificate resolver | +| ✅ | All application settings that are not the default | git submodules, LFS, shallow clone, auto deploy off, force HTTPS, debug, preview deployments on, PR deployments public, log drain, GPU (driver, count, device IDs, options), include timestamps, swarm-only worker nodes, build server, consistent container name, gzip off, strip prefix off, connect to Docker network, custom internal name, container label escape off, env sorting, read-only labels, preserve repository, disable build cache, build secrets off, a dismissed recommendation (Recommended settings), stop grace period 30, rolling update drain time 12, inject build args off, include source commit, Docker images to keep 5, custom container name prefix, redact env values in logs, Coolify default variables off, Traefik certificate resolver | | ✅ | Environment variables | normal, multiline, literal, shown once, build time only, runtime only, required, with comment, preview-only, and references to `{{team.*}}`, `{{project.*}}`, `{{environment.*}}`, `{{server.*}}` | | ✅ | Persistent volume | the seed has one; add a host-path bind mount and preview suffix on | | ✅ | File mount + directory mount | content, chown, chmod | diff --git a/openapi.json b/openapi.json index 7b60e74a4b..01084d1a3a 100644 --- a/openapi.json +++ b/openapi.json @@ -873,7 +873,7 @@ }, "use_build_secrets": { "type": "boolean", - "default": false, + "default": true, "description": "Use Docker Build Secrets for build-time environment variables." }, "is_git_submodules_enabled": { @@ -1531,7 +1531,7 @@ }, "use_build_secrets": { "type": "boolean", - "default": false, + "default": true, "description": "Use Docker Build Secrets for build-time environment variables." }, "is_git_submodules_enabled": { @@ -2189,7 +2189,7 @@ }, "use_build_secrets": { "type": "boolean", - "default": false, + "default": true, "description": "Use Docker Build Secrets for build-time environment variables." }, "is_git_submodules_enabled": { @@ -2737,7 +2737,7 @@ }, "use_build_secrets": { "type": "boolean", - "default": false, + "default": true, "description": "Use Docker Build Secrets for build-time environment variables." }, "is_git_submodules_enabled": { @@ -3265,7 +3265,7 @@ }, "use_build_secrets": { "type": "boolean", - "default": false, + "default": true, "description": "Use Docker Build Secrets for build-time environment variables." }, "is_git_submodules_enabled": { diff --git a/openapi.yaml b/openapi.yaml index c870fea1e8..97290ca26f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -563,7 +563,7 @@ paths: description: 'Use build server.' use_build_secrets: type: boolean - default: false + default: true description: 'Use Docker Build Secrets for build-time environment variables.' is_git_submodules_enabled: type: boolean @@ -1001,7 +1001,7 @@ paths: description: 'Use build server.' use_build_secrets: type: boolean - default: false + default: true description: 'Use Docker Build Secrets for build-time environment variables.' is_git_submodules_enabled: type: boolean @@ -1439,7 +1439,7 @@ paths: description: 'Use build server.' use_build_secrets: type: boolean - default: false + default: true description: 'Use Docker Build Secrets for build-time environment variables.' is_git_submodules_enabled: type: boolean @@ -1814,7 +1814,7 @@ paths: description: 'Use build server.' use_build_secrets: type: boolean - default: false + default: true description: 'Use Docker Build Secrets for build-time environment variables.' is_git_submodules_enabled: type: boolean @@ -2175,7 +2175,7 @@ paths: description: 'Use build server.' use_build_secrets: type: boolean - default: false + default: true description: 'Use Docker Build Secrets for build-time environment variables.' is_git_submodules_enabled: type: boolean diff --git a/resources/views/components/application/configuration-sidebar.blade.php b/resources/views/components/application/configuration-sidebar.blade.php index 592c81e48f..5e0560fcb3 100644 --- a/resources/views/components/application/configuration-sidebar.blade.php +++ b/resources/views/components/application/configuration-sidebar.blade.php @@ -13,6 +13,12 @@ 'route' => 'project.application.configuration', 'active' => $currentRoute === 'project.application.configuration', ], + [ + 'label' => 'Recommended settings', + 'route' => 'project.application.recommendations', + 'active' => $currentRoute === 'project.application.recommendations', + 'count' => \App\Enums\ApplicationRecommendation::openFor($application)->count(), + ], [ 'label' => 'Domains', 'route' => 'project.application.domains', @@ -146,6 +152,7 @@ // Icons follow the main sidebar's reicon set $menuIcons = [ 'General' => 'settings', + 'Recommended settings' => 'shield-star', 'Domains' => 'globe', 'Advanced' => 'grid', 'Swarm' => 'destinations', @@ -173,7 +180,7 @@ // Discord-style groups for the settings sidebar $menuGroups = [ - 'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck'], + 'Settings' => ['General', 'Recommended settings', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck'], 'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Files', 'Metrics', 'Analytics'], 'Deploy' => ['Git Source', 'Servers', 'Preview Deployments'], 'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'], @@ -266,6 +273,10 @@ @if ($menuItem['label'] === 'Files') @endif + @if (($menuItem['count'] ?? 0) > 0) + {{ $menuItem['count'] }} + @endif @if ($menuItem['badge'] ?? false) @elseif ($currentRoute === 'project.application.swarm' && $application->destination->server->isSwarm()) + @elseif ($currentRoute === 'project.application.recommendations') + @elseif ($currentRoute === 'project.application.advanced') @elseif ($currentRoute === 'project.application.environment-variables') diff --git a/resources/views/livewire/project/application/recommendations.blade.php b/resources/views/livewire/project/application/recommendations.blade.php new file mode 100644 index 0000000000..e2441c7085 --- /dev/null +++ b/resources/views/livewire/project/application/recommendations.blade.php @@ -0,0 +1,46 @@ +
+ + @forelse ($openRecommendations as $recommendation) + ! $loop->first])> +

{{ $recommendation->why($application) }}

+ @can('update', $application) +
+ @if ($recommendation->fixNeedsConfirmation()) + + @else + + {{ $recommendation->fixLabel() }} + + @endif + Dismiss +
+ @endcan +
+ @empty + + @endforelse +
+ + @if ($dismissedRecommendations->isNotEmpty()) + + @foreach ($dismissedRecommendations as $recommendation) +
+ {{ $recommendation->title() }} + @can('update', $application) + Restore + @endcan +
+ @endforeach +
+ @endif +
diff --git a/routes/web.php b/routes/web.php index 4cee090eff..6d646ca317 100644 --- a/routes/web.php +++ b/routes/web.php @@ -396,6 +396,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/domains', ApplicationConfiguration::class)->name('project.application.domains'); Route::get('/swarm', ApplicationConfiguration::class)->name('project.application.swarm'); Route::get('/advanced', ApplicationConfiguration::class)->name('project.application.advanced'); + Route::get('/recommendations', ApplicationConfiguration::class)->name('project.application.recommendations'); Route::get('/environment-variables', ApplicationConfiguration::class)->name('project.application.environment-variables'); Route::get('/persistent-storage', ApplicationConfiguration::class)->name('project.application.persistent-storage'); Route::get('/backups', ApplicationBackupIndex::class)->name('project.application.backup.index'); diff --git a/scripts/transfer-test/seed-full.php b/scripts/transfer-test/seed-full.php index bdbee95719..6df763fe92 100644 --- a/scripts/transfer-test/seed-full.php +++ b/scripts/transfer-test/seed-full.php @@ -80,6 +80,7 @@ $app->settings->forceFill([ 'include_coolify_default_variables' => false, 'traefik_certificate_resolver' => 'custom', 'connect_to_docker_network' => true, 'custom_internal_name' => 'whoami-two', 'is_permanent_redirect_enabled' => true, 'is_log_drain_enabled' => false, 'is_preview_deployments_enabled' => true, 'is_pr_deployments_public_enabled' => true, 'is_auto_deploy_enabled' => false, + 'use_build_secrets' => false, 'dismissed_recommendations' => ['use-build-secrets'], ])->save(); // Variables of every kind, and references to every shared scope diff --git a/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php b/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php index 1847c00221..b461cd6fda 100644 --- a/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php +++ b/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php @@ -59,7 +59,7 @@ function buildSecretsGithubPrivateKey(): string describe('PATCH /api/v1/applications/{uuid} use_build_secrets', function () { test('updates the application setting', function () { - expect($this->application->settings->use_build_secrets)->toBeFalse(); + $this->application->settings->update(['use_build_secrets' => false]); $this->withHeaders(buildSecretsApiHeaders($this->bearerToken)) ->patchJson("/api/v1/applications/{$this->application->uuid}", [ @@ -88,7 +88,7 @@ describe('PATCH /api/v1/applications/{uuid} use_build_secrets', function () { }); test('does not change the setting when omitted', function () { - $this->application->settings->update(['use_build_secrets' => true]); + $this->application->settings->update(['use_build_secrets' => false]); $this->withHeaders(buildSecretsApiHeaders($this->bearerToken)) ->patchJson("/api/v1/applications/{$this->application->uuid}", [ @@ -96,7 +96,7 @@ describe('PATCH /api/v1/applications/{uuid} use_build_secrets', function () { ]) ->assertOk(); - expect($this->application->fresh()->settings->use_build_secrets)->toBeTrue(); + expect($this->application->fresh()->settings->use_build_secrets)->toBeFalse(); }); }); @@ -142,6 +142,33 @@ describe('POST /api/v1/applications/public use_build_secrets', function () { }); }); +describe('application creation endpoints without use_build_secrets', function () { + test('turn build secrets on for the new application', function (string $endpoint, array $payload) { + if ($endpoint === 'private-deploy-key') { + $payload['private_key_uuid'] = PrivateKey::factory()->create(['team_id' => $this->team->id])->uuid; + } + + $response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$endpoint}", [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'autogenerate_domain' => false, + ...$payload, + ]) + ->assertCreated(); + + $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); + + expect($application->settings->use_build_secrets)->toBeTrue(); + })->with([ + 'public' => ['public', ['git_repository' => 'https://gitlab.com/coolify/build-secrets-test', 'git_branch' => 'main', 'build_pack' => 'nixpacks', 'ports_exposes' => '3000']], + 'private deploy key' => ['private-deploy-key', ['git_repository' => 'git@gitlab.com:coolify/build-secrets-test.git', 'git_branch' => 'main', 'build_pack' => 'dockerfile', 'ports_exposes' => '3000']], + 'dockerfile' => ['dockerfile', ['dockerfile' => base64_encode("FROM nginx:alpine\nEXPOSE 80")]], + 'docker image' => ['dockerimage', ['docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => 'alpine', 'ports_exposes' => '80']], + ]); +}); + describe('other application creation endpoints use_build_secrets', function () { test('creates a Dockerfile application with build secrets enabled', function () { $response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken)) diff --git a/tests/Feature/ApplicationBuildSecretsDefaultTest.php b/tests/Feature/ApplicationBuildSecretsDefaultTest.php new file mode 100644 index 0000000000..b25ae69679 --- /dev/null +++ b/tests/Feature/ApplicationBuildSecretsDefaultTest.php @@ -0,0 +1,68 @@ + InstanceSettings::query()->create(['id' => 0])); + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = $this->server->standaloneDockers()->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +test('a new application from the create form uses build secrets', function () { + $routeParams = [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + ]; + request()->headers->set('referer', route('project.resource.create', $routeParams).'?destination='.$this->destination->uuid); + + Livewire::withUrlParams(['destination' => $this->destination->uuid]) + ->test(SimpleDockerfile::class, $routeParams) + ->set('parameters', $routeParams) + ->set('dockerfile', "FROM nginx\nEXPOSE 80\n") + ->call('submit'); + + $application = Application::query()->where('environment_id', $this->environment->id)->sole(); + + expect($application->settings->use_build_secrets)->toBeTrue(); +}); + +test('a cloned application keeps the build secrets setting of its source', function (bool $sourceValue) { + $source = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $source->settings->update(['use_build_secrets' => $sourceValue]); + + $clone = clone_application($source->fresh(), $this->destination, [ + 'environment_id' => $this->environment->id, + ]); + + expect($clone->fresh()->settings->use_build_secrets)->toBe($sourceValue); +})->with([ + 'off' => false, + 'on' => true, +]); diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index 2b2e4dd404..a45cf144f4 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -634,6 +634,25 @@ it('mounts underscore nixpacks secrets on RUN lines when build secrets are enabl ->toContain('printenv SAFE_FROM_TOML'); }); +it('warns when a Dockerfile in build secrets mode cannot read build variables', function () { + [$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerfile']); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'env_args' => collect(['NODE_VERSION' => '22']), + 'build_secrets' => '--secret id=NODE_VERSION,env=NODE_VERSION', + 'saved_outputs' => collect([ + 'dockerfile_content' => "# syntax=docker/dockerfile:1.4\nARG NODE_VERSION\nFROM node:\${NODE_VERSION}\nRUN node --version", + ]), + ]); + + invokeDeploymentJobMethod($job, $reflection, 'modify_dockerfile_for_secrets', '/artifacts/test-app/Dockerfile'); + + $logs = implode("\n", $job->recordedLogEntries); + expect($logs) + ->toContain('docker/dockerfile:1.4') + ->toContain('NODE_VERSION'); +}); + it('skips unsafe reserved Nixpacks plan variable keys before validation', function (string $key) { [$application, $server] = makeDeploymentControlVarFixture([ 'build_pack' => 'nixpacks', diff --git a/tests/Feature/ApplicationRecommendationsTest.php b/tests/Feature/ApplicationRecommendationsTest.php new file mode 100644 index 0000000000..ddd6fb9429 --- /dev/null +++ b/tests/Feature/ApplicationRecommendationsTest.php @@ -0,0 +1,196 @@ + InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0])); + + $this->team = Team::factory()->create(); + $this->admin = User::factory()->create(); + $this->admin->teams()->attach($this->team, ['role' => 'admin']); + $this->member = User::factory()->create(); + $this->member->teams()->attach($this->team, ['role' => 'member']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = $this->server->standaloneDockers()->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + + $this->makeApplication = function (array $attributes = [], bool $useBuildSecrets = false, bool $withBuildVariable = true): Application { + $application = Application::factory()->create([ + 'environment_id' => $this->project->environments()->firstOrFail()->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'build_pack' => 'dockerfile', + ...$attributes, + ]); + $application->settings->update(['use_build_secrets' => $useBuildSecrets]); + + if ($withBuildVariable) { + EnvironmentVariable::create([ + 'key' => 'API_TOKEN', + 'value' => 'secret', + 'is_buildtime' => true, + 'is_runtime' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + } + + return $application->fresh(); + }; +}); + +function openRecommendationValues(Application $application): array +{ + return ApplicationRecommendation::openFor($application->fresh())->map->value->all(); +} + +test('open recommendations depend on the build pack and the build secrets setting', function (array $attributes, bool $useBuildSecrets, array $expected) { + $application = ($this->makeApplication)($attributes, $useBuildSecrets); + + expect(openRecommendationValues($application))->toBe($expected); +})->with([ + 'dockerfile with build arguments' => [['build_pack' => 'dockerfile'], false, ['use-build-secrets']], + 'dockerfile with build secrets' => [['build_pack' => 'dockerfile'], true, []], + 'nixpacks with build arguments' => [['build_pack' => 'nixpacks'], false, ['use-build-secrets', 'use-railpack']], + 'compose with build arguments' => [['build_pack' => 'dockercompose'], false, ['use-build-secrets']], + 'railpack always uses secrets' => [['build_pack' => 'railpack'], false, []], + 'docker image does not build' => [['build_pack' => 'dockerimage'], false, []], +]); + +test('build secrets are not recommended without user build-time variables', function () { + $application = ($this->makeApplication)(['build_pack' => 'nixpacks'], withBuildVariable: false); + + // Nixpacks applications always get the NIXPACKS_NODE_VERSION control variable. + expect($application->environment_variables()->where('key', 'NIXPACKS_NODE_VERSION')->exists())->toBeTrue() + ->and(openRecommendationValues($application))->toBe(['use-railpack']); +}); + +test('build secrets are not recommended for raw compose deployments', function () { + $application = ($this->makeApplication)(['build_pack' => 'dockercompose']); + $application->settings->update(['is_raw_compose_deployment_enabled' => true]); + + expect(openRecommendationValues($application))->toBe([]); +}); + +test('the build secrets recommendation is stronger when the image is pushed to a registry', function () { + $local = ($this->makeApplication)(); + $pushed = ($this->makeApplication)(['docker_registry_image_name' => 'ghcr.io/acme/app']); + + expect(ApplicationRecommendation::UseBuildSecrets->severity($local))->toBe('warning') + ->and(ApplicationRecommendation::UseBuildSecrets->severity($pushed))->toBe('danger') + ->and(ApplicationRecommendation::UseBuildSecrets->why($pushed))->toContain('ghcr.io/acme/app') + ->and(ApplicationRecommendation::UseBuildSecrets->why($local))->not->toContain('pushes its image'); +}); + +test('an admin turns on build secrets with one click', function () { + $application = ($this->makeApplication)(); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(Recommendations::class, ['application' => $application]) + ->call('applyFix', 'use-build-secrets') + ->assertDispatched('success') + ->assertDispatched('refresh') + ->assertViewHas('openRecommendations', fn ($open) => $open->isEmpty()); + + expect($application->fresh()->settings->use_build_secrets)->toBeTrue(); +}); + +test('an admin switches a Nixpacks application to Railpack and keeps the read-only labels in sync', function () { + $application = ($this->makeApplication)(['build_pack' => 'nixpacks'], useBuildSecrets: true); + $application->settings->update(['is_container_label_readonly_enabled' => true]); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(Recommendations::class, ['application' => $application->fresh()]) + ->call('applyFix', 'use-railpack') + ->assertDispatched('success'); + + $application = $application->fresh(); + expect($application->build_pack)->toBe('railpack') + ->and(base64_decode($application->custom_labels))->toBe(implode("\n", generateLabelsApplication($application))); +}); + +test('a dismissed recommendation leaves the open list and can be restored', function () { + $application = ($this->makeApplication)(); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(Recommendations::class, ['application' => $application]) + ->call('dismiss', 'use-build-secrets') + ->assertDispatched('refresh') + ->assertViewHas('openRecommendations', fn ($open) => $open->isEmpty()) + ->assertViewHas('dismissedRecommendations', fn ($dismissed) => $dismissed->map->value->all() === ['use-build-secrets']); + + expect($application->fresh()->settings->dismissed_recommendations)->toBe(['use-build-secrets']); + + $component->call('restore', 'use-build-secrets') + ->assertViewHas('openRecommendations', fn ($open) => $open->map->value->all() === ['use-build-secrets']); + + expect($application->fresh()->settings->dismissed_recommendations)->toBe([]); +}); + +test('unknown dismissed values do not hide recommendations', function () { + $application = ($this->makeApplication)(); + $application->settings->update(['dismissed_recommendations' => ['removed-recommendation']]); + + expect(openRecommendationValues($application))->toBe(['use-build-secrets']); +}); + +test('a member cannot apply, dismiss, or restore recommendations', function (string $method) { + $application = ($this->makeApplication)(); + $application->settings->update(['dismissed_recommendations' => $method === 'restore' ? ['use-build-secrets'] : null]); + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(Recommendations::class, ['application' => $application->fresh()]) + ->call($method, 'use-build-secrets') + ->assertDispatched('error') + ->assertNotDispatched('refresh'); + + $settings = $application->fresh()->settings; + expect($settings->use_build_secrets)->toBeFalse() + ->and($settings->dismissed_recommendations)->toBe($method === 'restore' ? ['use-build-secrets'] : null); +})->with(['applyFix', 'dismiss', 'restore']); + +test('the role in the application team decides after the user switches to a team they own', function () { + $application = ($this->makeApplication)(); + $ownTeam = Team::factory()->create(); + $this->member->teams()->attach($ownTeam, ['role' => 'owner']); + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(Recommendations::class, ['application' => $application]); + session(['currentTeam' => $ownTeam]); + $component->call('applyFix', 'use-build-secrets')->assertDispatched('error'); + + expect($application->fresh()->settings->use_build_secrets)->toBeFalse(); +}); + +test('an admin of another team cannot change the recommendations of an application', function () { + $application = ($this->makeApplication)(); + $otherTeam = Team::factory()->create(); + $otherAdmin = User::factory()->create(); + $otherAdmin->teams()->attach($otherTeam, ['role' => 'owner']); + $this->actingAs($otherAdmin); + session(['currentTeam' => $otherTeam]); + + Livewire::test(Recommendations::class, ['application' => $application]) + ->call('applyFix', 'use-build-secrets') + ->assertDispatched('error'); + + expect($application->fresh()->settings->use_build_secrets)->toBeFalse(); +}); diff --git a/tests/Feature/ApplicationSettingsAuditTest.php b/tests/Feature/ApplicationSettingsAuditTest.php index 550e6ef0a3..23f04d8f98 100644 --- a/tests/Feature/ApplicationSettingsAuditTest.php +++ b/tests/Feature/ApplicationSettingsAuditTest.php @@ -205,7 +205,7 @@ it('audits application setting changes from each settings control', function (Cl }, 'docker_images_to_keep'], 'build secrets' => [function () { Livewire::test(All::class, ['resource' => $this->application]) - ->set('use_build_secrets', true) + ->set('use_build_secrets', false) ->call('instantSave') ->assertHasNoErrors(); }, 'use_build_secrets'], diff --git a/tests/Feature/NixpacksDeprecationTest.php b/tests/Feature/NixpacksDeprecationTest.php index 15245bd7b2..f8057e74d9 100644 --- a/tests/Feature/NixpacksDeprecationTest.php +++ b/tests/Feature/NixpacksDeprecationTest.php @@ -1,5 +1,6 @@ assertDontSee(config('deprecations.nixpacks')); }); +test('the Railpack recommendation shows the nixpacks deprecation notice only for nixpacks', function (string $buildPack, bool $showsNotice) { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => $buildPack, + ]); + + $open = ApplicationRecommendation::openFor($application); + + expect($open->contains(ApplicationRecommendation::UseRailpack))->toBe($showsNotice) + ->and(ApplicationRecommendation::UseRailpack->why($application))->toContain(config('deprecations.nixpacks')); +})->with([ + 'nixpacks' => ['nixpacks', true], + 'railpack' => ['railpack', false], +]); + +test('switching the build pack to railpack on the General page closes the Railpack recommendation', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'nixpacks', + 'static_image' => 'nginx:alpine', + 'base_directory' => '/', + 'ports_exposes' => '3000', + 'is_http_basic_auth_enabled' => false, + 'redirect' => 'no', + ]); + + Livewire::test(General::class, ['application' => $application]) + ->set('buildPack', 'railpack'); + + expect(ApplicationRecommendation::openFor($application->fresh())->contains(ApplicationRecommendation::UseRailpack))->toBeFalse(); +}); + test('switching the build pack to railpack hides the nixpacks deprecation notice in the heading', function () { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, diff --git a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php index cf1307d9f9..9f13bc9611 100644 --- a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php @@ -1330,6 +1330,21 @@ test('transfer keeps the Traefik certificate resolver of applications and servic ->and(ServiceApplication::where('uuid', 'svc-app-resolver')->firstOrFail()->traefik_certificate_resolver)->toBe('cloudflare'); }); +test('transfer keeps the build secrets setting and the dismissed recommendations of an application', function () { + $this->application->settings->update([ + 'use_build_secrets' => false, + 'dismissed_recommendations' => ['use-build-secrets'], + ]); + $bundle = $this->exporter->export($this->server->fresh()); + + removeTransferSourceResources($this); + $this->importer->import($bundle, teamId: Team::factory()->create()->id); + + $settings = Application::where('uuid', $this->application->uuid)->sole()->settings; + expect($settings->use_build_secrets)->toBeFalse() + ->and($settings->dismissed_recommendations)->toBe(['use-build-secrets']); +}); + test('transfer keeps the cloud provider instance id of the server', function () { $this->server->forceFill(['hetzner_server_id' => 4242, 'vultr_instance_id' => 'vultr-7'])->save(); $bundle = $this->exporter->export($this->server->fresh()); diff --git a/tests/v4/Browser/ApplicationRecommendationsTest.php b/tests/v4/Browser/ApplicationRecommendationsTest.php new file mode 100644 index 0000000000..d506641f12 --- /dev/null +++ b/tests/v4/Browser/ApplicationRecommendationsTest.php @@ -0,0 +1,60 @@ +set('app.maintenance.store', 'array'); + $stack = seedBrowserResourceStack(); + $application = createBrowserApplication($stack, ['docker_registry_image_name' => 'ghcr.io/acme/app']); + $application->settings->update(['use_build_secrets' => false]); + EnvironmentVariable::create([ + 'key' => 'API_TOKEN', + 'value' => 'secret', + 'is_buildtime' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + + $page = visit('/login') + ->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertSee('Dashboard') + ->navigate(route('project.application.recommendations', [ + 'project_uuid' => $stack['project']->uuid, + 'environment_uuid' => $stack['environment']->uuid, + 'application_uuid' => $application->uuid, + ])) + ->assertSee('Use build secrets') + ->assertSee('Switch from Nixpacks to Railpack') + ->assertSee('ghcr.io/acme/app'); + + $menuCount = <<<'JS' + () => document.querySelector('[aria-label$=" open"]')?.textContent.trim() ?? null + JS; + $clickButton = fn (string $text) => $page->script(<< [...document.querySelectorAll('#recommended-settings-section button')] + .find(button => button.textContent.trim() === '{$text}') + .click() + JS); + + expect($page->script($menuCount))->toBe('2'); + $page->screenshot(filename: 'recommendations-open'); + + $clickButton('Turn on build secrets'); + $page->wait(1.5); + expect($application->fresh()->settings->use_build_secrets)->toBeTrue() + ->and($page->script($menuCount))->toBe('1'); + + $clickButton('Dismiss'); + $page->wait(1.5); + $page->assertSee('No recommendations')->assertSee('Restore'); + expect($page->script($menuCount))->toBeNull(); + + $page->assertNoJavaScriptErrors(); + $page->screenshot(filename: 'recommendations-empty'); +});