From 0892937ec8b0b6e96f8e9e462347837962649141 Mon Sep 17 00:00:00 2001 From: KellyMichels Date: Sun, 19 Jul 2026 15:05:22 -0500 Subject: [PATCH] fix(zdeploy): stop deleting operator-managed files on deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 11 +++++++++++ README.md | 2 +- zconfig.example.json | 8 +++++++- zdeploy.ps1 | 41 +++++++++++++++++++++++++++++++++++------ 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da35879..ebc3d52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ Notable changes to the Evomedia.net Token Savers. ## 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 - **`zdeploy` server-side health verification (`verify` block)** — projects not published through the edge proxy can declare diff --git a/README.md b/README.md index ab36f4a..357195f 100644 --- a/README.md +++ b/README.md @@ -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): -- **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. - **docker** — uploads the compose folder's files, `docker compose pull` + `up -d`. For stacks that run stock images (analytics, mail, etc.). diff --git a/zconfig.example.json b/zconfig.example.json index 403ccbc..bfa96cd 100644 --- a/zconfig.example.json +++ b/zconfig.example.json @@ -43,7 +43,13 @@ "path": "/health", "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": { diff --git a/zdeploy.ps1 b/zdeploy.ps1 index 6d83a38..5092170 100644 --- a/zdeploy.ps1 +++ b/zdeploy.ps1 @@ -16,8 +16,9 @@ # zdeploy all -Note "weekly release" # # Flow (python/vite/nextjs): zip source -> free server disk space -> scp up -> -# unzip into remote.path (preserving server-side .env) -> docker compose build + up -# -> verify the live site reports the new build version. Zips are always deleted. +# unzip into remote.path (preserving server-side .env* files and anything in +# 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): # python kind: app service "app", db service "db" @@ -141,6 +142,32 @@ function Invoke-RemoteUnzip { 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 # 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 "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 "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-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 "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" @@ -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 "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" + 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-RemoteUnzip -ZipName $zipName -DestPath $remotePath + Restore-OperatorFiles -Key $Key -RemotePath $remotePath 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 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 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 "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-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 Invoke-Ec2Step "docker compose down" "cd $remotePath && sudo COMPOSE_BAKE=false docker compose down"