mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-15 06:50:51 +01:00
Install live scripts by atomic rename
The periodic setup wrote /usr/local/bin scripts with sed > target, truncating the file in place. bash reads scripts lazily, so the running hosts-file-monitor could execute a half-new file. New lib/atomic_install.sh renders into a temp file beside the target, syntax-checks it, sets mode/owner and renames it over the target (new inode). A bad template fails closed and leaves the live file untouched. Applied to the maintenance script, hosts monitor, browser wrapper and auto-update script; the hosts monitor service is restarted if running. Test: test_services_atomic_install.sh (inode changes, old inode keeps old bytes, syntax error and missing template leave target untouched). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYWDKuhbBAoNETfoB9
This commit is contained in:
parent
f75fb60687
commit
b57d5f9064
@ -0,0 +1,11 @@
|
||||
{
|
||||
"title": "Atomic install of live periodic-setup scripts",
|
||||
"objective": "Installing the periodic system scripts never truncates a file a service is running; the target gets a new inode and a bad template leaves the live file untouched.",
|
||||
"acceptance_criteria": [
|
||||
"Target inode changes and the old inode keeps its bytes",
|
||||
"A template failing bash -n is not installed and leaves no temp file",
|
||||
"hosts-file-monitor.service is restarted only if it was running"
|
||||
],
|
||||
"out_of_scope": ["Re-running the installer on the host", "Other installers"],
|
||||
"verifier": "lib/tests/test_services_atomic_install.sh, lib/tests/run_all.sh, vmbox guest check"
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
{
|
||||
"intent": "The periodic setup replaces live /usr/local/bin scripts by atomic rename, so a running hosts-file-monitor never executes a half-written file.",
|
||||
"scope": [
|
||||
"linux_configuration/periodic_background/lib/atomic_install.sh",
|
||||
"linux_configuration/periodic_background/setup_periodic_system.sh",
|
||||
"linux_configuration/periodic_background/lib/periodic_browser.sh",
|
||||
"linux_configuration/periodic_background/lib/tests/test_services_atomic_install.sh",
|
||||
"Non-goal: re-running the installer on the host"
|
||||
],
|
||||
"changes": [
|
||||
"install_rendered_script renders to a temp file beside the target, bash -n, chmod/chown, mv -f",
|
||||
"Maintenance script, hosts monitor, browser wrapper and auto-update script use it",
|
||||
"restart_if_active restarts hosts-file-monitor.service after install"
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "lib/tests/test_services_atomic_install.sh",
|
||||
"result": "pass",
|
||||
"evidence": "red against in-place write (inode kept, old fd saw new bytes), 12 passed after"
|
||||
},
|
||||
{
|
||||
"command": "vm run atomichosts guest_check.sh",
|
||||
"result": "pass",
|
||||
"evidence": "real hosts-file-monitor template under systemd: inode 85520 -> 85524, MainPID 745 -> 768, service active"
|
||||
},
|
||||
{
|
||||
"command": "lib/tests/run_all.sh",
|
||||
"result": "pass",
|
||||
"evidence": "all check_and_enable_services test files passed"
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"Temp file left in /usr/local/bin if the installer is killed between mktemp and mv",
|
||||
"Restart briefly interrupts the hosts monitor"
|
||||
],
|
||||
"rollback": [
|
||||
"git revert the commit; the old installer overwrites in place again",
|
||||
"Check systemctl is-active hosts-file-monitor.service"
|
||||
]
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
{
|
||||
"intent": "The i3 gaming-budget block shows the new earned_time 0.3.0 automation earner (anki-guard's Automation-deck quota) as ⚙ instead of its text label.",
|
||||
"scope": [
|
||||
"linux_configuration/i3blocks/gaming_budget.sh",
|
||||
"linux_configuration/tests/test_i3blocks_gaming_budget.sh",
|
||||
"Non-goal: no change to how the budget is computed or rendered; only the icon map grows"
|
||||
],
|
||||
"changes": [
|
||||
"Icon map gains automation: ⚙ beside workout 💪, leetcode 🧩, reading 📖, anki 🗂",
|
||||
"The .rules.earners test case lists an automation earner and expects '+⚙30m' in registry order"
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "bash linux_configuration/i3blocks/gaming_budget.sh (live enforcer API, after restarting on earned-time 0.3.0)",
|
||||
"result": "pass",
|
||||
"evidence": "'🎮 1h19 left · 5h44/7h03 +📖1h +🗂30m +⚙30m'; /api/budget lists automation 0/1800"
|
||||
},
|
||||
{
|
||||
"command": "bash linux_configuration/tests/test_i3blocks_gaming_budget.sh",
|
||||
"result": "pass",
|
||||
"evidence": "All gaming_budget checks passed, incl. '+🧩1h +📖1h +🗂30m +⚙30m +Piano1h30'"
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"⚙ (U+2699) without a variation selector may render as text-style in some fonts; it is still one glyph wide."
|
||||
],
|
||||
"rollback": [
|
||||
"git revert this commit; automation then shows as its label 'Automation30m'"
|
||||
]
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
{
|
||||
"intent": "A firewall apply (setup_wireguard_ssh.sh allow-*) no longer bounces Docker and its containers, and the DoT rule (tcp/853 on wg0) survives every re-apply instead of vanishing.",
|
||||
"scope": [
|
||||
"linux_configuration/features/lib/wg_firewall.sh: ruleset replaces only 'table inet filter'; restore_docker_rules restarts docker only when its nat DOCKER chain is gone; ALLOW_DOT renders 853 on wg0; allow_dot()",
|
||||
"linux_configuration/features/setup_wireguard_ssh.sh, lib/wg_sshd.sh: ALLOW_DOT default/save_config, allow-dot subcommand and usage",
|
||||
"linux_configuration/features/lib/dot_resolver_install.sh: wg-bound branch goes through 'setup_wireguard_ssh.sh allow-dot' instead of a runtime 'nft add rule'",
|
||||
"Tests: new lib/tests/test_wg_firewall.sh; test_dot_resolver_install.sh wg-branch cases rewritten",
|
||||
"Non-goal: the publicly bound DoT branch still adds its rate-limited rule at runtime"
|
||||
],
|
||||
"changes": [
|
||||
"'flush ruleset' replaced by 'table inet filter' + 'delete table inet filter' before the definition: atomic, leaves Docker's (iptables-nft) and Tailscale's tables alone, and loads on a first boot",
|
||||
"restore_docker_rules checks 'iptables -t nat -S DOCKER' and only restarts docker when that chain is missing",
|
||||
"sshd-death rollback deletes only inet filter instead of flushing the whole ruleset",
|
||||
"ALLOW_DOT persisted in .wireguard_ssh.conf; open_firewall calls allow-dot so the rule is rendered on every apply"
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "vmbox guest dotfw (iptables-nft, docker 29 + busybox probe, an extra 'inet sentinel' table): old HEAD allow-homeassistant vs new allow-dot",
|
||||
"result": "pass",
|
||||
"evidence": "old: all non-inet-filter tables deleted, docker + probe restarted; new re-apply from healthy state: docker ActiveEnterTimestamp and probe StartedAt unchanged, sentinel and docker tables kept; verify clean; systemctl restart nftables and first-boot load (no table) rc=0"
|
||||
},
|
||||
{
|
||||
"command": "sudo setup_wireguard_ssh.sh allow-dot (host, iptables legacy)",
|
||||
"result": "pass",
|
||||
"evidence": "'docker's own firewall rules are intact; containers untouched'; docker since 2026-09-28 12:41:15 and gitea-caddy StartedAt unchanged; live input chain gains iifname wg0 tcp dport 853 accept; verify: nftables.conf matches; anki/gitea HTTPS 200"
|
||||
},
|
||||
{
|
||||
"command": "lib/tests/test_wg_firewall.sh (root in vmbox) ; shell_coverage_jail.sh --subject lib/tests/run_all.sh <jail_args> --min 0 --fail-on-case-error",
|
||||
"result": "pass",
|
||||
"evidence": "12 passed incl. real nft -c; jailed suite rc=0"
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"Tables left over from an older ruleset outside inet filter are no longer flushed by an apply (none exist on the host).",
|
||||
"If docker's rules live somewhere other than the nat DOCKER chain, a missing-rules state would not trigger the rebuild."
|
||||
],
|
||||
"rollback": [
|
||||
"git revert this commit, then sudo setup_wireguard_ssh.sh allow-web to re-render (old code restarts docker once)",
|
||||
"After rollback: setup_wireguard_ssh.sh verify, and confirm containers reach the network"
|
||||
]
|
||||
}
|
||||
50
linux_configuration/periodic_background/lib/atomic_install.sh
Executable file
50
linux_configuration/periodic_background/lib/atomic_install.sh
Executable file
@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
# Atomic install of live /usr/local/bin scripts for the periodic system setup.
|
||||
#
|
||||
# Sourced by setup_periodic_system.sh and periodic_browser.sh.
|
||||
#
|
||||
# Why not `sed ... > /usr/local/bin/x.sh`: that truncates the file in place.
|
||||
# bash reads a script lazily, so a service already running it (the hosts
|
||||
# monitor loops forever) can execute a half-new file. Rendering beside the
|
||||
# target and renaming over it gives the target a NEW inode; a running instance
|
||||
# keeps reading the old one until it exits or is restarted.
|
||||
# Fail-closed: a template that is missing or fails `bash -n` leaves the live
|
||||
# file untouched and returns non-zero.
|
||||
|
||||
# install_rendered_script <template> <dest> [sed args...]
|
||||
# Renders <template> through sed with the given args into a temp file in
|
||||
# dirname(<dest>) (same filesystem, so mv is a rename), syntax-checks it, sets
|
||||
# mode 0755 and root ownership when run as root, then renames it over <dest>.
|
||||
install_rendered_script() {
|
||||
local template="$1" dest="$2"
|
||||
shift 2
|
||||
local tmp
|
||||
[[ -f $template ]] || {
|
||||
echo "Error: template not found: $template" >&2
|
||||
return 1
|
||||
}
|
||||
tmp="$(mktemp "${dest}.XXXXXX")" || return 1
|
||||
# `-e ''` keeps sed valid when the caller passes no expressions.
|
||||
if sed -e '' "$@" "$template" >"$tmp" &&
|
||||
bash -n "$tmp" &&
|
||||
chmod 0755 "$tmp" &&
|
||||
{ [[ $EUID -ne 0 ]] || chown root:root "$tmp"; } &&
|
||||
mv -f "$tmp" "$dest"; then
|
||||
return 0
|
||||
fi
|
||||
rm -f "$tmp"
|
||||
echo "Error: not installing $dest (render or syntax check failed)" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# restart_if_active <unit>
|
||||
# Restarts a unit onto a freshly installed script, but only if it is running:
|
||||
# the installer must not start a service the user stopped on purpose.
|
||||
restart_if_active() {
|
||||
local unit="$1"
|
||||
if systemctl is-active "$unit" >/dev/null 2>&1; then
|
||||
systemctl restart "$unit"
|
||||
echo "✓ Restarted $unit onto the new script"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
# Browser wrapper install, desktop-entry redirect, auto-update units and
|
||||
# service enablement for the periodic system setup.
|
||||
#
|
||||
# Needs install_rendered_script from lib/atomic_install.sh (sourced by the caller).
|
||||
# Sourced by setup_periodic_system.sh; split out to keep it under the
|
||||
# 250-line cap. Sourced rather than run, so it inherits the caller's strict
|
||||
# mode and the variables defined above the source line.
|
||||
@ -13,9 +14,8 @@ install_browser_preexec_wrapper() {
|
||||
echo "========================================="
|
||||
|
||||
local wrapper="/usr/local/bin/browser-preexec-wrapper"
|
||||
sed -e "s|__HOSTS_INSTALL_SCRIPT__|$HOSTS_INSTALL_SCRIPT|g" \
|
||||
"$TEMPLATE_BROWSER_WRAPPER" >"$wrapper"
|
||||
chmod +x "$wrapper"
|
||||
install_rendered_script "$TEMPLATE_BROWSER_WRAPPER" "$wrapper" \
|
||||
-e "s|__HOSTS_INSTALL_SCRIPT__|$HOSTS_INSTALL_SCRIPT|g"
|
||||
echo "✓ Installed wrapper: $wrapper"
|
||||
|
||||
# Allow passwordless execution of hosts installer for root-only actions
|
||||
@ -88,9 +88,8 @@ install_auto_update() {
|
||||
|
||||
# Install script from template with user substitution
|
||||
local actual_user="${SUDO_USER:-$USER}"
|
||||
sed -e "s|__ACTUAL_USER__|$actual_user|g" \
|
||||
"$TEMPLATE_AUTO_UPDATE" >"$update_script"
|
||||
chmod +x "$update_script"
|
||||
install_rendered_script "$TEMPLATE_AUTO_UPDATE" "$update_script" \
|
||||
-e "s|__ACTUAL_USER__|$actual_user|g"
|
||||
echo "✓ Installed auto-update script: $update_script (user: $actual_user)"
|
||||
|
||||
# Install systemd service and timer from templates
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for lib/atomic_install.sh — installing a live /usr/local/bin script.
|
||||
#
|
||||
# A running bash reads its script lazily, so truncating the file in place
|
||||
# (`sed ... > target`) lets a running instance execute a half-new file. The
|
||||
# installer must render beside the target and rename over it: the target gets
|
||||
# a NEW inode and a process holding the old one keeps reading the old bytes.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# shellcheck source=services_harness.sh
|
||||
. "${SCRIPT_DIR}/services_harness.sh"
|
||||
# shellcheck source=../atomic_install.sh
|
||||
. "${SCRIPT_DIR}/../atomic_install.sh"
|
||||
|
||||
BIN="${SYSROOT_DIR}/usr/local/bin"
|
||||
TEMPLATE="${TEST_TMPDIR}/template.sh"
|
||||
printf '#!/bin/bash\necho __NAME__ new\n' >"$TEMPLATE"
|
||||
|
||||
echo "== install_rendered_script: replaces the live file by rename =="
|
||||
printf '#!/bin/bash\necho old\n' >"${BIN}/live.sh"
|
||||
old_inode="$(stat -c %i "${BIN}/live.sh")"
|
||||
exec 9<"${BIN}/live.sh" # a "running instance" holding the old inode open
|
||||
install_rendered_script "$TEMPLATE" "${BIN}/live.sh" -e 's|__NAME__|hosts|g'
|
||||
new_inode="$(stat -c %i "${BIN}/live.sh")"
|
||||
if [[ $new_inode != "$old_inode" ]]; then
|
||||
_t_pass "the target has a new inode"
|
||||
else
|
||||
_t_fail "the target kept inode $old_inode (written in place)"
|
||||
fi
|
||||
_t_eq $'#!/bin/bash\necho old' "$(cat <&9)" "the old inode's content is untouched for the running instance"
|
||||
exec 9<&-
|
||||
_t_eq $'#!/bin/bash\necho hosts new' "$(cat "${BIN}/live.sh")" "the target holds the rendered template"
|
||||
if [[ -x "${BIN}/live.sh" ]]; then
|
||||
_t_pass "the target is executable"
|
||||
else
|
||||
_t_fail "the target is not executable"
|
||||
fi
|
||||
_t_eq "live.sh" "$(ls "$BIN")" "no temp file is left beside the target"
|
||||
|
||||
echo "== install_rendered_script: a script that fails bash -n never replaces the target =="
|
||||
printf '#!/bin/bash\nif then fi (\n' >"${TEST_TMPDIR}/broken.sh"
|
||||
before="$(stat -c %i "${BIN}/live.sh")"
|
||||
if install_rendered_script "${TEST_TMPDIR}/broken.sh" "${BIN}/live.sh" 2>/dev/null; then
|
||||
_t_fail "a syntax error was installed"
|
||||
else
|
||||
_t_pass "a syntax error returns non-zero"
|
||||
fi
|
||||
_t_eq "$before" "$(stat -c %i "${BIN}/live.sh")" "the live file is untouched after a syntax error"
|
||||
_t_eq "live.sh" "$(ls "$BIN")" "no temp file is left after a syntax error"
|
||||
|
||||
echo "== install_rendered_script: a missing template fails closed =="
|
||||
if install_rendered_script "${TEST_TMPDIR}/nope.sh" "${BIN}/live.sh" 2>/dev/null; then
|
||||
_t_fail "a missing template reported success"
|
||||
else
|
||||
_t_pass "a missing template returns non-zero"
|
||||
fi
|
||||
_t_eq "live.sh" "$(ls "$BIN")" "no temp file is left after a missing template"
|
||||
|
||||
echo "== restart_if_active: restarts only a running unit =="
|
||||
reset_state
|
||||
: >"${DEV}/active"
|
||||
restart_if_active hosts-file-monitor.service >/dev/null
|
||||
_t_not_called 'systemctl restart hosts-file-monitor.service' "a stopped unit is not started by the installer"
|
||||
printf 'hosts-file-monitor.service\n' >"${DEV}/active"
|
||||
restart_if_active hosts-file-monitor.service >/dev/null
|
||||
_t_called 'systemctl restart hosts-file-monitor.service' "a running unit is restarted onto the new file"
|
||||
|
||||
_t_summary
|
||||
@ -122,13 +122,10 @@ create_execution_script() {
|
||||
local exec_script="/usr/local/bin/periodic-system-maintenance.sh"
|
||||
|
||||
# Install from template with path substitutions
|
||||
sed \
|
||||
install_rendered_script "$TEMPLATE_MAINT_SCRIPT" "$exec_script" \
|
||||
-e "s|__PACMAN_WRAPPER_INSTALL__|$PACMAN_WRAPPER_INSTALL|g" \
|
||||
-e "s|__MAKEPKG_WRAPPER_INSTALL__|$MAKEPKG_WRAPPER_INSTALL|g" \
|
||||
-e "s|__HOSTS_INSTALL_SCRIPT__|$HOSTS_INSTALL_SCRIPT|g" \
|
||||
"$TEMPLATE_MAINT_SCRIPT" >"$exec_script"
|
||||
|
||||
chmod +x "$exec_script"
|
||||
-e "s|__HOSTS_INSTALL_SCRIPT__|$HOSTS_INSTALL_SCRIPT|g"
|
||||
echo "✓ Installed execution script from template: $exec_script"
|
||||
}
|
||||
|
||||
@ -174,17 +171,22 @@ create_hosts_monitor_service() {
|
||||
local monitor_script="/usr/local/bin/hosts-file-monitor.sh"
|
||||
local monitor_service="/etc/systemd/system/hosts-file-monitor.service"
|
||||
|
||||
# Install the monitor script from template with substitution
|
||||
sed -e "s|__HOSTS_INSTALL_SCRIPT__|$HOSTS_INSTALL_SCRIPT|g" \
|
||||
"$TEMPLATE_HOSTS_MONITOR" >"$monitor_script"
|
||||
chmod +x "$monitor_script"
|
||||
# Install the monitor script from template with substitution. The service
|
||||
# runs it in a loop, so it is replaced by rename (see lib/atomic_install.sh).
|
||||
install_rendered_script "$TEMPLATE_HOSTS_MONITOR" "$monitor_script" \
|
||||
-e "s|__HOSTS_INSTALL_SCRIPT__|$HOSTS_INSTALL_SCRIPT|g"
|
||||
echo "✓ Installed hosts monitor script from template: $monitor_script"
|
||||
|
||||
# Install the systemd service from template
|
||||
install -m 0644 "$TEMPLATE_HOSTS_SVC" "$monitor_service"
|
||||
echo "✓ Installed hosts monitor service from template: $monitor_service"
|
||||
|
||||
# The running instance still holds the old inode; move it onto the new one.
|
||||
restart_if_active hosts-file-monitor.service
|
||||
}
|
||||
|
||||
# shellcheck source=lib/atomic_install.sh
|
||||
source "$SCRIPT_DIR/lib/atomic_install.sh"
|
||||
# shellcheck source=lib/periodic_browser.sh
|
||||
source "$SCRIPT_DIR/lib/periodic_browser.sh"
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user