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))