From 0b58c11f2bfa48fde6543f862579bb3d504c6bdb Mon Sep 17 00:00:00 2001 From: ginnoir Date: Sun, 14 Jun 2026 22:52:13 -0500 Subject: [PATCH] test: add portainer stack env guard --- scripts/check-portainer-stack-env.ps1 | 106 ++++++++++++++++++++ scripts/portainer-stack-env-exemptions.json | 7 ++ 2 files changed, 113 insertions(+) create mode 100644 scripts/check-portainer-stack-env.ps1 create mode 100644 scripts/portainer-stack-env-exemptions.json diff --git a/scripts/check-portainer-stack-env.ps1 b/scripts/check-portainer-stack-env.ps1 new file mode 100644 index 0000000..e128f17 --- /dev/null +++ b/scripts/check-portainer-stack-env.ps1 @@ -0,0 +1,106 @@ +[CmdletBinding()] +param( + [string]$StacksRoot = (Join-Path $PSScriptRoot "..\stacks"), + [string]$ExemptionsPath = (Join-Path $PSScriptRoot "portainer-stack-env-exemptions.json") +) + +$ErrorActionPreference = "Stop" +$failures = New-Object System.Collections.Generic.List[string] +$exemptions = @{} + +if (Test-Path $ExemptionsPath) { + $raw = Get-Content -Raw -LiteralPath $ExemptionsPath | ConvertFrom-Json + foreach ($property in $raw.PSObject.Properties) { + $exemptions[$property.Name] = @($property.Value) + } +} + +function Get-ServiceBlocks { + param([string[]]$Lines) + + $inServices = $false + $currentName = $null + $currentLines = New-Object System.Collections.Generic.List[string] + $blocks = New-Object System.Collections.Generic.List[object] + + foreach ($line in $Lines) { + if ($line -match '^services:\s*$') { + $inServices = $true + continue + } + + if (-not $inServices) { + continue + } + + if ($line -match '^[A-Za-z0-9_-]+:\s*$') { + break + } + + if ($line -match '^ ([A-Za-z0-9_-]+):\s*$') { + if ($null -ne $currentName) { + $blocks.Add([pscustomobject]@{ + Name = $currentName + Lines = @($currentLines) + }) + } + $currentName = $Matches[1] + $currentLines = New-Object System.Collections.Generic.List[string] + continue + } + + if ($null -ne $currentName) { + $currentLines.Add($line) + } + } + + if ($null -ne $currentName) { + $blocks.Add([pscustomobject]@{ + Name = $currentName + Lines = @($currentLines) + }) + } + + return $blocks +} + +Get-ChildItem -LiteralPath $StacksRoot -Directory | Sort-Object Name | ForEach-Object { + $stackName = $_.Name + $composePath = Join-Path $_.FullName "docker-compose.yml" + $stackEnvPath = Join-Path $_.FullName "stack.env" + + if (-not (Test-Path $composePath)) { + return + } + + if (-not (Test-Path $stackEnvPath)) { + $failures.Add("${stackName}: missing stack.env") + } + + $lines = Get-Content -LiteralPath $composePath + $interpolations = Select-String -LiteralPath $composePath -Pattern '\$\{' | + Where-Object { $_.Line -notmatch '^\s*#' } + + foreach ($match in $interpolations) { + $failures.Add("${stackName}: compose interpolation at $($match.Path):$($match.LineNumber): $($match.Line.Trim())") + } + + $allowedMissing = @() + if ($exemptions.ContainsKey($stackName)) { + $allowedMissing = @($exemptions[$stackName]) + } + + foreach ($service in Get-ServiceBlocks -Lines $lines) { + $hasEnvFile = $service.Lines | Where-Object { $_ -match '^\s+env_file:\s*$' -or $_ -match '^\s+- stack\.env\s*$' } + if (-not $hasEnvFile -and $allowedMissing -notcontains $service.Name) { + $failures.Add("${stackName}/${service.Name}: missing env_file: stack.env") + } + } +} + +if ($failures.Count -gt 0) { + $failures | ForEach-Object { Write-Error $_ } + exit 1 +} + +Write-Host "Portainer stack env checks passed." diff --git a/scripts/portainer-stack-env-exemptions.json b/scripts/portainer-stack-env-exemptions.json new file mode 100644 index 0000000..b48a599 --- /dev/null +++ b/scripts/portainer-stack-env-exemptions.json @@ -0,0 +1,7 @@ +{ + "authentik": ["authentik-redis"], + "dev": ["dbx", "redis_plane"], + "foundry": ["5etools"], + "monitoring": ["uptime-kuma"], + "owncloud": ["redis"] +}