<# .SYNOPSIS Validate a project directory against the Coolify hard rules before pushing. .DESCRIPTION Implements the pre-deploy checklist from docs/AGENTS-coolify-apps.md as an automated gate. Run this BEFORE pushing to GitHub and BEFORE creating the Coolify application. Reports each rule as PASS / WARN / FAIL with the concrete fix for any failure. The script does NOT modify files. It only inspects. .PARAMETER Path Project directory to validate. Defaults to the current directory. .PARAMETER ExpectedPort Port the app is expected to listen on. Used to cross-check against ports_exposes if a compose file declares it, and against Dockerfile EXPOSE. .PARAMETER Strict Treat WARN as failures (exit code 1). Default: only FAIL fails. .EXAMPLE .\Test-PreDeployChecklist.ps1 -Path .\my-app -ExpectedPort 8080 #> param( [string]$Path = ".", [string]$ExpectedPort, [switch]$Strict ) $ErrorActionPreference = "Stop" $root = (Resolve-Path -LiteralPath $Path).Path $results = New-Object System.Collections.Generic.List[object] function Add-Result { param([string]$Id, [string]$Status, [string]$Message, [string]$Fix = "") $results.Add([PSCustomObject]@{ Id = $Id Status = $Status Message = $Message Fix = $Fix }) } $composeFiles = @("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml") | ForEach-Object { Join-Path $root $_ } | Where-Object { Test-Path -LiteralPath $_ } $dockerfile = @("Dockerfile", "Dockerfile.dev") | ForEach-Object { Join-Path $root $_ } | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 # Rule 2.1 - DB host is service name, not localhost foreach ($cf in $composeFiles) { $txt = Get-Content -LiteralPath $cf -Raw if ($txt -match 'localhost|127\.0\.0\.1') { $svc = ($txt -split "`n" | Select-String -Pattern 'localhost|127\.0\.0\.1' | Select-Object -First 1) Add-Result "2.1-sibling-dns" "WARN" "$cf references localhost/127.0.0.1; if it is a DB/cache host for a sibling service, use the Docker service name." "Replace localhost with the sibling service name (see AGENTS-coolify-apps.md 2.1)." } } if ($results | Where-Object Id -eq '2.1-sibling-dns') { # already added } else { Add-Result "2.1-sibling-dns" "PASS" "No localhost/127.0.0.1 reference detected in compose files." } # Rule 2.2 - coolify external network if app references shared services foreach ($cf in $composeFiles) { $txt = Get-Content -LiteralPath $cf -Raw if (($txt -match 'postgres|redis|mysql|mariadb|mongodb') -and ($txt -notmatch '(?ms)networks:.*?coolify:.*?external:\s*true')) { Add-Result "2.2-coolify-network" "WARN" "$cf references a DB/cache but does not declare the external 'coolify' network." "If the app must reach shared Coolify services, add the external coolify network (see AGENTS-coolify-apps.md 2.2)." } } if (-not ($results | Where-Object Id -eq '2.2-coolify-network')) { Add-Result "2.2-coolify-network" "PASS" "No external coolify network requirement detected, or it is correctly declared." } # Rule 2.3 - no published 80/443 $badPorts = $false foreach ($cf in $composeFiles) { $txt = Get-Content -LiteralPath $cf -Raw if ($txt -match '(?m)^\s*-\s*"?80:80"?|^\s*-\s*"?443:443"?|"80:80"|"443:443"|ports_exposes.*\b80\b.*\b443\b') { Add-Result "2.3-no-80-443-publish" "FAIL" "$cf publishes host ports 80 or 443 (owned by coolify-proxy)." "Remove the host port mapping; let Traefik route (AGENTS-coolify-apps.md 2.3)." $badPorts = $true } } if (-not $badPorts) { Add-Result "2.3-no-80-443-publish" "PASS" "No host 80/443 publishing found." } # Rule 2.4 - FQDN hint (informational) Add-Result "2.4-fqdn-set" "INFO" "Remember to set FQDN to https://.urieljareth.org BEFORE first deploy (not via Coolify's auto UUID subdomain)." "Set -Fqdn when calling New-CoolifyApplication.ps1." # Rule 2.5 - no obvious secrets in tree $secretPatterns = @( 'AKIA[0-9A-Z]{16}', 'ghp_[A-Za-z0-9]{36}', 'github_pat_[A-Za-z0-9_]{82}', '-----BEGIN (RSA |EC |OPENSSH |)PRIVATE KEY-----', 'postgres(ql)?://[^:\s]+:[^@\s]+@' ) $found = $false Get-ChildItem -LiteralPath $root -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.FullName -notmatch '\\(\.git|node_modules|vendor|dist|build)\\' } | ForEach-Object { try { $content = Get-Content -LiteralPath $_.FullName -Raw -ErrorAction Stop foreach ($pat in $secretPatterns) { if ($content -match $pat) { Add-Result "2.5-no-secrets-in-repo" "FAIL" "Potential secret matching pattern '$pat' found in $($_.FullName)." "Remove the secret and inject it as env via Coolify (AGENTS-coolify-apps.md 2.5)." $found = $true } } } catch {} } if (-not $found) { Add-Result "2.5-no-secrets-in-repo" "PASS" "No obvious hardcoded secrets detected." } # Rule 4 - healthcheck present $hcFound = $false foreach ($cf in $composeFiles) { $txt = Get-Content -LiteralPath $cf -Raw if ($txt -match 'healthcheck:') { $hcFound = $true } } if ($dockerfile) { $txt = Get-Content -LiteralPath $dockerfile -Raw if ($txt -match 'HEALTHCHECK') { $hcFound = $true } } if ($hcFound) { Add-Result "4-healthcheck" "PASS" "Healthcheck detected." } else { Add-Result "4-healthcheck" "WARN" "No healthcheck found in compose or Dockerfile." "Add a healthcheck on a DB-independent endpoint (AGENTS-coolify-apps.md section 4)." } # Rule 5 - named volumes for persistence foreach ($cf in $composeFiles) { $txt = Get-Content -LiteralPath $cf -Raw if ($txt -match 'volumes:' -and $txt -notmatch '(?ms)^volumes:\s*\n\s+\S+:') { Add-Result "5-named-volumes" "WARN" "$cf has volumes but no top-level named volumes section." "Declare named volumes to survive redeploys (AGENTS-coolify-apps.md section 5)." } } if (-not ($results | Where-Object Id -eq '5-named-volumes')) { Add-Result "5-named-volumes" "PASS" "Volume declarations look consistent." } # Cross-check ExpectedPort vs Dockerfile EXPOSE if ($ExpectedPort -and $dockerfile) { $txt = Get-Content -LiteralPath $dockerfile -Raw if ($txt -match 'EXPOSE\s+(\d+)') { $exposed = $matches[1] if ($exposed -ne $ExpectedPort) { Add-Result "ports-match" "WARN" "Dockerfile EXPOSE=$exposed but ExpectedPort=$ExpectedPort." "Make ports_exposes match the real listening port." } else { Add-Result "ports-match" "PASS" "Dockerfile EXPOSE matches ExpectedPort ($ExpectedPort)." } } } $results | Format-Table -AutoSize $failed = $results | Where-Object Status -eq 'FAIL' $warned = $results | Where-Object Status -eq 'WARN' if ($failed) { Write-Host "`nFAIL: $($failed.Count) hard rule(s) violated. Fix before pushing." -ForegroundColor Red exit 1 } if ($warned) { Write-Host "`nWARN: $($warned.Count) soft warning(s). Review before deploy." -ForegroundColor Yellow if ($Strict) { exit 1 } } Write-Host "`nChecklist complete." -ForegroundColor Green