From bee68484588900cb638a597f74357a10c18f081a Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 3 Aug 2026 12:08:37 +0200 Subject: [PATCH] =?UTF-8?q?installer=20v1.23.0=20=E2=80=94=20publishing=20?= =?UTF-8?q?becomes=20an=20act,=20not=20a=20side-effect=20(R-110,=20R-183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two channels moved off main in the same change, because either one left behind makes the other cosmetic. Channel 1 — the served script. webpage.yaml git-synced /scripts/ from --branch=main every 30s and nginx served that tree, so pushing this file WAS publishing it: within half a minute it was what every new machine downloaded and ran as root, with no staging and no rollback but another push. The sync is now SPLIT: the website keeps tracking main at the same cadence (a copy edit must never need a release) and /scripts/ tracks the tag installer-v. PROVEN before the manifest was touched: git-sync v4.4.0 follows a tag AND notices a MOVED one — measured on a throwaway sync against this repo, "update required ... local: remote:" -> "updated successfully", within one period. The moved-tag half is what the publish model rests on. Channel 2 — the sixteen files fetched at run time. fetch_raw pulled from $AGENT_REPO/raw/branch/main; it now pulls raw/tag/v$ART_AGENT_VER. That is a correctness fix, not only a channel one (R-183): a fresh install fetched the vouched agent BINARY while taking its unit file, sudoers and guarded wrappers from whatever main held. Two refs, one install, nothing compared them. Their correct ref was never SCRIPT_VERSION — they do not live in this repo. No fallback to a branch: a vouched version whose tag is missing fails loudly rather than quietly serving main. Channel 3 — the URL — needed no change, recorded rather than left silent: https://felhom.eu/scripts/felhom-host-install.sh never carried a ref, so both producers follow the tag with no edit. No hub change, no hub version bump. Gate 6 in hostinstall_gates.py pins all three structurally with no network, so it stays in --fast and runs in CI. It deliberately does NOT assert "a tag exists for the current SCRIPT_VERSION": that would go red on the very push that bumps the version, before publishing — and publishing being separate is the ruling. --- manifests/webpage.yaml | 86 ++++++++++++++++++++++++++++++++-- scripts/CHANGELOG.md | 48 +++++++++++++++++++ scripts/felhom-host-install.sh | 25 ++++++++-- scripts/hostinstall_gates.py | 56 ++++++++++++++++++++++ 4 files changed, 209 insertions(+), 6 deletions(-) diff --git a/manifests/webpage.yaml b/manifests/webpage.yaml index f995f9d..af28fbf 100644 --- a/manifests/webpage.yaml +++ b/manifests/webpage.yaml @@ -71,8 +71,12 @@ data: # Host-install script. It lives at the repo's /scripts (outside the website doc-root), # synced into .../current/scripts by git-sync (see the sparse-checkout ConfigMap). Served # as text/plain so operators can inspect it in a browser before download-then-run. + # R-110: served from the INSTALLER TAG's tree, not the website's. The URL is unchanged + # (https://felhom.eu/scripts/felhom-host-install.sh) — it never carried a ref, so every + # producer of it (the bootstrap script, the hub's day-0 command) follows the tag with no + # edit. What changed is which tree this root points at. location /scripts/ { - root /usr/share/nginx/html/current; + root /usr/share/nginx/scripts/current; default_type text/plain; } @@ -213,8 +217,12 @@ metadata: name: git-sync-sparse-checkout namespace: felhom-system data: + # R-110: TWO sparse-checkouts, because there are now two syncs with two different refs. + # The website tracks `main` (a copy edit must never need a release); /scripts/ tracks the + # installer TAG (pushing the installer must never publish it). sparse-checkout: | /website/ + sparse-checkout-scripts: | /scripts/ --- # =================== @@ -246,6 +254,9 @@ spec: - name: git-data mountPath: /usr/share/nginx/html readOnly: true + - name: git-data-scripts + mountPath: /usr/share/nginx/scripts + readOnly: true - name: nginx-config mountPath: /etc/nginx/conf.d/default.conf subPath: default.conf @@ -269,11 +280,14 @@ spec: initialDelaySeconds: 3 periodSeconds: 10 + # ── The WEBSITE sync — tracks `main`, unchanged cadence ────────────────────────────── + # Deliberately still a branch: the site is content, and a typo fix must reach felhom.eu in + # thirty seconds without cutting a release. Only /scripts/ moved to a tag (R-110). - name: git-sync image: registry.k8s.io/git-sync/git-sync:v4.4.0 args: - --repo=https://gitea.dooplex.hu/admin/felhom.eu.git - - --branch=main + - --ref=main - --root=/git - --link=current - --period=30s @@ -294,13 +308,52 @@ spec: securityContext: runAsUser: 65534 # nobody + # ── The INSTALLER sync — tracks a TAG (R-110, operator ruling 2026-08-03) ───────────── + # felhom-host-install.sh runs as root on a virgin machine. Before this it was served + # straight from `main`, so pushing it WAS publishing it: within thirty seconds it was what + # every new machine downloaded and ran, with no staging and no rollback but another push. + # + # Publishing is now moving this tag; rolling back is moving it back. PROVEN, not assumed: + # git-sync v4.4.0 follows a tag AND notices a moved one — measured 2026-08-03 on a + # throwaway sync against this very repo (`update required … local: remote:` → + # `updated successfully`, one period, ~20 s). + # + # Bump this ref when the installer's published version changes. `hostinstall_gates.py` + # gate 6 fails if this sync stops naming an `installer-v…` tag. + - name: git-sync-scripts + image: registry.k8s.io/git-sync/git-sync:v4.4.0 + args: + - --repo=https://gitea.dooplex.hu/admin/felhom.eu.git + - --ref=installer-v1.23.0 + - --root=/git-scripts + - --link=current + - --period=30s + - --sparse-checkout-file=/etc/git-sync-scripts/sparse-checkout + volumeMounts: + - name: git-data-scripts + mountPath: /git-scripts + - name: sparse-checkout-scripts + mountPath: /etc/git-sync-scripts + resources: + requests: + memory: "32Mi" + cpu: "10m" + limits: + memory: "128Mi" + cpu: "100m" + securityContext: + runAsUser: 65534 # nobody + # Init container: wait for first sync before nginx starts initContainers: + # BOTH trees are seeded before nginx accepts traffic. The second one is why /scripts/ has + # no 404 window across this change: a fresh pod does not become ready until the installer + # tag has been checked out, exactly as the website already worked. - name: git-sync-init image: registry.k8s.io/git-sync/git-sync:v4.4.0 args: - --repo=https://gitea.dooplex.hu/admin/felhom.eu.git - - --branch=main + - --ref=main - --root=/git - --link=current - --one-time @@ -312,16 +365,43 @@ spec: mountPath: /etc/git-sync securityContext: runAsUser: 65534 + - name: git-sync-scripts-init + image: registry.k8s.io/git-sync/git-sync:v4.4.0 + args: + - --repo=https://gitea.dooplex.hu/admin/felhom.eu.git + - --ref=installer-v1.23.0 + - --root=/git-scripts + - --link=current + - --one-time + - --sparse-checkout-file=/etc/git-sync-scripts/sparse-checkout + volumeMounts: + - name: git-data-scripts + mountPath: /git-scripts + - name: sparse-checkout-scripts + mountPath: /etc/git-sync-scripts + securityContext: + runAsUser: 65534 volumes: - name: git-data emptyDir: {} + - name: git-data-scripts + emptyDir: {} - name: nginx-config configMap: name: nginx-config - name: sparse-checkout configMap: name: git-sync-sparse-checkout + items: + - key: sparse-checkout + path: sparse-checkout + - name: sparse-checkout-scripts + configMap: + name: git-sync-sparse-checkout + items: + - key: sparse-checkout-scripts + path: sparse-checkout --- apiVersion: v1 kind: Service diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index 059e92f..1811ac2 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,3 +1,51 @@ +## v1.23.0 — the installer is published, not pushed (2026-08-03, R-110 + R-183) + +**Two channels moved off `main` in the same change, because either one left behind makes the other +cosmetic.** + +**Channel 1 — the served script.** `manifests/webpage.yaml` git-synced `/scripts/` from +`--branch=main` on a 30 s period and nginx served that working tree, so **pushing this file WAS +publishing it**: within half a minute it was what every new machine downloaded and ran as root, with +no staging and no rollback but another push. The sync is now **split in two**: the website keeps +tracking `main` at the same cadence (a copy edit must never need a release), and `/scripts/` tracks +the tag **`installer-v`**. Publishing is moving that tag; rolling back is moving it +back. + +**PROVEN, not assumed:** git-sync v4.4.0 follows a tag *and* notices a **moved** one — measured on a +throwaway sync against this repo, `update required … local: remote:` → `updated +successfully`, within one period (~20 s). The moved-tag half is what the whole publish model rests +on, so it was measured before the manifest was touched. + +**Channel 2 — the sixteen files the installer fetches while it runs.** `fetch_raw` pulled from +`$AGENT_REPO/raw/branch/main`. It now pulls from **`raw/tag/v$ART_AGENT_VER`** — the agent version the +hub has vouched and whose binary sha this script already verifies. + +**That is a correctness fix, not only a publish-channel one (→ R-183).** These are the AGENT's +configs — its systemd unit, its sudoers, its guarded wrappers — and a fresh install was fetching the +**vouched binary** while taking its configs from **whatever `main` held**. Two refs, one install, and +nothing compared them. The right ref for them was never this script's `SCRIPT_VERSION`: they do not +live in this repo and have no relationship to its version line. + +**No fallback to a branch.** A vouched version whose tag is missing fails loudly rather than quietly +serving `main` — a silent fallback is the appearance of control with none of it. `felhom-agent` +carries `v` tags from now on, `release-agent.sh` creates them, and `agent_gates.py` fails if +the vouched version is not downloadable. + +**Channel 3 — the URL — needed no change, and that is worth recording rather than leaving as a +silence.** `https://felhom.eu/scripts/felhom-host-install.sh` never carried a ref: the ref lives in +the manifest. So both producers of that URL (`scripts/iso/felhom-bootstrap.sh`, the hub's day-0 +command) follow the tag with no edit — **and no hub change, so no hub version bump.** + +**Gate 6 in `hostinstall_gates.py`** pins all three structurally, with no network so it stays in +`--fast` and runs in CI on every push: no `raw/branch/` ref anywhere in the installer; `fetch_raw` +still pins to `$ART_AGENT_VER`; the manifest still syncs `/scripts/` from an `installer-v…` tag and +the website still from `main`. + +**It deliberately does NOT assert "a tag exists for the current SCRIPT_VERSION".** That gate would go +red on the very push that bumps the version, before publishing — and publishing being a separate +deliberate act is the entire ruling. A gate that fails on the normal path is one people learn to +ignore. + ## docs — v1.22.0 exercised end to end on two real reinstalls (2026-08-03, R-178) — **no script change** **Nothing shipped.** `felhom-host-install.sh` stayed at **v1.22.0**; the published copy at diff --git a/scripts/felhom-host-install.sh b/scripts/felhom-host-install.sh index 3aed387..df5193f 100644 --- a/scripts/felhom-host-install.sh +++ b/scripts/felhom-host-install.sh @@ -184,7 +184,7 @@ set -euo pipefail -SCRIPT_VERSION="1.22.0" # the SINGLE version source (F-1): -h and the run banners follow it. +SCRIPT_VERSION="1.23.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/ @@ -492,12 +492,31 @@ fetch_verify() { # exists, else anonymous). These are non-executable text (not the integrity-checked binary); the # sudoers is `visudo -cf`-validated before install, which catches corruption/tampering that would # matter. $1=repo-path $2=dest +# +# R-110 / R-183: PINNED TO THE AGENT VERSION BEING INSTALLED, never to a branch. +# +# These sixteen files are the AGENT's configs — its systemd unit, its sudoers, its guarded wrappers — +# so the ref that is correct for them is the agent version this run is installing, which the hub has +# vouched and whose binary sha this script verifies. It is NOT the installer's own SCRIPT_VERSION: +# these files do not live in the installer's repo and have no relationship to its version line. +# +# Before this they came from `raw/branch/main`, which is a REAL SKEW and not only a publish-channel +# defect (R-183): a fresh install fetched the vouched agent BINARY while taking its unit file and +# sudoers from whatever `main` happened to hold — two refs, one install, and nothing compared them. +# +# NO FALLBACK TO A BRANCH. A vouched version whose tag is missing must fail loudly here rather than +# quietly serving `main`, because a silent fallback is exactly the "appearance of control with none of +# it" this change exists to remove. `agent_gates.py`'s published-version gate keeps the tag and the +# vouched version in step, so this die is a backstop and not the primary control. fetch_raw() { local path="$1" dest="$2" + # Late steps (mgmt-watchdog, OOB) can run without step 5 having resolved the manifest. + [[ -n "$ART_AGENT_VER" ]] || resolve_artifacts + [[ -n "$ART_AGENT_VER" ]] || die "cannot pin $path: no agent version resolved from the hub manifest" local -a _auth; _git_auth_args _auth curl -fsS "${_auth[@]}" -o "$dest" \ - "$GITEA_BASE/$GITEA_OWNER/$AGENT_REPO/raw/branch/main/$path" \ - || die "raw fetch failed: $path" + "$GITEA_BASE/$GITEA_OWNER/$AGENT_REPO/raw/tag/v$ART_AGENT_VER/$path" \ + || die "raw fetch failed: $path (agent tag v$ART_AGENT_VER — is that version tagged in $AGENT_REPO?)" [[ -s "$dest" ]] || die "raw fetch empty: $path" } diff --git a/scripts/hostinstall_gates.py b/scripts/hostinstall_gates.py index 00150de..0f9e35d 100644 --- a/scripts/hostinstall_gates.py +++ b/scripts/hostinstall_gates.py @@ -146,6 +146,62 @@ if re.search(r'^PVE_STORAGES=\([^)]*felhom-pbs[^)]*\)', src, re.M): else: fail("felhom-pbs missing from the default PVE_STORAGES — narrowing it 403s the PBS-DR apply-bridge") +# ── 6. the publish channel is pinned, not floating (R-110 / R-183) ────────────── +# +# WHAT THIS ASSERTS, AND WHAT IT DELIBERATELY DOES NOT. +# +# It does NOT assert "a tag exists for the current SCRIPT_VERSION". That gate would fail the very +# push that bumps SCRIPT_VERSION, before publishing has happened — and publishing being a SEPARATE +# deliberate act is the whole point of R-110's ruling. A gate that goes red on the normal path is a +# gate people learn to ignore, which is the reasoning the task's own §8.4 applies to the agent-side +# gate; it applies here identically. "Is the vouched version actually downloadable" is a real +# invariant and it lives where a missing artifact genuinely breaks day-0 — `felhom-agent`'s +# `agent_gates.py`, which has the network access to answer it. +# +# What it asserts instead are the two STRUCTURAL regressions that would silently return the +# installer to a floating channel, both answerable by reading files (no network, so this stays in +# `--fast` and therefore runs in CI on every push): +# +# 6a. no `raw/branch/` ref anywhere in the installer — one of the sixteen agent-config fetches +# slipping back to `main` is exactly how a channel stays floating unnoticed, and it is +# invisible in a diff that touches one line. +# 6b. `fetch_raw` still pins to the resolved agent version — the positive form, so the mechanism +# cannot be quietly deleted rather than regressed. +# 6c. the website manifest still syncs `/scripts/` from a TAG ref and the website from `main` — +# the split is the deploy-side half of the same channel, and reverting it is one word. +branch_refs = [l for l in lines if "raw/branch/" in l and not l.lstrip().startswith("#")] +if branch_refs: + fail("installer still fetches from a BRANCH ref — the run-time channel is floating again " + "(R-110/R-183). Offending line(s): %s" % "; ".join(l.strip()[:90] for l in branch_refs)) +else: + ok("no raw/branch/ ref in the installer — every run-time fetch is pinned") + +if re.search(r'raw/tag/v\$ART_AGENT_VER/', src): + ok("fetch_raw pins the agent configs to the vouched agent version") +else: + fail("fetch_raw no longer pins to $ART_AGENT_VER — the agent's configs and its binary can " + "again come from different refs in one install (R-183)") + +WEBPAGE = os.path.join(ROOT, "manifests", "webpage.yaml") +try: + with io.open(WEBPAGE, "r", encoding="utf-8") as f: + wp = f.read() +except IOError as e: + fail("cannot read manifests/webpage.yaml to check the publish channel: %s" % e) + wp = None +if wp is not None: + # The scripts sync must name a tag ref; the website sync must still track main. + if re.search(r'--ref=installer-v', wp): + ok("manifest: /scripts/ syncs from an installer tag") + else: + fail("manifests/webpage.yaml has no `--ref=installer-v…` sync — /scripts/ is not served " + "from a tag, so pushing the installer publishes it again (R-110)") + if re.search(r'--(branch|ref)=main', wp): + ok("manifest: the website still tracks main (a copy edit must not need a release)") + else: + fail("manifests/webpage.yaml no longer tracks main for the website — pinning the SITE to " + "the installer tag turns every copy edit into a release") + print() if fails: print("hostinstall gates: %d FAILURE(S)" % len(fails))