installer v1.28.0: the removal genuinely reverses the installation (R-316)
gates / gates (push) Successful in 13s
gates / gates (push) Successful in 13s
v1.27.0's fix worked exactly once per machine. Measured on drill-r50 from virgin, on the PUBLISHED v1.27.0, before anything was changed: cycle 1 recorded 'no' and freed :53; cycle 2 recorded 'yes' and left dnsmasq running on 0.0.0.0:53; cycle 3 refused, exit 1. Every box already in the field is at cycle 2, and a reinstall onto a machine that has had Felhom is cycle 2 by definition. Why cycle 2 says yes: the preflight's ownership question is dpkg-query package presence and nothing else - not the absence of a record. Stopping the unit and leaving the package made our own package read as the household's one cycle later. Now the uninstall removes the package when the record says we installed it. Order unchanged and load-bearing: read the record, act, then delete the state file that holds it. TWO packages are recorded, because dnsmasq ships the unit and dnsmasq-base ships /usr/sbin/dnsmasq, and each is taken back only if we added it. The dependency check is a SIMULATION, not a guess: apt-get -s purge is asked what it would remove and the purge proceeds only if that set is a subset of ours; otherwise stop+disable, naming the package that blocked it. Never interactive, never fatal, and the success is re-queried rather than read off an exit code. Watched: three fixed cycles -> install 3 PASSES; a household resolver untouched; a dependent package not purged and named; no record -> untouched with the command named. Red-proofs with the mutation asserted applied: remove the purge -> cycle 3 refuses in those exact words; remove the ownership check -> a household resolver is purged; infer ownership -> the guess is taken. Also: R-317 (the agent stats a path dnsmasq-base owns to decide whether to install dnsmasq - pre-existing, now reachable), R-318 (no honest ownership marker exists for existing boxes; the preflight message is the mechanism), and the status page's decisions section rewritten to say what each decision costs and what doing nothing selects.
This commit is contained in:
@@ -1,3 +1,55 @@
|
||||
## felhom-host-install.sh v1.28.0 — the removal genuinely reverses the installation (2026-08-13, R-316)
|
||||
|
||||
**v1.27.0's fix worked exactly once per machine, and this is the measurement.** Three full cycles on
|
||||
`drill-r50` from `virgin`, on the PUBLISHED v1.27.0, before anything was changed:
|
||||
|
||||
| cycle | recorded | uninstall did | result |
|
||||
|---|---|---|---|
|
||||
| 1 | `no` | stop + disable | `:53` FREE |
|
||||
| 2 | **`yes`** | **left it running** | `0.0.0.0:53` taken |
|
||||
| 3 | — | — | **PRE-FLIGHT FAIL (exit 1)** — *"a resolver is already bound to :53"* |
|
||||
|
||||
**Why cycle 2 says `yes`:** the preflight asks `dpkg-query` whether `dnsmasq` is installed and nothing
|
||||
else — **package presence alone** (not the absence of a record). v1.27.0 stopped the unit and left the
|
||||
package, so the answer stayed yes and our own package became "the household's" one cycle later. **Every
|
||||
box already in the field is at cycle 2**, and a reinstall onto a machine that has had Felhom is cycle 2
|
||||
by definition.
|
||||
|
||||
**Now:** when the install-time record says we installed it, the uninstall **removes the package** as
|
||||
well as stopping the unit — which is what makes the next install a first install again. Order is
|
||||
unchanged and load-bearing: read the record, act, and only then delete the state file that holds it.
|
||||
|
||||
**TWO packages are recorded, not one.** `dnsmasq` ships the systemd unit; **`dnsmasq-base` ships
|
||||
`/usr/sbin/dnsmasq`**. They are separately installable, so each is recorded at preflight and taken back
|
||||
only if we added it.
|
||||
|
||||
**Guard rails, all measured on the box rather than reasoned:**
|
||||
|
||||
- **Ownership is read, never inferred** — no mtimes, no package metadata, no file dates.
|
||||
- **The dependency check is a SIMULATION.** `apt-get -s purge` is asked what it would remove, and the
|
||||
purge proceeds only if the answer is a subset of what we are entitled to remove. Otherwise: stop +
|
||||
disable, and the log names the package that stopped us.
|
||||
- **Never interactive, never fatal.** A wedged apt cannot strand a teardown — the failure is recorded
|
||||
and restated in the closing NOTE, because a half-completed teardown is how cycle 3 refuses.
|
||||
- **The success is asserted, not assumed:** after `apt-get` exits 0 the package is re-queried, because
|
||||
an exit code is not an observation.
|
||||
|
||||
**Scenarios, each watched:** three fixed cycles → **install 3 PASSES**; a household resolver →
|
||||
untouched; a dependent package (`household-dns-thing`) → **not purged, named in the log**, `:53` still
|
||||
freed; no ownership record → untouched, reason logged, exact command named.
|
||||
|
||||
**Red-proofs, mutation asserted applied each time:** remove the purge → **cycle 3 refuses again in
|
||||
those exact words**; remove the ownership check → **a household resolver is purged**; infer ownership
|
||||
when there is no record → **the guess is taken and a field box loses its own DNS**.
|
||||
|
||||
**Known residue, filed as R-317:** where `dnsmasq-base` pre-dated Felhom we correctly keep it, but it
|
||||
owns `/usr/sbin/dnsmasq`, which the agent stats to decide whether to install
|
||||
(`felhom-agent internal/lanresolver/lanresolver.go:105`). On that host shape the next install can skip
|
||||
the apt step and then fail to enable a unit that is gone. The uninstall says so explicitly rather than
|
||||
leaving it to be rediscovered from a silent resolver.
|
||||
|
||||
---
|
||||
|
||||
## PUBLISHED — `installer-v1.27.0` (2026-08-12)
|
||||
|
||||
**v1.26.0 (R-297) and v1.27.0 (R-300) were both written, pushed and deliberately left unpublished
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_VERSION="1.27.0" # the SINGLE version source (F-1): -h and the run banners follow it.
|
||||
SCRIPT_VERSION="1.28.0" # the SINGLE version source (F-1): -h and the run banners follow it.
|
||||
# The hub used to carry a copy for its Setup tab; R-94 DELETED it
|
||||
# (2026-08-02) because the hub cannot know which version a box runs —
|
||||
# the Setup command fetches this script at run time. scripts/
|
||||
@@ -842,6 +842,92 @@ _uninstall_statement() {
|
||||
# run_uninstall — the full guarded teardown. Every mutation goes through run() so --dry-run prints it
|
||||
# and executes nothing. Ordering is the reverse of install: guest -> agent -> pveum(ACL,token,user,
|
||||
# role) -> golden(opt-in) -> state file. See the TASK spec §7/§8.
|
||||
# _dnsmasq_purge_owned — remove the dnsmasq packages THIS install added, and nothing else (R-316).
|
||||
#
|
||||
# Called only from the `no` branch, i.e. only when the preflight RECORDED that we installed it. There
|
||||
# is no inference here and there must never be: no mtimes, no package metadata, no file dates. That
|
||||
# rule was earned and it is why the record exists at all.
|
||||
#
|
||||
# THE DEPENDENCY GUARD IS A SIMULATION, NOT A GUESS. `apt-get -s purge` is asked what it would do, and
|
||||
# the purge proceeds only if the answer names a subset of the packages we are entitled to remove. If
|
||||
# anything else would be dragged out, we STOP AND DISABLE instead and say which package stopped us —
|
||||
# a silent downgrade to the weaker action would be a warning standing beside a success.
|
||||
#
|
||||
# It can fail and the uninstall still succeeds: a wedged apt must not strand a teardown. Failure is
|
||||
# recorded in _DNSMASQ_PURGE_NOTE and reported in the closing statement, never swallowed.
|
||||
_DNSMASQ_PURGE_NOTE=""
|
||||
_dnsmasq_purge_owned() {
|
||||
local want=("dnsmasq")
|
||||
# dnsmasq-base ships /usr/sbin/dnsmasq and is separately installable — take it back only if we
|
||||
# added it too. Leaving a pre-existing base package is correct; see the WARN below for what that
|
||||
# costs on the next install.
|
||||
if [[ "$(_state_get dnsmasq_base_preexisting)" == "no" ]]; then
|
||||
want+=("dnsmasq-base")
|
||||
fi
|
||||
|
||||
if ! command -v apt-get >/dev/null 2>&1; then
|
||||
_DNSMASQ_PURGE_NOTE="apt-get absent — dnsmasq left installed (stopped + disabled)"
|
||||
log_warn " dnsmasq: apt-get not available — the package stays installed; the unit is stopped + disabled."
|
||||
return 0
|
||||
fi
|
||||
if $DRY_RUN; then
|
||||
log_dry "DEBIAN_FRONTEND=noninteractive apt-get -y purge ${want[*]} (after simulating the blast radius)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# What would come out? Every Remv/Purg line is a package this would remove.
|
||||
local sim removed extra=()
|
||||
sim=$(DEBIAN_FRONTEND=noninteractive apt-get -s purge "${want[@]}" 2>/dev/null) || sim=""
|
||||
if [[ -z "$sim" ]]; then
|
||||
_DNSMASQ_PURGE_NOTE="purge simulation failed — dnsmasq left installed (stopped + disabled)"
|
||||
log_warn " dnsmasq: could not simulate the removal — leaving the package installed (unit stopped + disabled)."
|
||||
log_warn " A reinstall will read it as pre-existing; clear it with: apt-get -y purge ${want[*]}"
|
||||
return 0
|
||||
fi
|
||||
removed=$(printf '%s\n' "$sim" | awk '/^(Remv|Purg) /{print $2}')
|
||||
local p
|
||||
for p in $removed; do
|
||||
case " ${want[*]} " in
|
||||
*" $p "*) ;;
|
||||
*) extra+=("$p") ;;
|
||||
esac
|
||||
done
|
||||
if (( ${#extra[@]} > 0 )); then
|
||||
_DNSMASQ_PURGE_NOTE="purge would also remove ${extra[*]} — dnsmasq left installed (stopped + disabled)"
|
||||
log_warn " dnsmasq: NOT purging — something else now depends on it. Removing it would also take out:"
|
||||
log_warn " ${extra[*]}"
|
||||
log_warn " The unit is stopped + disabled, so :53 is free. The package stays, deliberately."
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info " dnsmasq: removing the package(s) Felhom installed: ${want[*]}"
|
||||
if run env DEBIAN_FRONTEND=noninteractive apt-get -y purge "${want[@]}"; then
|
||||
# Positive observable: assert it is actually gone rather than trusting apt's exit code.
|
||||
if dpkg-query -W -f='${Status}' dnsmasq 2>/dev/null | grep -q "install ok installed"; then
|
||||
_DNSMASQ_PURGE_NOTE="apt-get reported success but dnsmasq is still installed"
|
||||
log_warn " dnsmasq: apt-get exited 0 but the package is STILL installed — treat as not removed."
|
||||
else
|
||||
log_success " dnsmasq removed — the next install starts from a clean slate, as a first install"
|
||||
fi
|
||||
else
|
||||
_DNSMASQ_PURGE_NOTE="apt-get purge failed — dnsmasq left installed (stopped + disabled)"
|
||||
log_warn " dnsmasq: the package removal FAILED. The unit is stopped + disabled, so :53 is free,"
|
||||
log_warn " but a reinstall will read the package as pre-existing. Clear it with:"
|
||||
log_warn " apt-get -y purge ${want[*]}"
|
||||
fi
|
||||
# R-316: the residual case, stated because it is invisible otherwise. If dnsmasq-base pre-dated us
|
||||
# we correctly leave it — but it owns /usr/sbin/dnsmasq, and the agent decides whether to install
|
||||
# by stat-ing exactly that path (felhom-agent internal/lanresolver/lanresolver.go:105). So on such
|
||||
# a host the next install skips the apt step and then fails to enable a unit that is no longer
|
||||
# there. Filed as R-317; named here so nobody has to rediscover it from a silent resolver.
|
||||
if [[ "$(_state_get dnsmasq_base_preexisting)" == "yes" ]]; then
|
||||
log_warn " NOTE: dnsmasq-base pre-dates Felhom and is deliberately kept. It owns /usr/sbin/dnsmasq,"
|
||||
log_warn " which the agent stats to decide whether to install — so on this host the next install"
|
||||
log_warn " may skip the install step and fail to enable the unit (R-317)."
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
run_uninstall() {
|
||||
log_step "UNINSTALL — local host teardown"
|
||||
|
||||
@@ -1098,8 +1184,22 @@ run_uninstall() {
|
||||
if $_dnsmasq_touched || systemctl is-enabled --quiet dnsmasq 2>/dev/null; then
|
||||
case "$_dnsmasq_owner" in
|
||||
no)
|
||||
# R-316 — STOPPING IS NOT REMOVING, AND THE DIFFERENCE COSTS THE *NEXT* CYCLE.
|
||||
#
|
||||
# R-300 stopped + disabled the unit and left the PACKAGE installed. That frees :53, so
|
||||
# cycle 1 looks fixed — and it is. But the next install's preflight asks dpkg "is
|
||||
# dnsmasq installed?", the answer is still yes, so it records `dnsmasq_preexisting=yes`,
|
||||
# the second uninstall then treats OUR OWN package as the household's and leaves it
|
||||
# running, and the third install refuses again with the message that blames the
|
||||
# customer's network. **Measured on drill-r50 2026-08-13, all three cycles, before this
|
||||
# was written.** Every box already in the field is at cycle 2.
|
||||
#
|
||||
# So: stop first (the port must free even if the package operation fails), then remove
|
||||
# what we added. Order matters — the state file that carries this record is deleted
|
||||
# later in this same function, so the record is read before it can be lost.
|
||||
log_info " dnsmasq was installed by Felhom (recorded at install) — stopping + disabling it"
|
||||
run systemctl disable --now dnsmasq || true
|
||||
_dnsmasq_purge_owned
|
||||
;;
|
||||
yes)
|
||||
log_info " dnsmasq pre-dates Felhom (recorded at install) — leaving it running, restarting only"
|
||||
@@ -1175,7 +1275,14 @@ run_uninstall() {
|
||||
# R-300: the packages are still not PURGED (they are system packages and purging them on a host we
|
||||
# may not own is the wrong blast radius) — but dnsmasq's UNIT is now stopped+disabled when the
|
||||
# install-time record says Felhom installed it, so our own leftover no longer blocks our own reinstall.
|
||||
log_info " NOTE: the 'sudo' and 'dnsmasq' packages were left INSTALLED (system packages, not purged); dnsmasq's unit was stopped+disabled only if Felhom installed it. The host record still exists in the hub — remove it there if desired."
|
||||
# R-316: this line used to say dnsmasq was left installed, and that was the defect in one sentence.
|
||||
# `sudo` genuinely is a system package and stays. dnsmasq is now taken back when the install-time
|
||||
# record says we added it — and when that could not be done, the reason is stated HERE rather than
|
||||
# scrolling past, because a teardown that quietly half-completed is how cycle 3 refuses.
|
||||
if [[ -n "$_DNSMASQ_PURGE_NOTE" ]]; then
|
||||
log_warn " NOTE: dnsmasq was NOT removed — $_DNSMASQ_PURGE_NOTE. Its unit is stopped + disabled, so :53 is free, but the NEXT install will read the package as pre-existing."
|
||||
fi
|
||||
log_info " NOTE: the 'sudo' package was left INSTALLED (a system package, never ours to remove). dnsmasq is removed only when the install-time record says Felhom installed it; a resolver that pre-dated Felhom is never touched. The host record still exists in the hub — remove it there if desired."
|
||||
$DRY_RUN && log_warn " DRY-RUN: nothing above was actually executed."
|
||||
return 0
|
||||
}
|
||||
@@ -1715,12 +1822,24 @@ step_preflight() {
|
||||
# heuristic dressed as a fact, and this project has been bitten by exactly that shape.
|
||||
#
|
||||
# Recorded on EVERY mode, because appliance is where the agent actually installs it.
|
||||
# R-316: TWO packages are recorded, not one, and the second is not pedantry.
|
||||
#
|
||||
# `dnsmasq` ships the systemd unit; **`dnsmasq-base` ships /usr/sbin/dnsmasq**. They are separately
|
||||
# installable, and a host can carry the base package for some unrelated reason. The removal must
|
||||
# take back only what WE added, so what we found has to be recorded package by package — the same
|
||||
# rule as before, applied one level finer.
|
||||
if command -v dpkg-query >/dev/null 2>&1 && dpkg-query -W -f='${Status}' dnsmasq 2>/dev/null | grep -q "install ok installed"; then
|
||||
_state_put dnsmasq_preexisting yes
|
||||
log_info " dnsmasq: already installed BEFORE Felhom — recorded; uninstall will not touch it"
|
||||
else
|
||||
_state_put dnsmasq_preexisting no
|
||||
log_info " dnsmasq: not present before Felhom — recorded; uninstall may stop+disable it if we install it"
|
||||
log_info " dnsmasq: not present before Felhom — recorded; uninstall will remove it again if we install it"
|
||||
fi
|
||||
if command -v dpkg-query >/dev/null 2>&1 && dpkg-query -W -f='${Status}' dnsmasq-base 2>/dev/null | grep -q "install ok installed"; then
|
||||
_state_put dnsmasq_base_preexisting yes
|
||||
log_info " dnsmasq-base: already installed BEFORE Felhom — recorded; uninstall will leave it"
|
||||
else
|
||||
_state_put dnsmasq_base_preexisting no
|
||||
fi
|
||||
|
||||
# Host DNS :53 gate (byo only) — GL-8/F6. In byo the agent's own lan-resolver stays OFF (a config
|
||||
|
||||
Reference in New Issue
Block a user