DC-050 harden dataDir + add image-layer migration
Three-part fix for the silent data-loss failure mode that survives DC-039:
If SERVICES_FILE env was unset, platformPaths.dataDir resolved to /etc/dashcaddy
(image-layer path), and audit/license/error logs would silently land there and
vanish on every container recreate.
1. platform-paths.assertSafe({mode:'production'}) — throws FATAL on forbidden
zones (/app/src,routes,scripts,utils,managers,security + /etc/* + /usr + /var).
Bypassed with SKIP_DATA_DIR_GUARD=1.
2. server.js calls assertSafe() before any runtime work.
3. start.sh one-time migration: scans 6 known image-layer zombie paths,
copies non-empty content to bind mount with 'migrated-' prefix,
gated by sentinel file. Survives set -e per-file failures.
19/19 platform-paths tests + 5/5 shell migration tests.
Suite: 1066/1067 (1 pre-existing public-routes-drift failure from in-flight
auth refactor, untouched by this commit).
Verified live on DNS2: live audit log at /app/data/audit-log.json (315KB,
active) is unaffected; vestigial 2-byte /app/src/security/audit-log.json +
140KB /app/src/utils/error.log (pre-DC-039 era) will be recovered on next
container recreate.
This commit is contained in:
Executable
+192
@@ -0,0 +1,192 @@
|
||||
#!/bin/bash
|
||||
# DC-039 follow-up — regression test for start.sh image-layer migration step.
|
||||
# Validates: idempotency, partial files, missing files, sentinel creation,
|
||||
# set -e doesn't kill the script on a single per-file failure.
|
||||
|
||||
set -u
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
FAILURES=0
|
||||
|
||||
pass() { echo " ✓ $1"; }
|
||||
fail() { echo " ✗ $1"; FAILURES=$((FAILURES + 1)); }
|
||||
|
||||
# ---- Setup helpers ----------------------------------------------------------
|
||||
# Source only the migration function out of start.sh — don't run the whole
|
||||
# script (it would try to bind to port 3001 + manage docker). Use the same
|
||||
# sh-extraction pattern as test-dashcaddy-update-backup.sh.
|
||||
|
||||
fresh_data_dir() {
|
||||
local d
|
||||
d="$(mktemp -d /tmp/dashcaddy-migration-test.XXXXXX)"
|
||||
echo "${d}"
|
||||
}
|
||||
|
||||
clean_data_dir() {
|
||||
rm -rf "$1" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Extract just the migration logic — it's the only block we want to test.
|
||||
extract_migration() {
|
||||
sed -n '/^MIGRATION_SENTINEL=/,/^run_image_layer_migration$/p' "${SCRIPT_DIR}/../start.sh"
|
||||
}
|
||||
|
||||
# Test 1: Sentinel file present → migration skips entirely
|
||||
echo "Test 1: sentinel exists → no copies"
|
||||
DATA_DIR="$(fresh_data_dir)"
|
||||
touch "${DATA_DIR}/.migrated-from-image-layer"
|
||||
extract_migration > /tmp/_migration_extract.sh
|
||||
# Override DATA_DIR to point at our test dir
|
||||
# Strip the actual call (the trailing 'run_image_layer_migration') so we
|
||||
# control invocation; in tests we re-define DATA_DIR first.
|
||||
{
|
||||
echo "DATA_DIR='${DATA_DIR}'"
|
||||
echo "MIGRATION_SENTINEL=\"\${DATA_DIR}/.migrated-from-image-layer\""
|
||||
sed -n '/^IMAGE_LAYER_ZOMBIES=(/,/^)$/p' "${SCRIPT_DIR}/../start.sh"
|
||||
sed -n '/^run_image_layer_migration()/,/^}$/p' "${SCRIPT_DIR}/../start.sh"
|
||||
} > /tmp/_migration_block.sh
|
||||
# shellcheck disable=SC1091
|
||||
source /tmp/_migration_block.sh
|
||||
# Plant a fake zombie that should NOT be migrated because the sentinel exists
|
||||
ZOMBIE_DIR="$(mktemp -d /tmp/dashcaddy-zombie.XXXXXX)"
|
||||
mkdir -p "${ZOMBIE_DIR}/security"
|
||||
echo '{"data":"should not be migrated"}' > "${ZOMBIE_DIR}/security/audit-log.json"
|
||||
run_image_layer_migration
|
||||
if [ -f "${DATA_DIR}/migrated-audit-log.json" ]; then
|
||||
fail "test 1: sentinel existed, migration should have skipped but a file appeared"
|
||||
else
|
||||
pass "sentinel skipped migration cleanly"
|
||||
fi
|
||||
rm -rf "${ZOMBIE_DIR}"
|
||||
clean_data_dir "${DATA_DIR}"
|
||||
|
||||
# Test 2: No sentinel + non-empty zombie → migration copies file
|
||||
echo "Test 2: zombie file present → migration copies to bind mount"
|
||||
DATA_DIR="$(fresh_data_dir)"
|
||||
ZOMBIE_DIR="$(mktemp -d /tmp/dashcaddy-zombie.XXXXXX)"
|
||||
mkdir -p "${ZOMBIE_DIR}/security" "${ZOMBIE_DIR}/managers"
|
||||
echo '{"audit":"prod data"}' > "${ZOMBIE_DIR}/security/audit-log.json"
|
||||
echo "license-secret-blob" > "${ZOMBIE_DIR}/managers/.license-secret"
|
||||
{
|
||||
echo "DATA_DIR='${DATA_DIR}'"
|
||||
echo "MIGRATION_SENTINEL=\"\${DATA_DIR}/.migrated-from-image-layer\""
|
||||
sed -n '/^IMAGE_LAYER_ZOMBIES=(/,/^)$/p' "${SCRIPT_DIR}/../start.sh"
|
||||
sed -n '/^run_image_layer_migration()/,/^}$/p' "${SCRIPT_DIR}/../start.sh"
|
||||
} > /tmp/_migration_block2.sh
|
||||
# Stub out the real zombie paths to point at our temp zombie
|
||||
sed -i "s|/opt/dashcaddy/dashcaddy-api/src/security/audit-log.json|${ZOMBIE_DIR}/security/audit-log.json|g" /tmp/_migration_block2.sh
|
||||
sed -i "s|/opt/dashcaddy/dashcaddy-api/src/managers/.license-secret|${ZOMBIE_DIR}/managers/.license-secret|g" /tmp/_migration_block2.sh
|
||||
# shellcheck disable=SC1091
|
||||
source /tmp/_migration_block2.sh
|
||||
run_image_layer_migration
|
||||
if [ ! -f "${DATA_DIR}/migrated-audit-log.json" ]; then
|
||||
fail "test 2: audit-log.json not migrated"
|
||||
elif ! grep -q "audit.*prod data" "${DATA_DIR}/migrated-audit-log.json"; then
|
||||
fail "test 2: audit-log.json migrated but content corrupt"
|
||||
else
|
||||
pass "audit-log.json migrated with correct content"
|
||||
fi
|
||||
if [ ! -f "${DATA_DIR}/migrated-.license-secret" ]; then
|
||||
fail "test 2: .license-secret not migrated"
|
||||
else
|
||||
pass ".license-secret migrated"
|
||||
fi
|
||||
if [ ! -f "${DATA_DIR}/.migrated-from-image-layer" ]; then
|
||||
fail "test 2: sentinel file was not written"
|
||||
else
|
||||
pass "sentinel file written"
|
||||
fi
|
||||
rm -rf "${ZOMBIE_DIR}"
|
||||
clean_data_dir "${DATA_DIR}"
|
||||
|
||||
# Test 3: Idempotency — running migration twice does NOT clobber first copy
|
||||
echo "Test 3: idempotency"
|
||||
DATA_DIR="$(fresh_data_dir)"
|
||||
ZOMBIE_DIR="$(mktemp -d /tmp/dashcaddy-zombie.XXXXXX)"
|
||||
mkdir -p "${ZOMBIE_DIR}/security"
|
||||
echo '{"first":true}' > "${ZOMBIE_DIR}/security/audit-log.json"
|
||||
{
|
||||
echo "DATA_DIR='${DATA_DIR}'"
|
||||
echo "MIGRATION_SENTINEL=\"\${DATA_DIR}/.migrated-from-image-layer\""
|
||||
sed -n '/^IMAGE_LAYER_ZOMBIES=(/,/^)$/p' "${SCRIPT_DIR}/../start.sh"
|
||||
sed -n '/^run_image_layer_migration()/,/^}$/p' "${SCRIPT_DIR}/../start.sh"
|
||||
} > /tmp/_migration_block3.sh
|
||||
sed -i "s|/opt/dashcaddy/dashcaddy-api/src/security/audit-log.json|${ZOMBIE_DIR}/security/audit-log.json|g" /tmp/_migration_block3.sh
|
||||
# shellcheck disable=SC1091
|
||||
source /tmp/_migration_block3.sh
|
||||
run_image_layer_migration
|
||||
echo '{"second":true}' > "${ZOMBIE_DIR}/security/audit-log.json" # mutate the source after migration
|
||||
run_image_layer_migration
|
||||
if grep -q "first.*true" "${DATA_DIR}/migrated-audit-log.json"; then
|
||||
pass "second run did not overwrite first migrated content"
|
||||
else
|
||||
fail "second run overwrote the migrated file"
|
||||
fi
|
||||
rm -rf "${ZOMBIE_DIR}"
|
||||
clean_data_dir "${DATA_DIR}"
|
||||
|
||||
# Test 4: Zero-byte zombie (empty file) → NOT migrated
|
||||
echo "Test 4: empty file is not migrated"
|
||||
DATA_DIR="$(fresh_data_dir)"
|
||||
ZOMBIE_DIR="$(mktemp -d /tmp/dashcaddy-zombie.XXXXXX)"
|
||||
mkdir -p "${ZOMBIE_DIR}/security"
|
||||
touch "${ZOMBIE_DIR}/security/audit-log.json" # zero bytes
|
||||
{
|
||||
echo "DATA_DIR='${DATA_DIR}'"
|
||||
echo "MIGRATION_SENTINEL=\"\${DATA_DIR}/.migrated-from-image-layer\""
|
||||
sed -n '/^IMAGE_LAYER_ZOMBIES=(/,/^)$/p' "${SCRIPT_DIR}/../start.sh"
|
||||
sed -n '/^run_image_layer_migration()/,/^}$/p' "${SCRIPT_DIR}/../start.sh"
|
||||
} > /tmp/_migration_block4.sh
|
||||
sed -i "s|/opt/dashcaddy/dashcaddy-api/src/security/audit-log.json|${ZOMBIE_DIR}/security/audit-log.json|g" /tmp/_migration_block4.sh
|
||||
# shellcheck disable=SC1091
|
||||
source /tmp/_migration_block4.sh
|
||||
run_image_layer_migration
|
||||
if [ -f "${DATA_DIR}/migrated-audit-log.json" ]; then
|
||||
fail "test 4: empty file should not be migrated"
|
||||
else
|
||||
pass "empty file correctly skipped"
|
||||
fi
|
||||
if [ -f "${DATA_DIR}/.migrated-from-image-layer" ]; then
|
||||
pass "sentinel still written even with zero zombies"
|
||||
else
|
||||
fail "sentinel should still be written even with no zombies"
|
||||
fi
|
||||
rm -rf "${ZOMBIE_DIR}"
|
||||
clean_data_dir "${DATA_DIR}"
|
||||
|
||||
# Test 5: set -e present + all per-file failures → script doesn't take down container
|
||||
echo "Test 5: a single per-file failure does not bring down the container"
|
||||
DATA_DIR="$(fresh_data_dir)"
|
||||
ZOMBIE_DIR="$(mktemp -d /tmp/dashcaddy-zombie.XXXXXX)"
|
||||
mkdir -p "${ZOMBIE_DIR}/security"
|
||||
echo "x" > "${ZOMBIE_DIR}/security/audit-log.json"
|
||||
chmod 000 "${ZOMBIE_DIR}/security/audit-log.json" # make it unreadable so cp -a fails
|
||||
{
|
||||
set -e # NOW we need to verify the inner guard prevents set -e from killing us
|
||||
echo "DATA_DIR='${DATA_DIR}'"
|
||||
echo "MIGRATION_SENTINEL=\"\${DATA_DIR}/.migrated-from-image-layer\""
|
||||
sed -n '/^IMAGE_LAYER_ZOMBIES=(/,/^)$/p' "${SCRIPT_DIR}/../start.sh"
|
||||
sed -n '/^run_image_layer_migration()/,/^}$/p' "${SCRIPT_DIR}/../start.sh"
|
||||
} > /tmp/_migration_block5.sh
|
||||
sed -i "s|/opt/dashcaddy/dashcaddy-api/src/security/audit-log.json|${ZOMBIE_DIR}/security/audit-log.json|g" /tmp/_migration_block5.sh
|
||||
EXIT=0
|
||||
# shellcheck disable=SC1091
|
||||
source /tmp/_migration_block5.sh && run_image_layer_migration || EXIT=$?
|
||||
chmod 644 "${ZOMBIE_DIR}/security/audit-log.json" 2>/dev/null || true
|
||||
rm -rf "${ZOMBIE_DIR}"
|
||||
clean_data_dir "${DATA_DIR}"
|
||||
if [ "$EXIT" -eq 0 ]; then
|
||||
pass "script survived a per-file cp failure"
|
||||
else
|
||||
fail "set -e propagated a per-file failure (exit ${EXIT}); container would not boot"
|
||||
fi
|
||||
|
||||
# ---- Cleanup ----------------------------------------------------------------
|
||||
rm -f /tmp/_migration_extract.sh /tmp/_migration_block*.sh
|
||||
|
||||
echo
|
||||
if [ "$FAILURES" -eq 0 ]; then
|
||||
echo "All migration regression tests passed."
|
||||
exit 0
|
||||
fi
|
||||
echo "${FAILURES} test(s) failed."
|
||||
exit 1
|
||||
Reference in New Issue
Block a user