Refactor Tachyon installer into resilient transactional flow (#61)

Replace the monolithic installer with a staged transactional flow, add atomic locking, APK lock waiting/diagnostics, tmpfs and overlay preflight, verified release downloads, recovery snapshots, package transaction healthchecks, and active installer regression tests.
This commit is contained in:
Dushnilin
2026-09-17 15:26:07 +03:00
committed by GitHub
parent a27173a04a
commit 3bcce51ec3
6 changed files with 968 additions and 3406 deletions
+46 -164
View File
@@ -3,173 +3,55 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
INSTALLER="$ROOT_DIR/install.sh"
WORK_DIR="$(mktemp -d)"
MAKEFILE="$ROOT_DIR/tachyon/Makefile"
MIGRATION="$ROOT_DIR/tachyon/files/usr/lib/config/migration.uc"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
[ -r "$INSTALLER" ] || fail "install.sh is missing"
[ -r "$MAKEFILE" ] || fail "tachyon/Makefile is missing"
[ -r "$MIGRATION" ] || fail "runtime migration module is missing"
# 1. Extract embedded ucode helper and verify its syntax
helper="$WORK_DIR/install-json.uc"
# Installer v3 deliberately does not duplicate legacy conversion logic. Package
# postinst owns the move into /etc/config/tachyon and the runtime migration module
# owns schema conversion.
if grep -Fq 'installer_cleanup_legacy' "$INSTALLER"; then
fail "installer must not embed legacy cleanup/migration ownership"
fi
if grep -Fq 'migrate-podkop' "$INSTALLER"; then
fail "installer must delegate legacy schema migration to package/runtime code"
fi
grep -Fq 'if [ -f /etc/config/netshift ]; then' "$MAKEFILE" ||
fail "package postinst must detect NetShift config"
grep -Fq 'elif [ -f /etc/config/forkop ]; then' "$MAKEFILE" ||
fail "package postinst must detect Forkop config"
grep -Fq 'elif [ -f /etc/config/podkop ]; then' "$MAKEFILE" ||
fail "package postinst must detect Podkop config"
grep -Fq '/usr/lib/tachyon/config/migration.uc migrate-podkop' "$MAKEFILE" ||
fail "legacy package postinst must call migrate-podkop"
grep -Fq 'migrate-podkop' "$MIGRATION" ||
fail "runtime migration module must expose migrate-podkop"
# Before package hooks can move/remove legacy files, the installer takes a safety
# snapshot of every supported legacy config.
for path in \
/etc/config/netshift \
/etc/config/forkop \
/etc/config/forkop_plus \
/etc/config/podkop \
/etc/config/podkop_plus; do
grep -Fq "$path" "$INSTALLER" ||
fail "installer recovery snapshot must include $path"
done
grep -Fq 'snapshot_state || fail "Could not create recovery snapshot"' "$INSTALLER" ||
fail "recovery snapshot must be created before package installation"
awk '
/cat > "\$helper_path" <<'\''EOF'\''/ { capture = 1; next }
capture && /^EOF$/ { exit }
capture { print }
' "$INSTALLER" > "$helper"
[ -s "$helper" ] || fail "failed to extract embedded installer ucode helper"
/^main\(\)/ { in_main=1 }
in_main && /snapshot_state/ && !snapshot { snapshot=NR }
in_main && /install_core_transaction/ && !install { install=NR }
END { exit !(snapshot > 0 && install > snapshot) }
' "$INSTALLER" || fail "legacy configs must be snapshotted before package hooks run"
if command -v ucode >/dev/null 2>&1; then
ucode -c -o /dev/null "$helper" || fail "embedded ucode helper failed syntax check"
fi
# 2. Test installer_cleanup_legacy with mock Forkop environment
MOCK_ROOT="$WORK_DIR/rootfs"
mkdir -p "$MOCK_ROOT/etc/config"
mkdir -p "$MOCK_ROOT/etc/init.d"
mkdir -p "$MOCK_ROOT/usr/bin"
mkdir -p "$MOCK_ROOT/usr/lib/forkop"
mkdir -p "$MOCK_ROOT/tmp"
mkdir -p "$MOCK_ROOT/var/run/forkop"
cat >"$MOCK_ROOT/etc/config/forkop" <<'EOF'
config settings 'settings'
option enabled '1'
EOF
cat >"$MOCK_ROOT/etc/init.d/forkop" <<'EOF'
#!/usr/bin/env sh
case "$1" in
enabled) exit 0 ;;
running) exit 0 ;;
stop|disable) exit 0 ;;
*) exit 0 ;;
esac
EOF
chmod 0755 "$MOCK_ROOT/etc/init.d/forkop"
cat >"$MOCK_ROOT/usr/bin/forkop" <<'EOF'
#!/usr/bin/env sh
case "$1" in
get_status) printf '{"running":1}\n' ;;
restore_dnsmasq) exit 0 ;;
*) exit 0 ;;
esac
EOF
chmod 0755 "$MOCK_ROOT/usr/bin/forkop"
# Mock opkg
MOCK_BIN="$WORK_DIR/bin"
mkdir -p "$MOCK_BIN"
cat >"$MOCK_BIN/opkg" <<'EOF'
#!/usr/bin/env sh
case "$1" in
list-installed)
printf '%s\n' \
'forkop - 1.0.4' \
'luci-app-forkop - 1.0.4' \
'luci-i18n-forkop-ru - 1.0.4'
;;
remove)
shift
[ "$1" = "--force-depends" ] && shift
printf 'removed: %s\n' "$1" >> "$TMP_DIR/opkg-removed.log"
;;
esac
exit 0
EOF
chmod 0755 "$MOCK_BIN/opkg"
# Run installer-cleanup-legacy in mock environment
if command -v ucode >/dev/null 2>&1; then
export PATH="$MOCK_BIN:$PATH"
export TMP_DIR="$WORK_DIR"
state_output="$(
TACHYON_LEGACY_DETECTED=1 \
TACHYON_INSTALLER_OPKG_BIN="$MOCK_BIN/opkg" \
TACHYON_INSTALLER_TACHYON_INIT="$WORK_DIR/no-init" \
TACHYON_INSTALLER_TACHYON_BIN="$WORK_DIR/no-bin" \
TACHYON_INSTALLER_TACHYON_LIB="$WORK_DIR/no-lib" \
TACHYON_INSTALLER_TACHYON_UCI_DEFAULTS="$WORK_DIR/no-defaults" \
TACHYON_INSTALLER_TACHYON_LUCI_VIEW="$WORK_DIR/no-view" \
TACHYON_INSTALLER_MENU_JSON="$WORK_DIR/no-menu" \
TACHYON_INSTALLER_ACL_JSON="$WORK_DIR/no-acl" \
TACHYON_INSTALLER_RU_LMO="$WORK_DIR/no-ru-lmo" \
TACHYON_INSTALLER_EN_LMO="$WORK_DIR/no-en-lmo" \
TACHYON_INSTALLER_RU_LUA="$WORK_DIR/no-ru-lua" \
TACHYON_INSTALLER_EN_LUA="$WORK_DIR/no-en-lua" \
TACHYON_INSTALLER_LEGACY_INIT="$WORK_DIR/no-leg-init" \
TACHYON_INSTALLER_LEGACY_BIN="$WORK_DIR/no-leg-bin" \
TACHYON_INSTALLER_LEGACY_LIB="$WORK_DIR/no-leg-lib" \
TACHYON_INSTALLER_LEGACY_UCI_DEFAULTS="$WORK_DIR/no-leg-defaults" \
TACHYON_INSTALLER_LEGACY_LUCI_VIEW="$WORK_DIR/no-leg-view" \
TACHYON_INSTALLER_LEGACY_MENU_JSON="$WORK_DIR/no-leg-menu" \
TACHYON_INSTALLER_LEGACY_ACL_JSON="$WORK_DIR/no-leg-acl" \
TACHYON_INSTALLER_LEGACY_BASE_INIT="$WORK_DIR/no-base-init" \
TACHYON_INSTALLER_LEGACY_BASE_BIN="$WORK_DIR/no-base-bin" \
TACHYON_INSTALLER_LEGACY_BASE_LIB="$WORK_DIR/no-base-lib" \
TACHYON_INSTALLER_LEGACY_BASE_UCI_DEFAULTS="$WORK_DIR/no-base-defaults" \
TACHYON_INSTALLER_LEGACY_BASE_LUCI_VIEW="$WORK_DIR/no-base-view" \
TACHYON_INSTALLER_LEGACY_BASE_MENU_JSON="$WORK_DIR/no-base-menu" \
TACHYON_INSTALLER_LEGACY_BASE_ACL_JSON="$WORK_DIR/no-base-acl" \
TACHYON_INSTALLER_LEGACY_BASE_I18N="$WORK_DIR/no-base-i18n" \
TACHYON_INSTALLER_LEGACY_BRAND="podkop" \
TACHYON_INSTALLER_LEGACY_BACKEND="podkop-plus" \
ucode "$helper" installer-cleanup-legacy
)"
printf '%s\n' "$state_output" | grep -Fxq 'TACHYON_LEGACY_DETECTED=1' ||
fail "installer_cleanup_legacy must report TACHYON_LEGACY_DETECTED=1 when Forkop is installed"
# 3. Test post-install when TACHYON_LEGACY_DETECTED=1
# reset_settings must NOT be executed
cat >"$WORK_DIR/mock-tachyon-bin" <<'EOF'
#!/usr/bin/env sh
if [ "$1" = "reset_settings" ]; then
echo "CALLED_RESET_SETTINGS" >> "$TMP_DIR/reset_settings.called"
fi
exit 0
EOF
chmod 0755 "$WORK_DIR/mock-tachyon-bin"
TACHYON_WAS_INSTALLED=0 \
TACHYON_LEGACY_DETECTED=1 \
TACHYON_WAS_ENABLED=1 \
TACHYON_WAS_RUNNING=1 \
TACHYON_INSTALLER_TACHYON_BIN="$WORK_DIR/mock-tachyon-bin" \
TACHYON_INSTALLER_TACHYON_INIT="$WORK_DIR/mock-tachyon-bin" \
ucode "$helper" installer-post-install
if [ -f "$WORK_DIR/reset_settings.called" ]; then
fail "installer_post_install must not invoke reset_settings when legacy migration is active"
fi
fi
# 4. Static checks on install.sh to ensure Forkop / Netshift migration contracts
grep -Fq 'TACHYON_FORKOP_MIGRATION=1' "$INSTALLER" ||
fail "install.sh must set TACHYON_FORKOP_MIGRATION flag"
grep -Fq 'TACHYON_NETSHIFT_MIGRATION=1' "$INSTALLER" ||
fail "install.sh must set TACHYON_NETSHIFT_MIGRATION flag"
grep -Fq 'legacy_detected_before="$TACHYON_LEGACY_DETECTED"' "$INSTALLER" ||
fail "cleanup_legacy_installation must guard TACHYON_LEGACY_DETECTED against being overwritten to 0"
grep -Fq 'installer_package_installed("forkop")' "$INSTALLER" ||
fail "installer_cleanup_legacy must detect forkop packages"
grep -Fq 'installer_package_installed("netshift")' "$INSTALLER" ||
fail "installer_cleanup_legacy must detect netshift packages"
grep -Fq '"/etc/config/forkop"' "$INSTALLER" ||
fail "installer must reference /etc/config/forkop for migration and cleanup"
printf 'Forkop migration installer tests passed successfully.\n'
printf 'PASS: legacy migration ownership and installer recovery contract\n'
+54 -122
View File
@@ -1,138 +1,70 @@
#!/usr/bin/env bash
# A rebuilt release keeps its version string, so "the package is installed and the
# version matches" stays true even when the package manager rolled the upgrade
# back — which is how a failed install used to end with a green success banner.
# The only thing that moves is the commit SHA baked into the installed constants
# module, so these checks pin how the installer reads it.
set -eo pipefail
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
INSTALLER="$ROOT_DIR/install.sh"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
assert_eq() {
local expected="$1" actual="$2" label="$3"
[ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'"
}
fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
[ -r "$INSTALLER" ] || fail "install.sh must exist"
# The function is lifted out of the installer rather than sourced: install.sh runs
# main() at the bottom, so sourcing it would attempt a real installation. Only the
# hardcoded path is redirected, so the extraction logic under test is the shipped
# one, character for character.
sed -n '/^installed_backend_sha() {/,/^}/p' "$INSTALLER" >"$WORK_DIR/sha.sh"
[ -s "$WORK_DIR/sha.sh" ] ||
fail "install.sh must define installed_backend_sha for rollback detection"
grep -Fq '/usr/lib/tachyon/core/constants.uc' "$WORK_DIR/sha.sh" ||
fail "installed_backend_sha must read the installed constants module"
sed 's#/usr/lib/tachyon/core/constants.uc#'"$WORK_DIR"'/constants.uc#' \
"$WORK_DIR/sha.sh" >"$WORK_DIR/sha-under-test.sh"
# Package manager errors are fatal. The old installer tried to infer success after
# a non-zero apk result and could accidentally accept a rolled-back upgrade.
grep -Fq 'install_core_transaction || fail "Tachyon package transaction failed"' "$INSTALLER" ||
fail "package transaction failures must abort the install"
if grep -Fq 'Package manager reported non-critical errors during installation' "$INSTALLER"; then
fail "installer must not convert package-manager failures into success"
fi
read_sha() {
local constants="$1"
rm -f "$WORK_DIR/constants.uc"
[ "$constants" = "" ] || cp "$constants" "$WORK_DIR/constants.uc"
(
# shellcheck disable=SC1090
. "$WORK_DIR/sha-under-test.sh"
installed_backend_sha
)
}
# Backend, LuCI and optional i18n are installed in one package-manager invocation.
grep -Fq 'pkg_install_local_bundle "$@"' "$INSTALLER" ||
fail "release packages must be submitted as one transaction"
grep -Fq 'apk_run apk-transaction "$PACKAGE_TIMEOUT_SECONDS" add --allow-untrusted "$@"' "$INSTALLER" ||
fail "APK install must be one bounded transaction"
grep -Fq 'run_logged_timeout opkg-transaction "$PACKAGE_TIMEOUT_SECONDS" opkg install' "$INSTALLER" ||
fail "opkg install must be one bounded transaction"
printf ' c.TACHYON_COMMIT_SHA = env("TACHYON_COMMIT_SHA", "ba69e3d2");\n' \
>"$WORK_DIR/stamped.uc"
assert_eq "ba69e3d2" "$(read_sha "$WORK_DIR/stamped.uc")" "a stamped build must report its SHA"
# The historical 120s package timeout caused valid postinst hooks to be killed and
# APK to roll the upgrade back. Keep a larger package transaction budget.
grep -Fq 'PACKAGE_TIMEOUT_SECONDS=420' "$INSTALLER" ||
fail "package transaction timeout must allow slow OpenWrt package hooks"
# An unprocessed tree keeps the literal placeholder; treating it as a SHA would
# make every install look like a rollback.
printf ' c.TACHYON_COMMIT_SHA = env("TACHYON_COMMIT_SHA", "__COMPILED_COMMIT_SHA__");\n' \
>"$WORK_DIR/placeholder.uc"
assert_eq "" "$(read_sha "$WORK_DIR/placeholder.uc")" "the build placeholder must not read as a SHA"
# Before the package manager runs, old rc.common/procd lock holders must be
# cleared. Old Tachyon releases could otherwise block prerm until APK rolled back.
grep -Fq 'release_tachyon_init_lock' "$INSTALLER" ||
fail "installer must release stale Tachyon init locks"
grep -Fq '/proc/[0-9]*/fd/1000' "$INSTALLER" ||
fail "installer must find leaked procd lock holders by fd 1000"
grep -Fq '*procd_tachyon*|*tachyon*lock*' "$INSTALLER" ||
fail "fd sweep must be limited to Tachyon-owned locks"
grep -Fq 'is_protected_pid' "$INSTALLER" ||
fail "installer must protect its own process ancestry"
grep -Fq '99-tachyon-wan|flock 1000|/etc/init.d/tachyon' "$INSTALLER" ||
fail "installer must clear old rc.common waiters"
# The Makefile substitutes "unknown" when git is unavailable at build time. It
# never changes between builds, so it must not be compared as a SHA either.
printf ' c.TACHYON_COMMIT_SHA = env("TACHYON_COMMIT_SHA", "unknown");\n' \
>"$WORK_DIR/unknown.uc"
assert_eq "" "$(read_sha "$WORK_DIR/unknown.uc")" "an unstamped build must not read as a SHA"
# The lock release must happen before packages are handed to apk/opkg.
awk '
/^main\(\)/ { in_main=1 }
in_main && /prepare_transaction/ && !prepare { prepare=NR }
in_main && /install_core_transaction/ && !install { install=NR }
END { exit !(prepare > 0 && install > prepare) }
' "$INSTALLER" || fail "stale init locks must be released before package install"
assert_eq "" "$(read_sha "")" "a missing constants module must yield no SHA"
# A failed later stage restores the safety snapshot rather than leaving user
# configuration rewritten by package hooks.
grep -Fq 'TX_ACTIVE=1' "$INSTALLER" || fail "transaction recovery guard missing"
grep -Fq 'TX_COMMITTED=1' "$INSTALLER" || fail "transaction commit guard missing"
grep -Fq 'restore_snapshot' "$INSTALLER" || fail "failed transaction must restore snapshot"
grep -Fq 'chmod 0600 /etc/config/tachyon' "$INSTALLER" ||
fail "restored Tachyon config must remain private"
# A rolled-back rebuild is only detectable by comparing SHAs, so the backend
# install must refuse to accept a mere presence check when the SHA did not move.
grep -Fq 'rolled the upgrade back' "$INSTALLER" ||
fail "install.sh must fail loudly when the backend SHA did not move after install"
# An empty "before" SHA is not evidence of a fresh install: installed_backend_sha
# rejects both the placeholder and "unknown", which is exactly what a stale build
# on disk reports. Presence before the install is what separates an upgrade from a
# first install, so the rollback check must consult it — otherwise a rolled-back
# upgrade of an unstamped build reaches the "installed — continuing" branch and the
# run marches on over a failure.
sed -n '/^install_backend_package() {/,/^}/p' "$INSTALLER" >"$WORK_DIR/backend.sh"
[ -s "$WORK_DIR/backend.sh" ] || fail "install.sh must define install_backend_package"
grep -Fq 'backend_present_before' "$WORK_DIR/backend.sh" ||
fail "install_backend_package must record whether tachyon was installed before the upgrade"
grep -Fq 'still reports no commit SHA' "$WORK_DIR/backend.sh" ||
fail "an upgrade that leaves the installed build without a SHA must fail, not warn and continue"
# 120s was below the time the package hooks legitimately need to stop and restart
# the service; being killed there makes apk roll the transaction back.
for pm in apk opkg; do
if grep -nE "(apk_with_lock_retry|run_logged_timeout) \"$pm\" 120 $pm (add|install)" "$INSTALLER" >/dev/null; then
fail "$pm package install must not keep the 120s ceiling that rolled upgrades back"
fi
done
# Bounding the hooks only helps the build being installed; during THIS upgrade the
# package manager runs the prerm of whatever is already on disk, and every release
# up to 1.2.66 shipped one with no timeout. It blocks on rc.common's `flock -w
# 1000`, so the installer has to free that lock itself before invoking the package
# manager — otherwise the pre-upgrade hook outlives apk's patience and the whole
# transaction is rolled back.
sed -n '/^function installer_release_init_lock() {/,/^}/p' "$INSTALLER" >"$WORK_DIR/lock.uc"
[ -s "$WORK_DIR/lock.uc" ] ||
fail "install.sh must define installer_release_init_lock to unwedge the old prerm hook"
for pattern in '99-tachyon-wan|flock 1000' '/etc/init.d/tachyon'; do
grep -Fq "$pattern" "$WORK_DIR/lock.uc" ||
fail "installer_release_init_lock must clear '$pattern' waiters"
done
# Killing by name misses the actual holders. procd_lock() does `exec 1000>` on the
# lock file and `flock 1000` with no -w, so every background spawn inheriting fd 1000
# keeps the lock. On a live router the holders were watchdog workers, logread, the
# zapret2 supervisors, nfqws2 and a curl — none of which match a tachyon name.
grep -Fq '/fd/1000' "$WORK_DIR/lock.uc" ||
fail "installer_release_init_lock must clear lock holders by descriptor, not only by name"
grep -Fq 'procd_tachyon' "$WORK_DIR/lock.uc" ||
fail "installer_release_init_lock must only kill holders of Tachyon's own procd lock"
# The installer runs as a child of /usr/bin/tachyon during an upgrade, so a sweep that
# does not exclude its own ancestors kills the process performing the install.
grep -Fq '/stat' "$WORK_DIR/lock.uc" ||
fail "installer_release_init_lock must walk /proc/<pid>/stat to spare its own ancestors"
# ucode resolves names lexically and does not hoist, so a call placed above the
# declaration silently becomes null and the lock is never released.
lock_decl="$(grep -m 1 -n '^function installer_release_init_lock() {' "$INSTALLER" | cut -d: -f1)"
lock_call="$(grep -m 1 -n 'installer_release_init_lock();' "$INSTALLER" | cut -d: -f1)"
[ -n "$lock_decl" ] && [ -n "$lock_call" ] ||
fail "installer_release_init_lock must be both declared and called"
[ "$lock_decl" -lt "$lock_call" ] ||
fail "installer_release_init_lock is called before its declaration (ucode does not hoist)"
# The lock has to be dropped as part of stopping the service, i.e. before any
# package is handed to the package manager.
sed -n '/^function installer_cleanup_legacy() {/,/^}/p' "$INSTALLER" >"$WORK_DIR/cleanup.uc"
grep -Fq 'installer_release_init_lock();' "$WORK_DIR/cleanup.uc" ||
fail "installer_cleanup_legacy must release the init lock before the packages are installed"
# Successful completion is only printed after the runtime healthcheck and commit.
awk '
/^main\(\)/ { in_main=1 }
in_main && /healthcheck/ && !health { health=NR }
in_main && /commit_transaction/ && !commit { commit=NR }
in_main && /installed successfully/ && !success { success=NR }
END { exit !(health > 0 && commit > health && success > commit) }
' "$INSTALLER" || fail "success must be gated by healthcheck and transaction commit"
printf 'PASS: installer rollback detection\n'
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
INSTALLER="$ROOT_DIR/install.sh"
fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
[ -r "$INSTALLER" ] || fail "install.sh is missing"
sh -n "$INSTALLER" || fail "install.sh must be valid POSIX shell"
grep -Fq 'mkdir "$LOCK_DIR"' "$INSTALLER" || fail "installer lock must use atomic mkdir"
grep -Fq 'Removing stale installer lock' "$INSTALLER" || fail "stale installer lock must be recoverable"
grep -Fq 'trap cleanup EXIT' "$INSTALLER" || fail "cleanup must run on EXIT"
grep -Fq 'trap on_int INT' "$INSTALLER" || fail "INT handler missing"
grep -Fq 'on_int() { exit 130; }' "$INSTALLER" || fail "INT must return 130"
grep -Fq 'on_term() { exit 143; }' "$INSTALLER" || fail "TERM must return 143"
if grep -Fq 'trap cleanup EXIT HUP INT TERM' "$INSTALLER"; then
fail "cleanup-only signal trap can resume installation after a signal"
fi
awk '
/^main\(\)/ { in_main=1 }
in_main && /sync_time/ && !sync { sync=NR }
in_main && /ensure_bootstrap_ucode_runtime/ && !bootstrap { bootstrap=NR }
END { exit !(sync > 0 && bootstrap > sync) }
' "$INSTALLER" || fail "sync_time must happen before bootstrap package/network operations"
grep -Fq 'apk --wait "$APK_LOCK_WAIT_SECONDS"' "$INSTALLER" || fail "apk --wait support is required"
grep -Fq 'diagnose_apk_lock' "$INSTALLER" || fail "APK lock diagnosis is required"
if grep -Fq 'sanitize_apk_world' "$INSTALLER"; then
fail "installer must not mutate /etc/apk/world as generic APK cleanup"
fi
grep -Fq 'TACHYON_DOWNLOAD_BYTES=' "$INSTALLER" || fail "release download size accounting missing"
grep -Fq 'free_kb /tmp' "$INSTALLER" || fail "/tmp free-space check missing"
grep -Fq 'MIN_TMP_HEADROOM_KB' "$INSTALLER" || fail "/tmp safety headroom missing"
grep -Fq 'pkg_install_local_bundle "$@"' "$INSTALLER" || fail "bundle transaction helper is not used"
grep -Fq 'apk-transaction' "$INSTALLER" || fail "APK transaction tag missing"
grep -Fq 'opkg-transaction' "$INSTALLER" || fail "opkg transaction tag missing"
awk '
/^main\(\)/ { in_main=1 }
in_main && /download_release/ && !download { download=NR }
in_main && /snapshot_state/ && !snapshot { snapshot=NR }
in_main && /prepare_transaction/ && !prepare { prepare=NR }
in_main && /install_core_transaction/ && !install { install=NR }
in_main && /healthcheck/ && !health { health=NR }
in_main && /commit_transaction/ && !commit { commit=NR }
END { exit !(download > 0 && snapshot > download && prepare > snapshot && install > prepare && health > install && commit > health) }
' "$INSTALLER" || fail "transaction phase ordering is unsafe"
grep -Fq 'restore_snapshot' "$INSTALLER" || fail "failed transaction must restore recovery snapshot"
grep -Fq 'TX_COMMITTED' "$INSTALLER" || fail "transaction commit guard missing"
grep -Fq '/fd/1000' "$INSTALLER" || fail "procd fd1000 lock-holder cleanup missing"
grep -Fq 'is_protected_pid' "$INSTALLER" || fail "installer must protect its own process ancestry"
grep -Fq '99-tachyon-wan|flock 1000|/etc/init.d/tachyon' "$INSTALLER" || fail "legacy init-lock waiter cleanup missing"
for pattern in 'tachyon_' 'luci-app-tachyon_' 'luci-i18n-tachyon-ru_' 'sha256sums.txt'; do
grep -Fq "$pattern" "$INSTALLER" || fail "release asset contract missing: $pattern"
done
grep -Fq 'Checksum mismatch' "$INSTALLER" || fail "checksum failure path missing"
if grep -qi 'jsdelivr' "$INSTALLER"; then
fail "GitHub release assets must not use the jsDelivr Git-tree endpoint"
fi
grep -Fq '/usr/bin/tachyon component_action sing_box "$_action"' "$INSTALLER" || fail "sing-box must be delegated to component_action"
export TACHYON_INSTALLER_TEST=1
# shellcheck source=/dev/null
. "$INSTALLER"
TEST_TMP="$(mktemp -d)"
trap 'rm -rf "$TEST_TMP"' EXIT
LOCK_DIR="$TEST_TMP/lock"
acquire_lock || fail "first atomic lock acquisition failed"
[ -f "$LOCK_DIR/pid" ] || fail "lock PID was not recorded"
release_lock
[ ! -e "$LOCK_DIR" ] || fail "lock was not released"
start="$(date +%s)"
if run_with_deadline 1 sh -c 'sleep 5'; then
fail "deadline watchdog accepted an over-deadline command"
fi
elapsed="$(( $(date +%s) - start ))"
[ "$elapsed" -lt 4 ] || fail "deadline watchdog did not stop the command promptly"
run_with_deadline 3 sh -c 'exit 0' || fail "deadline helper changed a successful exit status"
printf 'PASS: ultimate installer transaction contract\n'
+44 -101
View File
@@ -8,24 +8,17 @@ TACHYON_BIN="$ROOT_DIR/tachyon/files/usr/bin/tachyon"
REAL_LIB="$ROOT_DIR/tachyon/files/usr/lib"
INSTALLER="$ROOT_DIR/install.sh"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
[ -r "$RESET_UC" ] || fail "service/reset.uc must own reset-to-defaults logic"
[ -r "$TACHYON_BIN" ] || fail "tachyon entrypoint is missing"
grep -Fq 'reset_settings: [ "service/reset.uc", "reset-settings", 1 ]' "$TACHYON_BIN" ||
fail "tachyon entrypoint must dispatch reset_settings to service/reset.uc"
grep -Fq 'reset_settings' "$TACHYON_BIN" ||
fail "tachyon entrypoint must list reset_settings in the command table"
grep -Fq 'reset_settings [no-start]' "$TACHYON_BIN" ||
fail "tachyon help must document reset_settings"
mkdir -p "$WORK_DIR/lib/defaults"
cat >"$WORK_DIR/default-config" <<'EOF'
@@ -39,15 +32,14 @@ cp "$WORK_DIR/default-config" "$WORK_DIR/lib/defaults/config"
mkdir -p \
"$WORK_DIR/persistent/subscription-cache" \
"$WORK_DIR/persistent/rulesets" \
"$WORK_DIR/persistent/tailscale/server-main"
printf '%s\n' 'stale-cache' >"$WORK_DIR/persistent/subscription-cache/cache.json"
printf '%s\n' 'stale-ruleset' >"$WORK_DIR/persistent/rulesets/community.srs"
printf '%s\n' 'tailscale-identity' >"$WORK_DIR/persistent/tailscale/server-main/node.key"
mkdir -p "$WORK_DIR/runtime" "$WORK_DIR/sing-box-tmp"
printf '%s\n' '{"log":{}}' >"$WORK_DIR/sing-box-tmp/config.json"
mkdir -p "$(dirname "$WORK_DIR/sing-box-config")"
printf '%s\n' '{"log":{}}' >"$WORK_DIR/sing-box-config"
printf '%s\n' 'custom-user-config' >"$WORK_DIR/config"
"$WORK_DIR/persistent/tailscale/server-main" \
"$WORK_DIR/runtime" \
"$WORK_DIR/sing-box-tmp"
printf '%s\n' stale >"$WORK_DIR/persistent/subscription-cache/cache.json"
printf '%s\n' stale >"$WORK_DIR/persistent/rulesets/community.srs"
printf '%s\n' identity >"$WORK_DIR/persistent/tailscale/server-main/node.key"
printf '%s\n' '{}' >"$WORK_DIR/sing-box-config"
printf '%s\n' custom >"$WORK_DIR/config"
: >"$WORK_DIR/bin.log"
: >"$WORK_DIR/init.log"
@@ -56,97 +48,48 @@ cat >"$WORK_DIR/fake-bin" <<'SH'
printf '%s\n' "$*" >> "$TACHYON_RESET_BIN_LOG"
exit 0
SH
chmod 0755 "$WORK_DIR/fake-bin"
cat >"$WORK_DIR/fake-init" <<'SH'
#!/usr/bin/env sh
printf '%s\n' "$*" >> "$TACHYON_RESET_INIT_LOG"
exit 0
SH
chmod 0755 "$WORK_DIR/fake-init"
chmod 0755 "$WORK_DIR/fake-bin" "$WORK_DIR/fake-init"
TACHYON_LIB="$WORK_DIR/lib" \
TACHYON_BIN="$WORK_DIR/fake-bin" \
TACHYON_SERVICE_INIT="$WORK_DIR/fake-init" \
TACHYON_CONFIG_PATH="$WORK_DIR/config" \
TACHYON_PERSISTENT_DIR="$WORK_DIR/persistent" \
TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/runtime" \
TACHYON_SING_BOX_TMP_DIR="$WORK_DIR/sing-box-tmp" \
TACHYON_SING_BOX_CONFIG_PATH="$WORK_DIR/sing-box-config" \
TACHYON_RESET_BIN_LOG="$WORK_DIR/bin.log" \
TACHYON_RESET_INIT_LOG="$WORK_DIR/init.log" \
ucode -L "$WORK_DIR/lib" -L "$REAL_LIB" "$RESET_UC" reset-settings no-start >"$WORK_DIR/result.json"
grep -Fq '"success":true' "$WORK_DIR/result.json" ||
fail "reset-settings must report success"
run_reset() {
TACHYON_LIB="$WORK_DIR/lib" \
TACHYON_BIN="$WORK_DIR/fake-bin" \
TACHYON_SERVICE_INIT="$WORK_DIR/fake-init" \
TACHYON_CONFIG_PATH="$WORK_DIR/config" \
TACHYON_PERSISTENT_DIR="$WORK_DIR/persistent" \
TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/runtime" \
TACHYON_SING_BOX_TMP_DIR="$WORK_DIR/sing-box-tmp" \
TACHYON_SING_BOX_CONFIG_PATH="$WORK_DIR/sing-box-config" \
TACHYON_RESET_BIN_LOG="$WORK_DIR/bin.log" \
TACHYON_RESET_INIT_LOG="$WORK_DIR/init.log" \
ucode -L "$WORK_DIR/lib" -L "$REAL_LIB" "$RESET_UC" reset-settings "$@"
}
grep -Fxq 'stop' "$WORK_DIR/bin.log" ||
fail "reset must stop the service through the backend entrypoint"
grep -Fxq 'stop' "$WORK_DIR/init.log" ||
fail "reset must stop the service through init.d"
if grep -Fxq 'start' "$WORK_DIR/init.log"; then
fail "reset no-start must not start the service"
fi
cmp -s "$WORK_DIR/config" "$WORK_DIR/default-config" ||
fail "reset must restore the config file to factory defaults"
# busybox-based images may lack stat(1); ucode's fs module is always present.
run_reset no-start >"$WORK_DIR/result.json"
grep -Fq '"success":true' "$WORK_DIR/result.json" || fail "reset-settings must report success"
grep -Fxq 'stop' "$WORK_DIR/bin.log" || fail "reset must stop through backend entrypoint"
grep -Fxq 'stop' "$WORK_DIR/init.log" || fail "reset must stop through init.d"
if grep -Fxq 'start' "$WORK_DIR/init.log"; then fail "no-start reset must not start service"; fi
cmp -s "$WORK_DIR/config" "$WORK_DIR/default-config" || fail "reset must restore factory config"
[ "$(ucode -e "let fs = require('fs'); printf('%o', fs.stat('$WORK_DIR/config').mode & 511);")" = "600" ] ||
fail "reset config must be chmod 600"
[ ! -e "$WORK_DIR/persistent/subscription-cache" ] || fail "reset must clear subscription cache"
[ ! -e "$WORK_DIR/persistent/rulesets" ] || fail "reset must clear ruleset cache"
[ "$(cat "$WORK_DIR/persistent/tailscale/server-main/node.key")" = identity ] || fail "reset must preserve Tailscale identity"
[ ! -e "$WORK_DIR/runtime" ] || fail "reset must clear runtime state"
[ ! -e "$WORK_DIR/sing-box-tmp" ] || fail "reset must clear sing-box temporary state"
[ ! -e "$WORK_DIR/sing-box-config" ] || fail "reset must remove generated sing-box config"
[ ! -e "$WORK_DIR/persistent/subscription-cache" ] ||
fail "reset must remove stale subscription caches"
[ ! -e "$WORK_DIR/persistent/rulesets" ] ||
fail "reset must remove stale ruleset caches"
[ "$(cat "$WORK_DIR/persistent/tailscale/server-main/node.key")" = 'tailscale-identity' ] ||
fail "reset must preserve the Tailscale identity"
[ ! -e "$WORK_DIR/runtime" ] ||
fail "reset must remove the runtime state directory"
[ ! -e "$WORK_DIR/sing-box-tmp" ] ||
fail "reset must remove the sing-box temporary directory"
[ ! -e "$WORK_DIR/sing-box-config" ] ||
fail "reset must remove the sing-box config file"
TACHYON_LIB="$WORK_DIR/lib" \
TACHYON_BIN="$WORK_DIR/fake-bin" \
TACHYON_SERVICE_INIT="$WORK_DIR/fake-init" \
TACHYON_CONFIG_PATH="$WORK_DIR/config" \
TACHYON_PERSISTENT_DIR="$WORK_DIR/persistent" \
TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/runtime" \
TACHYON_SING_BOX_TMP_DIR="$WORK_DIR/sing-box-tmp" \
TACHYON_SING_BOX_CONFIG_PATH="$WORK_DIR/sing-box-config" \
TACHYON_RESET_BIN_LOG="$WORK_DIR/bin.log" \
TACHYON_RESET_INIT_LOG="$WORK_DIR/init.log" \
ucode -L "$WORK_DIR/lib" -L "$REAL_LIB" "$RESET_UC" reset-settings >"$WORK_DIR/result.json"
grep -Fxq 'start' "$WORK_DIR/init.log" ||
fail "reset without no-start must start the service"
TACHYON_LIB="$WORK_DIR/lib" \
TACHYON_BIN="$WORK_DIR/fake-bin" \
TACHYON_SERVICE_INIT="$WORK_DIR/fake-init" \
TACHYON_CONFIG_PATH="$WORK_DIR/config" \
TACHYON_PERSISTENT_DIR="$WORK_DIR/persistent" \
TACHYON_RUNTIME_STATE_DIR="$WORK_DIR/runtime" \
TACHYON_SING_BOX_TMP_DIR="$WORK_DIR/sing-box-tmp" \
TACHYON_SING_BOX_CONFIG_PATH="$WORK_DIR/sing-box-config" \
TACHYON_RESET_BIN_LOG="$WORK_DIR/bin.log" \
TACHYON_RESET_INIT_LOG="$WORK_DIR/init.log" \
ucode -L "$WORK_DIR/lib" -L "$REAL_LIB" "$RESET_UC" bogus-mode 2>"$WORK_DIR/usage.err" &&
fail "reset module must reject unknown modes"
grep -Fq 'Usage' "$WORK_DIR/usage.err" ||
fail "reset module must print usage for unknown modes"
grep -Fq 'reset_settings [no-start]' "$TACHYON_BIN" ||
fail "tachyon help must document reset_settings"
grep -Fq 'reset_settings' "$TACHYON_BIN" ||
fail "tachyon failsafe dnsmasq restore must cover reset_settings"
# The installer must wipe leftover state on a fresh install but keep user
# configuration on upgrades and legacy migrations.
grep -Fq 'TACHYON_WAS_INSTALLED' "$INSTALLER" ||
fail "installer must remember whether Tachyon was installed before"
grep -Fq 'reset_settings", "no-start"' "$INSTALLER" ||
fail "installer post-install must delegate fresh-install defaults to reset_settings"
if grep -n -E 'reset_settings|TACHYON_WAS_INSTALLED' "$INSTALLER" | grep -qE 'rm -f |uci |sed -i '; then
fail "installer must not own reset/state shell logic"
# Installer v3 preserves configuration and delegates package initialization to
# package hooks/runtime modules. It must never implement reset semantics itself.
grep -Fq 'snapshot_state()' "$INSTALLER" || fail "installer must snapshot config before package changes"
grep -Fq 'TACHYON_WAS_INSTALLED' "$INSTALLER" || fail "installer must remember previous install state"
if grep -n -E '(^|[;&|[:space:]])uci[[:space:]]+-q|sed -i .*/etc/config/tachyon' "$INSTALLER" >/dev/null; then
fail "installer shell must not own UCI reset/mutation logic"
fi
printf 'reset settings contract checks passed\n'
+20 -24
View File
@@ -2,52 +2,48 @@
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
# 1. Structural checks in CLI and backend ucode
grep -Fq 'delete_section' "$ROOT_DIR/tachyon/files/usr/bin/tachyon" ||
fail "tachyon CLI must declare delete_section command"
grep -Fq 'cascade_delete_section' "$ROOT_DIR/tachyon/files/usr/lib/config/connections.uc" ||
fail "connections.uc must implement cascade_delete_section"
grep -Fq 'cli_delete_section' "$ROOT_DIR/tachyon/files/usr/lib/config/connections.uc" ||
fail "connections.uc must export cli_delete_section"
grep -Fq 'priority_group' "$ROOT_DIR/tachyon/files/usr/lib/config/migration.uc" ||
fail "migration.uc CHILD_ITEM_TYPES must include priority_group"
grep -Fq 'priority_level' "$ROOT_DIR/tachyon/files/usr/lib/config/migration.uc" ||
fail "migration.uc CHILD_ITEM_TYPES must include priority_level"
# 2. Structural checks in LuCI JS view
grep -Fq 'cascadeDeleteSection' "$ROOT_DIR/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js" ||
fail "section.js must implement cascadeDeleteSection"
grep -Fq 'cascadeDeleteSection(section_id)' "$ROOT_DIR/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js" ||
fail "section.js remove handler must invoke cascadeDeleteSection"
grep -Fq 'cascadeDeleteSection' "$ROOT_DIR/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js" ||
fail "tachyon.js must invoke cascadeDeleteSection on section remove/add"
# 3. Installer safety checks
grep -Fq 'backup_existing_config' "$ROOT_DIR/install.sh" ||
fail "install.sh must invoke backup_existing_config"
# 3. Installer safety contract: never mutate user configuration before it is
# snapshotted, and verify the resulting runtime before declaring success.
INSTALLER="$ROOT_DIR/install.sh"
grep -Fq 'snapshot_state()' "$INSTALLER" ||
fail "install.sh must provide a recovery snapshot stage"
grep -Fq '/etc/config/tachyon' "$INSTALLER" ||
fail "installer snapshot/healthcheck must cover /etc/config/tachyon"
grep -Fq 'chmod 0600 /etc/config/tachyon' "$INSTALLER" ||
fail "installer must keep Tachyon config private"
grep -Fq 'healthcheck()' "$INSTALLER" ||
fail "installer must verify the installed runtime"
grep -Fq '/usr/share/luci/menu.d/luci-app-tachyon.json' "$INSTALLER" ||
fail "installer healthcheck must verify the LuCI menu"
grep -Fq 'path_exists("/etc/config/tachyon")' "$ROOT_DIR/install.sh" ||
fail "install.sh must check /etc/config/tachyon before considering installation fresh"
grep -Fq 'path_executable(INSTALLER_TACHYON_BIN)' "$ROOT_DIR/install.sh" ||
fail "install.sh must check INSTALLER_TACHYON_BIN before considering installation fresh"
awk '
/^main\(\)/ { in_main=1 }
in_main && /snapshot_state/ && !snapshot { snapshot=NR }
in_main && /install_core_transaction/ && !install { install=NR }
END { exit !(snapshot > 0 && install > snapshot) }
' "$INSTALLER" || fail "installer must snapshot configuration before package hooks run"
printf 'section cascade deletion and installer checks passed\n'