diff --git a/deploy/ci/deployment-scripts.test.mjs b/deploy/ci/deployment-scripts.test.mjs index f9710fd1a..f355fbb90 100644 --- a/deploy/ci/deployment-scripts.test.mjs +++ b/deploy/ci/deployment-scripts.test.mjs @@ -1,14 +1,24 @@ 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, '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 @@ -50,6 +60,158 @@ 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-')); + 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', [pathRunner, 'path', 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', [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`, + `${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/ci/resolve-path-subprocess.sh b/deploy/ci/resolve-path-subprocess.sh new file mode 100644 index 000000000..3a30385a7 --- /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 diff --git a/deploy/scripts/lib.sh b/deploy/scripts/lib.sh index 53bde22b9..57512f9ed 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() {