mirror of
https://github.com/kellymichels/zscripts-token-savers
synced 2026-10-07 07:18:18 +00:00
* fix(zdeploy): edge deploy ships asset subdirectories, not just top-level files A project self-hosting assets in folders (fonts/, vendor/) lost them on every deploy: docker created empty root-owned mount points and nginx served 404s from them, so fonts fell back silently and vendored JS never loaded. Every subdirectory except nginx-logs/ and .git/ now ships recursively, and the ensure-dir chown is recursive so scp into docker-created root-owned dirs cannot fail. Closes #42 * fix(zdeploy): stop deploys hanging on ssh prompts, keep vendored archives, make unzip install idempotent - Get-Ec2SshOpts: every deploy-path ssh/scp now carries BatchMode=yes plus connect/keepalive timeouts. Without BatchMode ssh prompts and waits forever, and because the deploy pipes stderr through the pipeline the prompt never reaches the screen - the run just stops under the last step label with no explanation. - Archive filter exempts files under vendor/: a vendored *.tgz is a build input, and dropping it fails a Dockerfile COPY at image build. - unzip install checks command -v first instead of an apt round-trip on every deploy. Ported from the private script; three tests cover the vendor/ exemption.
318 lines
14 KiB
PowerShell
318 lines
14 KiB
PowerShell
# Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers
|
|
# Created by Kelly Michels · dev@evomedia.net
|
|
# Licensed under the MIT License. See LICENSE.
|
|
|
|
# NewProjectArchive.Tests.ps1 - Pester 5 suite for the archive builder.
|
|
#
|
|
# Invoke-Pester .\tests
|
|
#
|
|
# Where the ZHelpers suite checks that Get-ArchiveExcludes returns the right
|
|
# *list*, this one checks the zip that actually gets built and shipped. A name
|
|
# can be on the exclude list and still end up in the archive; only opening the
|
|
# zip proves otherwise.
|
|
#
|
|
# Every test builds a real temp source tree, zips it, and inspects the entries.
|
|
# No config is read, so this file is independent of ZCONFIG and zconfig.json.
|
|
|
|
BeforeAll {
|
|
. (Join-Path (Split-Path -Parent $PSScriptRoot) "ZHelpers.ps1")
|
|
|
|
# Build a source tree from a list of relative paths. A path ending in '/'
|
|
# becomes an (empty) directory; anything else becomes a file with content.
|
|
function New-TestTree {
|
|
param([string[]]$Paths)
|
|
$root = Join-Path ([IO.Path]::GetTempPath()) ("zarc-" + [guid]::NewGuid().ToString("N"))
|
|
New-Item -ItemType Directory -Path $root -Force | Out-Null
|
|
foreach ($p in $Paths) {
|
|
$full = Join-Path $root ($p -replace '/', '\')
|
|
if ($p.EndsWith('/')) {
|
|
New-Item -ItemType Directory -Path $full -Force | Out-Null
|
|
} else {
|
|
$dir = Split-Path -Parent $full
|
|
if ($dir -and -not (Test-Path -LiteralPath $dir)) {
|
|
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
|
}
|
|
"content of $p" | Set-Content -LiteralPath $full -Encoding UTF8
|
|
}
|
|
}
|
|
return $root
|
|
}
|
|
|
|
# Relative entry paths inside a zip, forward-slashed as stored.
|
|
function Get-ZipEntries {
|
|
param([string]$ZipPath)
|
|
Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue
|
|
$zip = [IO.Compression.ZipFile]::OpenRead($ZipPath)
|
|
try { return @($zip.Entries | ForEach-Object { $_.FullName }) }
|
|
finally { $zip.Dispose() }
|
|
}
|
|
|
|
# Build a tree, archive it, return the entry list. Cleans up after itself.
|
|
function Get-ArchivedEntries {
|
|
param([string[]]$Paths, [string[]]$TopLevelExclude = @(), [switch]$IncludeScriptFiles, [string[]]$ExtraFiles = @())
|
|
$root = New-TestTree -Paths $Paths
|
|
$zip = Join-Path ([IO.Path]::GetTempPath()) ("zarc-" + [guid]::NewGuid().ToString("N") + ".zip")
|
|
try {
|
|
$splat = @{ SourcePath = $root; DestinationZip = $zip; TopLevelExclude = $TopLevelExclude; ExtraFiles = $ExtraFiles }
|
|
if ($IncludeScriptFiles) { $splat.IncludeScriptFiles = $true }
|
|
New-ProjectArchive @splat 6>$null
|
|
return Get-ZipEntries -ZipPath $zip
|
|
}
|
|
finally {
|
|
Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
|
|
Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
}
|
|
|
|
Describe "New-ProjectArchive - basic packaging" {
|
|
It "includes ordinary project files" {
|
|
$e = Get-ArchivedEntries -Paths @("app.py", "readme.md")
|
|
$e | Should -Contain "app.py"
|
|
$e | Should -Contain "readme.md"
|
|
}
|
|
|
|
It "preserves nested structure with forward slashes (zip convention)" {
|
|
$e = Get-ArchivedEntries -Paths @("src/lib/util.py")
|
|
$e | Should -Contain "src/lib/util.py"
|
|
}
|
|
|
|
It "throws when the source directory does not exist" {
|
|
{ New-ProjectArchive -SourcePath (Join-Path ([IO.Path]::GetTempPath()) "definitely-not-here-$([guid]::NewGuid())") `
|
|
-DestinationZip (Join-Path ([IO.Path]::GetTempPath()) "x.zip") } | Should -Throw
|
|
}
|
|
|
|
It "throws rather than shipping an empty archive when everything is filtered out" {
|
|
# A tree of nothing but junk must fail loudly - a silently empty deploy
|
|
# zip would unpack to nothing on the server.
|
|
{ Get-ArchivedEntries -Paths @("debug.log", "notes.bak") } | Should -Throw
|
|
}
|
|
|
|
It "overwrites an existing zip at the destination" {
|
|
$root = New-TestTree -Paths @("app.py")
|
|
$zip = Join-Path ([IO.Path]::GetTempPath()) ("zarc-" + [guid]::NewGuid().ToString("N") + ".zip")
|
|
try {
|
|
"stale placeholder" | Set-Content -LiteralPath $zip -Encoding UTF8
|
|
New-ProjectArchive -SourcePath $root -DestinationZip $zip 6>$null
|
|
(Get-ZipEntries -ZipPath $zip) | Should -Contain "app.py"
|
|
}
|
|
finally {
|
|
Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
|
|
Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
It "creates the destination directory when it does not exist" {
|
|
$root = New-TestTree -Paths @("app.py")
|
|
$sub = Join-Path ([IO.Path]::GetTempPath()) ("zarc-out-" + [guid]::NewGuid().ToString("N"))
|
|
$zip = Join-Path $sub "nested\deploy.zip"
|
|
try {
|
|
New-ProjectArchive -SourcePath $root -DestinationZip $zip 6>$null
|
|
Test-Path -LiteralPath $zip | Should -BeTrue
|
|
}
|
|
finally {
|
|
Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
|
|
Remove-Item -LiteralPath $sub -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
}
|
|
|
|
Describe "New-ProjectArchive - TopLevelExclude" {
|
|
It "drops an excluded top-level file" {
|
|
$e = Get-ArchivedEntries -Paths @("app.py", ".env") -TopLevelExclude @(".env")
|
|
$e | Should -Contain "app.py"
|
|
$e | Should -Not -Contain ".env"
|
|
}
|
|
|
|
It "drops an excluded top-level directory and everything under it" {
|
|
$e = Get-ArchivedEntries -Paths @("app.py", "uploads/photo.png", "uploads/deep/scan.png") -TopLevelExclude @("uploads")
|
|
$e | Should -Contain "app.py"
|
|
@($e | Where-Object { $_ -like "uploads/*" }).Count | Should -Be 0
|
|
}
|
|
|
|
# This is the documented (and surprising) semantic: the exclude list matches
|
|
# TOP-LEVEL names only. A nested backend/.env is NOT caught by excluding
|
|
# ".env" - the server-side copy is what protects it on deploy (deploy.preserve).
|
|
# Pinning it here so a future change to that behaviour is a deliberate choice
|
|
# rather than an accident.
|
|
It "does NOT exclude a nested file that merely shares an excluded name" {
|
|
$e = Get-ArchivedEntries -Paths @("app.py", "backend/.env") -TopLevelExclude @(".env")
|
|
$e | Should -Contain "backend/.env"
|
|
}
|
|
|
|
It "excludes several names at once" {
|
|
$e = Get-ArchivedEntries -Paths @("app.py", ".env", "docs/guide.md", "archive/old.txt") `
|
|
-TopLevelExclude @(".env", "docs", "archive")
|
|
$e | Should -Be @("app.py")
|
|
}
|
|
|
|
It "is unaffected by an exclude naming something that isn't there" {
|
|
$e = Get-ArchivedEntries -Paths @("app.py") -TopLevelExclude @("nonexistent", "also-missing")
|
|
$e | Should -Contain "app.py"
|
|
}
|
|
}
|
|
|
|
Describe "New-ProjectArchive - recursive junk pruning" {
|
|
# NOTE: capture the -ForEach value into a named variable before any
|
|
# Where-Object. Inside a pipeline scriptblock $_ rebinds to the pipeline
|
|
# item, shadowing Pester's value - an assertion written as
|
|
# `$_ -like "*/$_/*"` silently matches nothing and the test passes while
|
|
# checking nothing at all.
|
|
It "prunes nested junk directory '<_>' (TopLevelExclude cannot reach it)" -ForEach @(".git", "node_modules", "__pycache__", ".pytest_cache", ".mypy_cache", ".tox", ".next", ".turbo", "coverage", "htmlcov", ".idea", ".vscode") {
|
|
$junkDir = $_
|
|
$e = Get-ArchivedEntries -Paths @("app.py", "src/$junkDir/junk.txt")
|
|
$e | Should -Not -Contain "src/$junkDir/junk.txt"
|
|
@($e | Where-Object { $_ -like "*/$junkDir/*" }).Count | Should -Be 0
|
|
$e | Should -Contain "app.py"
|
|
}
|
|
|
|
It "prunes top-level junk directory '<_>' even with no TopLevelExclude" -ForEach @(".git", "node_modules", "__pycache__", ".idea", ".vscode", "coverage") {
|
|
$junkDir = $_
|
|
$e = Get-ArchivedEntries -Paths @("app.py", "$junkDir/junk.txt")
|
|
$e | Should -Not -Contain "$junkDir/junk.txt"
|
|
$e | Should -Contain "app.py"
|
|
}
|
|
|
|
It "prunes a junk directory nested several levels deep" {
|
|
$e = Get-ArchivedEntries -Paths @("app.py", "a/b/c/node_modules/pkg/index.js")
|
|
@($e | Where-Object { $_ -like "*node_modules*" }).Count | Should -Be 0
|
|
$e | Should -Contain "app.py"
|
|
}
|
|
|
|
It "keeps a file whose name merely contains a junk directory name" {
|
|
$e = Get-ArchivedEntries -Paths @("app.py", "src/node_modules_notes.md")
|
|
$e | Should -Contain "src/node_modules_notes.md"
|
|
}
|
|
|
|
It "keeps a directory whose name merely starts with a junk directory name" {
|
|
$e = Get-ArchivedEntries -Paths @("app.py", "coverage-reports/summary.md")
|
|
$e | Should -Contain "coverage-reports/summary.md"
|
|
}
|
|
}
|
|
|
|
Describe "New-ProjectArchive - file filters" {
|
|
It "skips junk extension '<_>'" -ForEach @(".log", ".tmp", ".bak", ".old", ".orig", ".rej", ".swp", ".pyc", ".pyo", ".tsbuildinfo", ".db", ".sqlite", ".sqlite3") {
|
|
$e = Get-ArchivedEntries -Paths @("app.py", "artifact$_")
|
|
$e | Should -Not -Contain "artifact$_"
|
|
$e | Should -Contain "app.py"
|
|
}
|
|
|
|
It "skips nested archive '<_>' (no zips inside zips)" -ForEach @(".zip", ".tar", ".gz", ".7z", ".rar", ".iso", ".jar") {
|
|
$e = Get-ArchivedEntries -Paths @("app.py", "bundle$_")
|
|
$e | Should -Not -Contain "bundle$_"
|
|
}
|
|
|
|
It "skips OS junk file '<_>'" -ForEach @(".DS_Store", "Thumbs.db", "desktop.ini") {
|
|
$e = Get-ArchivedEntries -Paths @("app.py", "src/$_")
|
|
$e | Should -Not -Contain "src/$_"
|
|
}
|
|
|
|
It "keeps a source file whose extension is not filtered" {
|
|
$e = Get-ArchivedEntries -Paths @("app.py", "styles.css", "index.html", "data.json")
|
|
foreach ($f in @("app.py", "styles.css", "index.html", "data.json")) { $e | Should -Contain $f }
|
|
}
|
|
|
|
It "keeps an archive inside vendor/ - a vendored tarball is a build input" {
|
|
# Dockerfiles COPY vendor/ wholesale; dropping the tarball there fails
|
|
# the image build on the server, far from the filter that ate it.
|
|
$e = Get-ArchivedEntries -Paths @("app.py", "vendor/sdk-1.0.0.tgz")
|
|
$e | Should -Contain "vendor/sdk-1.0.0.tgz"
|
|
}
|
|
|
|
It "keeps an archive in a nested vendor/ directory" {
|
|
$e = Get-ArchivedEntries -Paths @("app.py", "packages/api/vendor/sdk.tgz")
|
|
$e | Should -Contain "packages/api/vendor/sdk.tgz"
|
|
}
|
|
|
|
It "still skips an archive outside vendor/ (the exemption is not global)" {
|
|
$e = Get-ArchivedEntries -Paths @("app.py", "assets/bundle.tgz")
|
|
$e | Should -Not -Contain "assets/bundle.tgz"
|
|
}
|
|
}
|
|
|
|
Describe "New-ProjectArchive - IncludeScriptFiles" {
|
|
It "skips <_> by default (local scripts are not part of a deploy)" -ForEach @(".ps1", ".cmd", ".bat") {
|
|
$e = Get-ArchivedEntries -Paths @("app.py", "helper$_")
|
|
$e | Should -Not -Contain "helper$_"
|
|
}
|
|
|
|
It "includes <_> when -IncludeScriptFiles is set (backing up the scripts folder itself)" -ForEach @(".ps1", ".cmd", ".bat") {
|
|
$e = Get-ArchivedEntries -Paths @("app.py", "helper$_") -IncludeScriptFiles
|
|
$e | Should -Contain "helper$_"
|
|
}
|
|
}
|
|
|
|
Describe "New-ProjectArchive - ExtraFiles" {
|
|
It "adds a file from outside the source tree at the archive root" {
|
|
$extraDir = Join-Path ([IO.Path]::GetTempPath()) ("zarc-extra-" + [guid]::NewGuid().ToString("N"))
|
|
New-Item -ItemType Directory -Path $extraDir -Force | Out-Null
|
|
$extra = Join-Path $extraDir "dump.sql"
|
|
"-- pg_dump output" | Set-Content -LiteralPath $extra -Encoding UTF8
|
|
try {
|
|
# This is how zbackup bundles a Postgres dump alongside the source.
|
|
$e = Get-ArchivedEntries -Paths @("app.py") -ExtraFiles @($extra)
|
|
$e | Should -Contain "dump.sql"
|
|
$e | Should -Contain "app.py"
|
|
}
|
|
finally { Remove-Item -LiteralPath $extraDir -Recurse -Force -ErrorAction SilentlyContinue }
|
|
}
|
|
|
|
It "ignores an ExtraFiles entry that does not exist" {
|
|
{ Get-ArchivedEntries -Paths @("app.py") -ExtraFiles @("C:\definitely\not\here.sql") } | Should -Not -Throw
|
|
}
|
|
}
|
|
|
|
Describe "New-ProjectArchive - realistic deploy shape" {
|
|
It "produces a clean python deploy zip: source in, secrets and cruft out" {
|
|
# The end-to-end shape of what zdeploy ships for a python project,
|
|
# using the real exclude list rather than a hand-written one.
|
|
$proj = [pscustomobject]@{
|
|
kind = "python"
|
|
deploy = [pscustomobject]@{ exclude = @("docs") }
|
|
}
|
|
$excl = Get-ArchiveExcludes -Project $proj
|
|
|
|
$e = Get-ArchivedEntries -TopLevelExclude $excl -Paths @(
|
|
"app/main.py",
|
|
"app/models.py",
|
|
"requirements.txt",
|
|
".env", # secret - excluded on deploy
|
|
".env.production", # secret - excluded on deploy
|
|
"uploads/user-file.png", # user data - excluded on deploy
|
|
"docs/design.md", # project's own deploy.exclude
|
|
".venv/Lib/site.py", # virtualenv
|
|
"__pycache__/main.pyc", # bytecode
|
|
"src/__pycache__/deep.pyc", # nested bytecode (recursive prune)
|
|
".git/config", # vcs
|
|
"debug.log" # junk extension
|
|
)
|
|
|
|
$e | Should -Contain "app/main.py"
|
|
$e | Should -Contain "app/models.py"
|
|
$e | Should -Contain "requirements.txt"
|
|
|
|
foreach ($unwanted in @(".env", ".env.production", "debug.log")) {
|
|
$e | Should -Not -Contain $unwanted
|
|
}
|
|
foreach ($prefix in @("uploads/", "docs/", ".venv/", "__pycache__/", ".git/")) {
|
|
@($e | Where-Object { $_ -like "$prefix*" }).Count | Should -Be 0
|
|
}
|
|
@($e | Where-Object { $_ -like "*__pycache__*" }).Count | Should -Be 0
|
|
}
|
|
|
|
It "keeps secrets and uploads in a BACKUP of the same tree" {
|
|
# Same source, -ForBackup excludes: a backup that dropped .env or
|
|
# uploads would not be a restore point.
|
|
$proj = [pscustomobject]@{ kind = "python" }
|
|
$excl = Get-ArchiveExcludes -Project $proj -ForBackup
|
|
|
|
$e = Get-ArchivedEntries -TopLevelExclude $excl -Paths @(
|
|
"app/main.py", ".env", "uploads/user-file.png", ".venv/Lib/site.py"
|
|
)
|
|
|
|
$e | Should -Contain ".env"
|
|
$e | Should -Contain "uploads/user-file.png"
|
|
@($e | Where-Object { $_ -like ".venv/*" }).Count | Should -Be 0 # still no virtualenv
|
|
}
|
|
}
|