From 2be454dfcfaf3ad439f013eefa6aa16434c754e7 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 30 Jul 2026 12:18:24 +0800 Subject: [PATCH] fix: recover staging deploy tree ownership --- deploy/sync-staging-tree.sh | 10 +++- .../tests/staging-backend-workflows.test.ts | 56 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/deploy/sync-staging-tree.sh b/deploy/sync-staging-tree.sh index 005766fa..b67623fc 100755 --- a/deploy/sync-staging-tree.sh +++ b/deploy/sync-staging-tree.sh @@ -6,7 +6,15 @@ if [ "$#" -ne 2 ] || [ ! -d "$1" ] || [ ! -d "$2" ]; then exit 1 fi -rsync -az --delete \ +destination_deploy="$2/deploy" +if [ -d "$destination_deploy" ] && [ ! -w "$destination_deploy" ]; then + docker run --rm --network none --read-only --user 0:0 \ + --cap-drop ALL --cap-add CHOWN --security-opt no-new-privileges \ + -v "$destination_deploy:/destination" postgres:17-alpine \ + chown -R "$(id -u):$(id -g)" /destination +fi + +rsync -az --delete --no-owner --no-group \ --exclude='/.git/' \ --exclude='/.env*' \ --exclude='/.docker/' \ diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index a9bde16d..a5fc8777 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -153,6 +153,62 @@ test("live staging sync preserves env, state, incoming files, and encrypted back } }); +test("live staging sync repairs a non-writable deploy tree without preserving foreign ownership", () => { + const root = mkdtempSync(join(tmpdir(), "jyotisha-live-sync-permissions-")); + const source = join(root, "source"); + const destination = join(root, "destination"); + const destinationDeploy = join(destination, "deploy"); + const mockBin = join(root, "bin"); + const dockerLog = join(root, "docker.log"); + mkdirSync(join(source, "deploy"), { recursive: true }); + mkdirSync(destinationDeploy, { recursive: true }); + mkdirSync(mockBin); + writeFileSync(join(source, "deploy", "current.txt"), "new\n"); + writeFileSync(join(destinationDeploy, "stale.txt"), "old\n"); + writeFileSync( + join(mockBin, "docker"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "printf '%s\\n' \"$*\" >\"$DOCKER_LOG\"", + 'chmod -R u+rwX "$REPAIR_DESTINATION"', + "", + ].join("\n"), + ); + chmodSync(join(mockBin, "docker"), 0o755); + chmodSync(destinationDeploy, 0o555); + + try { + const result = spawnSync( + "bash", + [fileURLToPath(syncScript), source, destination], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${mockBin}:${process.env.PATH ?? ""}`, + DOCKER_LOG: dockerLog, + REPAIR_DESTINATION: destinationDeploy, + }, + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(join(destinationDeploy, "stale.txt")), false); + assert.equal( + readFileSync(join(destinationDeploy, "current.txt"), "utf8"), + "new\n", + ); + const invocation = readFileSync(dockerLog, "utf8"); + assert.match(invocation, /--network none --read-only --user 0:0/); + assert.match(invocation, /--cap-drop ALL --cap-add CHOWN/); + assert.match(invocation, /postgres:17-alpine chown -R/); + assert.match(read(syncScript), /--no-owner --no-group/); + } finally { + if (existsSync(destinationDeploy)) chmodSync(destinationDeploy, 0o755); + rmSync(root, { recursive: true, force: true }); + } +}); + test("all staging mutations share Actions serialization and one host lock", () => { const deployment = read(deployWorkflow); const migration = read(migrationWorkflow);