From 60f8074917c650e160e6e647971ee383f9673036 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 12 Nov 2026 05:34:46 +0000 Subject: [PATCH 1/2] [Fix] Backup destinations lose missing parent directories --- deploy/ci/deployment-scripts.test.mjs | 181 +++++++++++++++++++++++++- deploy/scripts/lib.sh | 11 +- 2 files changed, 188 insertions(+), 4 deletions(-) diff --git a/deploy/ci/deployment-scripts.test.mjs b/deploy/ci/deployment-scripts.test.mjs index f9710fd1af..f33f094fad 100644 --- a/deploy/ci/deployment-scripts.test.mjs +++ b/deploy/ci/deployment-scripts.test.mjs @@ -1,8 +1,16 @@ import { spawnSync } from 'node:child_process'; import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { resolve } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, + existsSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; const validationRunner = resolve( import.meta.dirname, @@ -50,6 +58,175 @@ test('deployment domains use valid DNS labels', () => { } }); +test('deployment paths preserve nested missing parents without creating them during resolution', () => { + const cwd = mkdtempSync(join(tmpdir(), 'roomote-paths-')); + const lib = resolve(import.meta.dirname, '..', 'scripts', 'lib.sh'); + try { + mkdirSync(join(cwd, 'existing dir')); + for (const path of [ + join(cwd, 'missing', 'nested', 'backups'), + 'missing/nested/backups', + 'missing parents/nested backups/', + 'existing dir/new leaf', + 'existing dir', + '-missing/nested', + ]) { + const result = spawnSync( + 'bash', + [ + '-c', + 'source "$1"; resolved=$(abs_path "$2"); printf "%s\\n" "$resolved"', + 'path-test', + lib, + path, + ], + { cwd, encoding: 'utf8' }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal( + result.stdout.trimEnd(), + path.startsWith('/') ? path : `${cwd}/${path}`, + ); + assert.equal(result.stderr, ''); + } + assert.equal(existsSync(join(cwd, 'missing')), false); + const state = spawnSync( + 'bash', + [ + '-c', + 'source "$1"; customer_state_dir demo; terraform_state_file demo; terraform_tfvars_file demo', + 'path-test', + lib, + ], + { + cwd, + encoding: 'utf8', + env: { + ...process.env, + ROOMOTE_DEPLOY_STATE_DIR: `${cwd}/missing/state`, + }, + }, + ); + assert.equal(state.status, 0, state.stderr); + assert.deepEqual(state.stdout.trim().split('\n'), [ + `${cwd}/missing/state/demo`, + `${cwd}/missing/state/demo/terraform.tfstate`, + `${cwd}/missing/state/demo/terraform.tfvars`, + ]); + assert.equal(existsSync(join(cwd, 'missing')), false); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test('backup downloads to the requested nested output through the actual CLI', () => { + const cwd = mkdtempSync(join(tmpdir(), 'roomote-backup-path-')); + const script = resolve( + import.meta.dirname, + '..', + 'scripts', + 'roomote-deploy', + ); + try { + const bin = join(cwd, 'bin'); + mkdirSync(bin); + writeFileSync(join(bin, 'ssh'), '#!/usr/bin/env bash\nexit 0\n', { + mode: 0o755, + }); + // Keep even a baseline regression from writing an accidental root path. + writeFileSync( + join(bin, 'mkdir'), + `#!/usr/bin/env bash +set -euo pipefail +destination="\${@: -1}" +case "$destination" in + "$PWD"/*) exec /bin/mkdir "$@" ;; + *) printf 'refused unexpected output directory: %s\\n' "$destination" >&2; exit 42 ;; +esac +`, + { mode: 0o755 }, + ); + writeFileSync( + join(bin, 'scp'), + `#!/usr/bin/env bash +set -euo pipefail +source_path="\${@: -2:1}" +destination="\${@: -1}" +if [[ "$source_path" == root@example.invalid:* ]]; then + printf 'disposable encrypted bundle fixture\\n' > "$destination/$(basename -- "$source_path")" +fi +`, + { mode: 0o755 }, + ); + const passphrase = join(cwd, 'passphrase'); + writeFileSync(passphrase, 'disposable passphrase\n'); + for (const output of [ + 'missing/nested backups', + join(cwd, 'absolute missing', 'backups'), + ]) { + const result = spawnSync( + 'bash', + [ + script, + 'backup', + '--customer', + 'demo', + '--host', + 'example.invalid', + '--output-dir', + output, + '--passphrase-file', + passphrase, + ], + { + cwd, + encoding: 'utf8', + env: { ...process.env, PATH: `${bin}:${process.env.PATH}` }, + }, + ); + assert.equal(result.status, 0, result.stderr); + const complete = result.stdout + .split('\n') + .find((line) => line.startsWith('Backup complete: ')); + assert.ok(complete, result.stdout); + const bundle = complete.slice('Backup complete: '.length); + assert.equal( + bundle, + `${output.startsWith('/') ? output : `${cwd}/${output}`}/${basename(bundle)}`, + ); + assert.equal( + readFileSync(bundle, 'utf8'), + 'disposable encrypted bundle fixture\n', + ); + } + const missingInput = spawnSync( + 'bash', + [ + script, + 'backup', + '--customer', + 'demo', + '--host', + 'example.invalid', + '--output-dir', + 'untouched/backups', + '--passphrase-file', + 'absent/secret', + ], + { + cwd, + encoding: 'utf8', + env: { ...process.env, PATH: `${bin}:${process.env.PATH}` }, + }, + ); + assert.equal(missingInput.status, 1); + assert.match(missingInput.stderr, /passphrase file not found or empty/); + assert.equal(existsSync(join(cwd, 'untouched')), false); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + test('successful deploys record the installed release after readiness', () => { for (const script of ['deploy.sh', 'upgrade.sh']) { const source = readFileSync( diff --git a/deploy/scripts/lib.sh b/deploy/scripts/lib.sh index 53bde22b97..57512f9ed7 100644 --- a/deploy/scripts/lib.sh +++ b/deploy/scripts/lib.sh @@ -25,8 +25,15 @@ abs_path() { fi local dir - dir="$(cd -- "$(dirname -- "$path")" && pwd)" - printf '%s/%s\n' "$dir" "$(basename -- "$path")" + if dir="$(cd -- "$(dirname -- "$path")" 2>/dev/null && pwd)"; then + printf '%s/%s\n' "$dir" "$(basename -- "$path")" + else + # Output and state directories may have multiple not-yet-created parents. + case "$path" in + /*) printf '%s\n' "$path" ;; + *) printf '%s/%s\n' "$PWD" "$path" ;; + esac + fi } state_root() { From f055f2005081064bceb44d7c120994e80171b0ce Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 12 Nov 2026 05:41:39 +0000 Subject: [PATCH 2/2] [Test] Run deployment path probes without inline shell commands --- deploy/ci/deployment-scripts.test.mjs | 41 +++++++++------------------ deploy/ci/resolve-path-subprocess.sh | 18 ++++++++++++ 2 files changed, 31 insertions(+), 28 deletions(-) create mode 100644 deploy/ci/resolve-path-subprocess.sh diff --git a/deploy/ci/deployment-scripts.test.mjs b/deploy/ci/deployment-scripts.test.mjs index f33f094fad..f355fbb906 100644 --- a/deploy/ci/deployment-scripts.test.mjs +++ b/deploy/ci/deployment-scripts.test.mjs @@ -17,6 +17,8 @@ const validationRunner = resolve( 'validate-domain-subprocess.sh', ); +const pathRunner = resolve(import.meta.dirname, 'resolve-path-subprocess.sh'); + function validateDomain(domain) { // Pass the runner script and the candidate domain as explicit positional // arguments to `bash` rather than interpolating either into a `-c` shell @@ -60,7 +62,6 @@ test('deployment domains use valid DNS labels', () => { test('deployment paths preserve nested missing parents without creating them during resolution', () => { const cwd = mkdtempSync(join(tmpdir(), 'roomote-paths-')); - const lib = resolve(import.meta.dirname, '..', 'scripts', 'lib.sh'); try { mkdirSync(join(cwd, 'existing dir')); for (const path of [ @@ -71,17 +72,10 @@ test('deployment paths preserve nested missing parents without creating them dur 'existing dir', '-missing/nested', ]) { - const result = spawnSync( - 'bash', - [ - '-c', - 'source "$1"; resolved=$(abs_path "$2"); printf "%s\\n" "$resolved"', - 'path-test', - lib, - path, - ], - { cwd, encoding: 'utf8' }, - ); + const result = spawnSync('bash', [pathRunner, 'path', path], { + cwd, + encoding: 'utf8', + }); assert.equal(result.status, 0, result.stderr); assert.equal( result.stdout.trimEnd(), @@ -90,23 +84,14 @@ test('deployment paths preserve nested missing parents without creating them dur assert.equal(result.stderr, ''); } assert.equal(existsSync(join(cwd, 'missing')), false); - const state = spawnSync( - 'bash', - [ - '-c', - 'source "$1"; customer_state_dir demo; terraform_state_file demo; terraform_tfvars_file demo', - 'path-test', - lib, - ], - { - cwd, - encoding: 'utf8', - env: { - ...process.env, - ROOMOTE_DEPLOY_STATE_DIR: `${cwd}/missing/state`, - }, + const state = spawnSync('bash', [pathRunner, 'state', 'demo'], { + cwd, + encoding: 'utf8', + env: { + ...process.env, + ROOMOTE_DEPLOY_STATE_DIR: `${cwd}/missing/state`, }, - ); + }); assert.equal(state.status, 0, state.stderr); assert.deepEqual(state.stdout.trim().split('\n'), [ `${cwd}/missing/state/demo`, diff --git a/deploy/ci/resolve-path-subprocess.sh b/deploy/ci/resolve-path-subprocess.sh new file mode 100644 index 0000000000..3a30385a72 --- /dev/null +++ b/deploy/ci/resolve-path-subprocess.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +source "$script_dir/../scripts/lib.sh" + +case "$1" in + path) + resolved="$(abs_path "$2")" + printf '%s\n' "$resolved" + ;; + state) + customer_state_dir "$2" + terraform_state_file "$2" + terraform_tfvars_file "$2" + ;; + *) exit 1 ;; +esac