fix(zdeploy): stop deleting operator-managed files on deploy

The project-directory replacement preserved only ./.env, silently
destroying every other server-side file (.env.db, staged signing keys,
certs) on every deploy — and the vite kind preserved nothing at all.

- preserve all .env* files at the project root by default
- new deploy.preserve array for additional files/directories
- implemented via tar to the home dir before the wipe, extract after
  the unzip; server-side copies win over zip contents (same semantics
  ./.env always had)
- helpers deliberately avoid embedded quotes and $( ): PowerShell 5.1
  strips embedded double quotes when passing args to ssh.exe, which
  silently corrupts remote commands (discovered when v1 of this fix
  failed exactly that way)

Fixes #2
This commit is contained in:
KellyMichels 2026-07-19 15:05:22 -05:00
parent 2cf83a74ab
commit 0892937ec8
4 changed files with 54 additions and 8 deletions

View File

@ -10,6 +10,17 @@ Notable changes to the Evomedia.net Token Savers.
## Unreleased ## Unreleased
### Fixed
- **`zdeploy` no longer deletes operator-managed files on deploy** (#2) —
the project-directory replacement preserved only `./.env`, silently
destroying every other server-side file (`.env.db`, staged signing
keys, certs) on every deploy. All `.env*` files at the project root are
now preserved by default, plus anything listed in the new
`deploy.preserve` array (files or directories); the vite kind, which
previously preserved nothing, gets the same protection. Found the hard
way: a first production deploy of an auth service wiped its staged DB
credentials and RSA signing keys.
### Added ### Added
- **`zdeploy` server-side health verification (`verify` block)** — projects - **`zdeploy` server-side health verification (`verify` block)** — projects
not published through the edge proxy can declare not published through the edge proxy can declare

View File

@ -175,7 +175,7 @@ zdeploy all [-Note "message"]
The core workflow, per project kind (projects with `deploy.gitPull` first `git pull --ff-only` so a merged PR isn't left behind): The core workflow, per project kind (projects with `deploy.gitPull` first `git pull --ff-only` so a merged PR isn't left behind):
- **python / vite / nextjs** — zip the local source (excluding `.git`, `node_modules`, envs, archives, junk, plus anything in `deploy.exclude`), free disk space on the server (docker prune; aborts if under 1.5 GB free), `scp` the zip up, unzip into `remote.path` preserving the server-side `.env`, `docker compose build` + `up -d`, then **verify the live site reports the new build version** (see [Enabling deploy verification](#enabling-deploy-verification)). nextjs additionally waits for Postgres (`db` block) and applies migrations (`migrations` field). Zips are always deleted locally afterward. - **python / vite / nextjs** — zip the local source (excluding `.git`, `node_modules`, envs, archives, junk, plus anything in `deploy.exclude`), free disk space on the server (docker prune; aborts if under 1.5 GB free), `scp` the zip up, unzip into `remote.path` preserving all server-side `.env*` files plus anything listed in `deploy.preserve` (staged keys, certs, seed data — files or directories), `docker compose build` + `up -d`, then **verify the live site reports the new build version** (see [Enabling deploy verification](#enabling-deploy-verification)). nextjs additionally waits for Postgres (`db` block) and applies migrations (`migrations` field). Zips are always deleted locally afterward.
- **edge** — uploads *every top-level file* in the edge folder (nginx.conf, compose, css, htpasswd, …), validates the new config with `nginx -t` before switching over, then recreates the proxy. - **edge** — uploads *every top-level file* in the edge folder (nginx.conf, compose, css, htpasswd, …), validates the new config with `nginx -t` before switching over, then recreates the proxy.
- **docker** — uploads the compose folder's files, `docker compose pull` + `up -d`. For stacks that run stock images (analytics, mail, etc.). - **docker** — uploads the compose folder's files, `docker compose pull` + `up -d`. For stacks that run stock images (analytics, mail, etc.).

View File

@ -43,7 +43,13 @@
"path": "/health", "path": "/health",
"expect": "\"status\":\"ok\"" "expect": "\"status\":\"ok\""
}, },
"deploy": { "zipName": "PyAppDeploy.zip", "gitPull": true, "exclude": ["docs"] } "deploy": {
"zipName": "PyAppDeploy.zip",
"gitPull": true,
"exclude": ["docs"],
"_comment": "preserve: server-side files/dirs in the project dir that deploys must never delete (.env* files are always preserved)",
"preserve": ["keys", "seed-data"]
}
}, },
"viteapp": { "viteapp": {

View File

@ -16,8 +16,9 @@
# zdeploy all -Note "weekly release" # zdeploy all -Note "weekly release"
# #
# Flow (python/vite/nextjs): zip source -> free server disk space -> scp up -> # Flow (python/vite/nextjs): zip source -> free server disk space -> scp up ->
# unzip into remote.path (preserving server-side .env) -> docker compose build + up # unzip into remote.path (preserving server-side .env* files and anything in
# -> verify the live site reports the new build version. Zips are always deleted. # deploy.preserve) -> docker compose build + up -> verify the live site reports
# the new build version. Zips are always deleted.
# #
# Compose service-name conventions (override with remote.appService): # Compose service-name conventions (override with remote.appService):
# python kind: app service "app", db service "db" # python kind: app service "app", db service "db"
@ -141,6 +142,32 @@ function Invoke-RemoteUnzip {
Invoke-Ec2Step "unzip $ZipName" $bash Invoke-Ec2Step "unzip $ZipName" $bash
} }
# ── Operator-file preservation (issue #2) ────────────────────────────────────
# Deploys replace the project directory wholesale, which used to destroy every
# operator-managed file except ./.env. These helpers preserve all .env* files
# at the project root PLUS any paths listed in deploy.preserve (files or
# directories), by tarring them to the home dir before the wipe and extracting
# them back after the unzip. Server-side copies win over anything shipped in
# the zip — the same semantics ./.env always had.
function Save-OperatorFiles {
param([string]$Key, $Proj, [string]$RemotePath)
$paths = @('.env*')
if ($Proj.deploy -and $Proj.deploy.preserve) { $paths += @($Proj.deploy.preserve) }
$spec = $paths -join ' '
$tarball = "$RemoteHome/preserve_${Key}.tgz"
# NOTE: no embedded quotes or $( ) here - PowerShell 5.1 strips embedded
# double quotes when passing args to ssh.exe, silently corrupting the
# remote command. Globs expand remotely; tar archives whatever exists
# and its nonzero exit for missing paths is deliberately swallowed.
Invoke-Ec2Step "preserve operator files ($spec)" "rm -f $tarball; cd $RemotePath && tar -czf $tarball $spec 2>/dev/null; true"
}
function Restore-OperatorFiles {
param([string]$Key, [string]$RemotePath)
$tarball = "$RemoteHome/preserve_${Key}.tgz"
Invoke-Ec2Step "restore operator files" "test -f $tarball && tar -xzf $tarball -C $RemotePath; rm -f $tarball; true"
}
# ── Deploy verification (build-version match, not just HTTP 200 — a 200 can be # ── Deploy verification (build-version match, not just HTTP 200 — a 200 can be
# a stale cached build; the version match proves the new build is live) ───── # a stale cached build; the version match proves the new build is live) ─────
@ -284,10 +311,10 @@ function Invoke-PythonDeploy {
Invoke-Ec2Step "apt-get install unzip" "sudo apt-get update -qq && sudo apt-get install -y unzip" Invoke-Ec2Step "apt-get install unzip" "sudo apt-get update -qq && sudo apt-get install -y unzip"
Invoke-Ec2Step "ensure stack root" "sudo mkdir -p $STACK_ROOT && sudo chown ${Ec2User}:${Ec2User} $STACK_ROOT" Invoke-Ec2Step "ensure stack root" "sudo mkdir -p $STACK_ROOT && sudo chown ${Ec2User}:${Ec2User} $STACK_ROOT"
Invoke-Ec2Step "ensure shared web network" "sudo docker network create web 2>/dev/null || true" Invoke-Ec2Step "ensure shared web network" "sudo docker network create web 2>/dev/null || true"
Invoke-Ec2Step "backup .env if present" "if [ -f $remotePath/.env ]; then cp $remotePath/.env $RemoteHome/.env.${Key}_bak; fi" Save-OperatorFiles -Key $Key -Proj $Proj -RemotePath $remotePath
Invoke-Ec2Step "replace project directory" "sudo rm -rf $remotePath && sudo mkdir -p $remotePath && sudo chown ${Ec2User}:${Ec2User} $remotePath" Invoke-Ec2Step "replace project directory" "sudo rm -rf $remotePath && sudo mkdir -p $remotePath && sudo chown ${Ec2User}:${Ec2User} $remotePath"
Invoke-RemoteUnzip -ZipName $zipName -DestPath $remotePath Invoke-RemoteUnzip -ZipName $zipName -DestPath $remotePath
Invoke-Ec2Step "restore .env from backup" "if [ -f $RemoteHome/.env.${Key}_bak ]; then cp $RemoteHome/.env.${Key}_bak $remotePath/.env; fi" Restore-OperatorFiles -Key $Key -RemotePath $remotePath
Invoke-Ec2Step "require compose directory" "test -d $composeDir" Invoke-Ec2Step "require compose directory" "test -d $composeDir"
Invoke-Ec2Step "docker compose build $appSvc" "cd $composeDir && sudo COMPOSE_BAKE=false docker compose build $appSvc" Invoke-Ec2Step "docker compose build $appSvc" "cd $composeDir && sudo COMPOSE_BAKE=false docker compose build $appSvc"
Invoke-Ec2Step "docker compose up -d" "cd $composeDir && sudo COMPOSE_BAKE=false docker compose up -d" Invoke-Ec2Step "docker compose up -d" "cd $composeDir && sudo COMPOSE_BAKE=false docker compose up -d"
@ -398,8 +425,10 @@ function Invoke-ViteDeploy {
Invoke-Ec2Step "apt-get install unzip" "sudo apt-get update -qq && sudo apt-get install -y unzip" Invoke-Ec2Step "apt-get install unzip" "sudo apt-get update -qq && sudo apt-get install -y unzip"
Invoke-Ec2Step "ensure stack root" "sudo mkdir -p $STACK_ROOT && sudo chown ${Ec2User}:${Ec2User} $STACK_ROOT" Invoke-Ec2Step "ensure stack root" "sudo mkdir -p $STACK_ROOT && sudo chown ${Ec2User}:${Ec2User} $STACK_ROOT"
Invoke-Ec2Step "ensure shared web network" "sudo docker network create web 2>/dev/null || true" Invoke-Ec2Step "ensure shared web network" "sudo docker network create web 2>/dev/null || true"
Save-OperatorFiles -Key $Key -Proj $Proj -RemotePath $remotePath
Invoke-Ec2Step "replace project directory" "sudo rm -rf $remotePath && sudo mkdir -p $remotePath && sudo chown ${Ec2User}:${Ec2User} $remotePath" Invoke-Ec2Step "replace project directory" "sudo rm -rf $remotePath && sudo mkdir -p $remotePath && sudo chown ${Ec2User}:${Ec2User} $remotePath"
Invoke-RemoteUnzip -ZipName $zipName -DestPath $remotePath Invoke-RemoteUnzip -ZipName $zipName -DestPath $remotePath
Restore-OperatorFiles -Key $Key -RemotePath $remotePath
Invoke-Ec2Step "require compose file" "test -f $remotePath/docker-compose.yml" Invoke-Ec2Step "require compose file" "test -f $remotePath/docker-compose.yml"
Invoke-Ec2Step "docker compose build" "cd $remotePath && sudo COMPOSE_BAKE=false docker compose build" Invoke-Ec2Step "docker compose build" "cd $remotePath && sudo COMPOSE_BAKE=false docker compose build"
Invoke-Ec2Step "docker compose up -d" "cd $remotePath && sudo COMPOSE_BAKE=false docker compose up -d" Invoke-Ec2Step "docker compose up -d" "cd $remotePath && sudo COMPOSE_BAKE=false docker compose up -d"
@ -464,10 +493,10 @@ function Invoke-NextDeploy {
Invoke-Ec2Step "ensure unzip installed" "sudo apt-get update -qq && sudo apt-get install -y unzip" Invoke-Ec2Step "ensure unzip installed" "sudo apt-get update -qq && sudo apt-get install -y unzip"
Invoke-Ec2Step "ensure stack root" "sudo mkdir -p $STACK_ROOT && sudo chown ${Ec2User}:${Ec2User} $STACK_ROOT" Invoke-Ec2Step "ensure stack root" "sudo mkdir -p $STACK_ROOT && sudo chown ${Ec2User}:${Ec2User} $STACK_ROOT"
Invoke-Ec2Step "ensure shared web network" "sudo docker network create web 2>/dev/null || true" Invoke-Ec2Step "ensure shared web network" "sudo docker network create web 2>/dev/null || true"
Invoke-Ec2Step "backup .env if present" "if [ -f $remotePath/.env ]; then cp $remotePath/.env $RemoteHome/.env.${Key}_bak; fi" Save-OperatorFiles -Key $Key -Proj $Proj -RemotePath $remotePath
Invoke-Ec2Step "replace project directory" "sudo rm -rf $remotePath && sudo mkdir -p $remotePath && sudo chown ${Ec2User}:${Ec2User} $remotePath" Invoke-Ec2Step "replace project directory" "sudo rm -rf $remotePath && sudo mkdir -p $remotePath && sudo chown ${Ec2User}:${Ec2User} $remotePath"
Invoke-RemoteUnzip -ZipName $zipName -DestPath $remotePath Invoke-RemoteUnzip -ZipName $zipName -DestPath $remotePath
Invoke-Ec2Step "restore .env from backup" "if [ -f $RemoteHome/.env.${Key}_bak ]; then cp $RemoteHome/.env.${Key}_bak $remotePath/.env; fi" Restore-OperatorFiles -Key $Key -RemotePath $remotePath
Write-Host "`n--- [4] Docker compose rebuild ---" -ForegroundColor Cyan Write-Host "`n--- [4] Docker compose rebuild ---" -ForegroundColor Cyan
Invoke-Ec2Step "docker compose down" "cd $remotePath && sudo COMPOSE_BAKE=false docker compose down" Invoke-Ec2Step "docker compose down" "cd $remotePath && sudo COMPOSE_BAKE=false docker compose down"