R-1228: retire DooPlex's copy of the website (operator yes); the installer pin moves to INSTALLER-TAG
gates / gates (push) Successful in 3m25s

- INSTALLER-TAG (one line) is the only installer pin; publish-site-cloudflare.sh reads it.
- hostinstall gate 6c: pin file holds one tag, no manifest pins one, publish script takes
  the site from origin/main and scripts/ from the tag. Red seen on five mutants.
- manifests: webpage.yaml keeps only FileBrowser; contact-mailer.yaml loses the old
  felhom.eu/api Ingress (an orphan: glued to the next doc by a `80---` typo).
- R-110 fence text, manifests/website rules, build skill, runbooks, poster facts updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-11-07 23:10:21 +01:00
1 parent 7c6d3236df
commit c28ce7ce2f
16 files changed
+107 -418

No files matched your search

+5 -9
View File
@@ -23,13 +23,9 @@ ArgoCD's source of truth is the **manifest in git**:
- `manifest_bearer_gate.py` fails on any bearer-shaped literal anywhere under `manifests/`. It runs
from `python3 scripts/repo_gates.py`.
## The two git-syncs in `webpage.yaml`
## The installer is no longer served from a manifest
`manifests/webpage.yaml` runs **two** git-syncs and they track different refs: the website from
`main`, and `/scripts/` from the tag `installer-v<SCRIPT_VERSION>`. Changing either `--ref` changes
what real machines download — the init container **and** the sidecar both carry it, and both must
move together. `hostinstall_gates.py` gate 6 fails if the manifest stops naming an `installer-v…`
tag or if the website stops tracking `main`. All four git-sync containers (two sidecars, two init containers) read
Gitea **in-cluster** (`http://gitea.gitea-system.svc.cluster.local:3000/…`, since 2026-10-19) and must name the same
repo — gate 6 fails if they differ; the sidecars carry `--max-failures=-1` (retry, never exit). The full publish/rollback procedure is the R-110 fence
in the repo core `CLAUDE.md`.
Until 2026-11-07 `manifests/webpage.yaml` ran DooPlex's copy of the website and pinned the installer tag in two
`--ref` lines. That copy is retired (R-1228); `webpage.yaml` now holds only FileBrowser. The installer tag lives in
`INSTALLER-TAG` at the repo root, and `hostinstall_gates.py` gate 6 fails if any manifest pins one again. The full
publish/rollback procedure is the R-110 fence in the repo core `CLAUDE.md`.
+2 -2
View File
@@ -8,8 +8,8 @@ paths: ["website/**"]
**Since 2026-10-30 (D35 / R-1228) the public site is served by Cloudflare Pages** (project `felhom-site`): a push to
`main` reaches the public site only after `sec run CF_PAGES_TOKEN -- scripts/publish-site-cloudflare.sh` (`--dry-run`
first; it uploads `website/` from `main` and `/scripts/` from the installer tag). DooPlex's git-sync copy keeps tracking
`main` as the way back (`documentation/audits/release-2026-10-30/p3-cloudflare/PLAN.md` §5). No image build.
first; it uploads `website/` from `main` and `/scripts/` from the installer tag). DooPlex's git-sync copy (the way back)
was retired on 2026-11-07 (R-1228); the installer tag is pinned in `INSTALLER-TAG`. No image build.
**Do NOT pin the website to the installer tag.** The sparse-checkout used to cover `/website/` and
`/scripts/` in one sync; pinning that would turn every copy edit into a release. The installer's
+7 -8
View File
@@ -58,18 +58,17 @@ This fence is in the core deliberately: its trigger is editing `scripts/felhom-h
no path-scoped rule covers that file. It governs the one artifact that runs **as root on a virgin
box**.
- **Pushing `scripts/felhom-host-install.sh` to `main` publishes NOTHING.** `manifests/webpage.yaml`
runs two git-syncs: the website from `main`, and `/scripts/` from the tag
`installer-v<SCRIPT_VERSION>`.
- **To publish:** cut `installer-v<new SCRIPT_VERSION>`, bump the `--ref` in `webpage.yaml` (**both**
the sidecar and the init container), commit, sync — **and** (since 2026-10-30, D35: felhom.eu is served by Cloudflare
Pages) run `sec run CF_PAGES_TOKEN -- scripts/publish-site-cloudflare.sh`, which uploads the tag the manifest names.
- **Pushing `scripts/felhom-host-install.sh` to `main` publishes NOTHING.** felhom.eu is Cloudflare Pages (D35); its
`/scripts/` is uploaded from the tag `installer-v<SCRIPT_VERSION>` named in the one-line file `INSTALLER-TAG` (since
2026-11-07, R-1228 — DooPlex's own copy and its two git-syncs are retired), the website from `main`.
- **To publish:** cut `installer-v<new SCRIPT_VERSION>`, put it in `INSTALLER-TAG`, commit, push — **and** run
`sec run CF_PAGES_TOKEN -- scripts/publish-site-cloudflare.sh`, which uploads the tag that file names.
- **To roll back:** move the tag back, then run the same script (~1 min; it re-reads the tag). That is the
emergency lever; fix forward afterwards.
- **Do NOT pin the website to the tag**, and the URL never carries a ref — `felhom-bootstrap.sh` and
the hub's day-0 command follow the tag with no edit.
- `hostinstall_gates.py` gate 6 fails if the manifest stops naming an `installer-v…` tag or if the
website stops tracking `main`.
- `hostinstall_gates.py` gate 6 fails if `INSTALLER-TAG` does not name exactly one `installer-v…` tag, if a manifest
pins a second one, or if the publish script stops taking the site from `main` and `/scripts/` from the tag.
## Workflow — what is specific to this repo
+4
View File
@@ -0,0 +1,4 @@
# The installer version felhom.eu/scripts/ serves (R-110, R-1228). ONE line: installer-v<X.Y.Z>.
# scripts/publish-site-cloudflare.sh uploads scripts/ from THIS tag; hostinstall_gates.py gate 6 checks this file.
# Until 2026-11-07 the pin was the two `--ref=installer-v…` lines in manifests/webpage.yaml (DooPlex's copy, retired).
installer-v1.36.1
+1 -1
View File
@@ -25,7 +25,7 @@
## What we work on
- HTTP2Shell waiters: cal.com, Calibre-Web, RomM have no fix; SparkyFitness has one on a new main version (R-785, behind R-784).
- Dated checks, run by CC. 2026-11-06: ask you to retire DooPlex's old copy of the website (R-1228).
- Retire DooPlex's old copy of the website (your yes, 2026-11-07): the installer's version pin moves out of it first (R-1228).
## Waiting on you — decide
@@ -116,7 +116,7 @@ Footnote: "W = the household's backup window start, 02:30 by default. Tiers 1–
- **Vaultwarden** — the operator's password manager.
- **Prometheus + Alertmanager** — the operator's alarms.
- **The signing keys** — they sign every agent update and config bundle a box accepts.
- **The contact form's mailer** (`api.felhom.eu`) — and, until 2026-11-06, the old copy of the website (the way back after the 2026-10-30 move to Cloudflare).
- **The contact form's mailer** (`api.felhom.eu`). The old copy of the website (the way back after the 2026-10-30 move to Cloudflare) was retired on 2026-11-07.
- **Local backups** — several restic sets on DooPlex's own disks (red outline: "all on the same machine").
- **ep0-copy** — a nightly pull of the whole ep0 datastore back to DooPlex (ciphertext only; DooPlex cannot read households' copies).
+1 -2
View File
@@ -210,7 +210,7 @@ stopping line that lies.
| **R-1206** | Backup & restore | P3 | **Backups written while the box's clock ran ahead stay „the latest" after the clock is fixed.** The rehearsal's scenario #10 (clock set to 2026-10-28) left off-site snapshots dated 2026-10-28; on 2026-10-21 the rebuilt box's recovery page listed every app's „Legutóbbi mentés: 2026. 10. 28. 07:39", and the full restore said „mentés: 2026. 10. 28. 07:37" — a date in the future (`audits/fixes-2026-10-21/P1-live/07-unlock-right.txt`, `11-nextcloud-full-restore.txt`). restic orders snapshots by their own time, so the future ones are „newest" to every retention rule for a week. NOT measured: whether the box's keep-daily prune then drops a real snapshot. Related R-1202, R-1204 (clock). | NARROWED — the NAS tier only. **2026-10-22 night (read, `backup/offbox_window.go:22-24, 130-137`):** on the Felhom off-site tier no real snapshot is lost — the guard refuses a prune while any snapshot is future-dated (R-822, by design). The NAS tier prunes without that guard: open. **2026-10-25:** the kept-data choice no longer takes such a future-dated copy as the newest (R-1271, controller `bd9f4f7`); retention is unchanged — this row. | — | A pure unit test with the policy simulator on the NAS tier and one future-dated snapshot; if a real one is dropped, give the NAS tier the same guard | CC |
| **R-1212** | Backup & restore | P4 | **Two household texts on the backup path do not follow the box's language:** the share-restore summary (`SharesRestoreResult.FlashMessage`, `controller/internal/backup/shares_restore.go`) is built from Hungarian Go literals, and the off-site `LastError` for an interrupted run is the Hungarian literal „megszakadt futás (a vezérlő újraindult futás közben)" (`controller/internal/backup/backup.go`). An English household reads Hungarian there. Found by the night helper 2026-10-21. | OPEN | — | Bundle keys for both (rendered in the box language when written, R-557), with parity fixtures | CC |
| **R-1221** | Backup & restore | P4 | **The nightly whole-box backup starts ~1.5–5 min later each night, until one night is skipped (after ~70–80 nights with no press between).** REAL, low harm (night 2026-10-22; split from R-1189 (b)): agent `localapi/server.go:1184` is `age >= Cadence` with the age counted from the archive START; the controller polls every 5 min (`quiesce.go:150`) and the archive starts after the apps stop. Measured on demo-hp: 04:31:07 → 04:33:18 → 04:36:47 → 04:38:14, and 04:33:37 → 04:38:44 → 04:42:40. At W+6h one night is skipped (44 h is below the 48 h valve, `quiesce.go:807-818`); the hub's 26 h line (`deadline.go:29`) mails the operator. Any manual or off-cycle run resets the drift. Evidence `audits/night-2026-10-22/P3-candidates/demohp-whole-box-completions.txt`. | OPEN | — | Fix: inside the night window a tier is due at `age >= cadence - 4h`; the agent answers due-ness, so it is agent code (a unit test with an injected clock) and rides an agent release | CC |
| **R-1228** | Backup & restore | P3 | **The website, the installer script and the hub are all served from the home line — a DooPlex loss stops new installs and the public site.** DooPlex survey 2026-10-23 night (`audits/dooplex-survival-2026-10-22/PLAN.md`, G8): `felhom.eu`, `hub.felhom.eu` and `gitea.dooplex.hu` resolve to the home address; the installer fetches `felhom.eu/scripts/felhom-host-install.sh`; only the ISO (Cloudflare R2) survives. | OPEN — **2026-10-23 — D35 ruled YES, after the release week (operator):** serve the website and the installer from Cloudflare like the ISO. Dated: start on or after 2026-10-30 (DUE-CHECKS). **2026-10-30: SWITCHED** (operator yes, 10:02:35 UTC): `felhom.eu` and `www` are Cloudflare Pages (`felhom-site`, uploaded by `scripts/publish-site-cloudflare.sh`); 30 of 30 pages and the installer byte-identical to `installer-v1.36.1` from outside; a fresh install from the public ISO 1.30.0 (throwaway VM 3461 on demo-hp) fetched the installer from the test address and from `felhom.eu` and passed the preflight both times; the contact form posts to `api.felhom.eu` (DooPlex mailer, a test mail arrived). `audits/release-2026-10-30/p3-cloudflare/`. **What is left:** a new install still needs DooPlex for the hub (claim, config, first report) and Gitea (the agent package, the golden) — by design (D35); DooPlex's webpage copy runs until 2026-11-06 as the way back (DUE-CHECKS), then the operator decides to retire it. NARROWED **2026-11-07: asked** (demo-check session, in chat); the due date moved to 2026-11-14 to wait for the answer. | the operator's yes | Operator decides (STATUS): A) accept — Gitea and the hub first, the site after; B) the static site + the installer tag on Cloudflare like the ISO — CC's pick, after the release week | CC (ruled yes 2026-10-23, D35) |
| **R-1228** | Backup & restore | P3 | **The website, the installer script and the hub are all served from the home line — a DooPlex loss stops new installs and the public site.** DooPlex survey 2026-10-23 night (`audits/dooplex-survival-2026-10-22/PLAN.md`, G8): `felhom.eu`, `hub.felhom.eu` and `gitea.dooplex.hu` resolve to the home address; the installer fetches `felhom.eu/scripts/felhom-host-install.sh`; only the ISO (Cloudflare R2) survives. | OPEN — **2026-10-23 — D35 ruled YES, after the release week (operator):** serve the website and the installer from Cloudflare like the ISO. Dated: start on or after 2026-10-30 (DUE-CHECKS). **2026-10-30: SWITCHED** (operator yes, 10:02:35 UTC): `felhom.eu` and `www` are Cloudflare Pages (`felhom-site`, uploaded by `scripts/publish-site-cloudflare.sh`); 30 of 30 pages and the installer byte-identical to `installer-v1.36.1` from outside; a fresh install from the public ISO 1.30.0 (throwaway VM 3461 on demo-hp) fetched the installer from the test address and from `felhom.eu` and passed the preflight both times; the contact form posts to `api.felhom.eu` (DooPlex mailer, a test mail arrived). `audits/release-2026-10-30/p3-cloudflare/`. **What is left:** a new install still needs DooPlex for the hub (claim, config, first report) and Gitea (the agent package, the golden) — by design (D35); DooPlex's webpage copy runs until 2026-11-06 as the way back (DUE-CHECKS), then the operator decides to retire it. NARROWED **2026-11-07: asked, and the operator said RETIRE IT (in chat, 23:0x CET).** The copy has no visitors (`felhom.eu` resolves to Cloudflare, also from DooPlex). It is not just a Deployment to delete: `manifests/webpage.yaml` is also where the installer TAG is pinned — `publish-site-cloudflare.sh` reads it and `hostinstall_gates.py` gate 6 checks it, and the R-110 fence in `CLAUDE.md` + `.claude/rules/manifests.md` describes it. So the retirement moves the tag pin to its own home first (script + gate 6 + its decoys + the fence text), then removes the webpage Deployment, Service, Ingress, its two ConfigMaps and TLS Secret by a deliberate ArgoCD sync (FileBrowser in the same file stays). Not started at night during an agent rollout. | the operator's yes | Operator decides (STATUS): A) accept — Gitea and the hub first, the site after; B) the static site + the installer tag on Cloudflare like the ISO — CC's pick, after the release week | CC (ruled yes 2026-10-23, D35) |
| **R-1229** | Backup & restore | P4 | **ArgoCD's per-app auto-sync settings live only on the cluster, not in git.** DooPlex survey 2026-10-23 night (`audits/dooplex-survival-2026-10-22/PLAN.md`, G9): the Applications differ (`enabled` true/false, prune, selfHeal); a rebuilt cluster from `homelab-manifests` would not have them. | OPEN | — | Write each Application's sync policy into `homelab-manifests` (a homelab change, the operator's yes for the commit that changes live behaviour) | operator |
| **R-1231** | Backup & restore | P4 | **The recovery page says the box's host service is „older” when the controller only cannot tell.** Test night 2026-10-23 (`P4-testnight/07a-unlock-refused-fixture-version.txt`): with the agent's capability `unknown` (a version string it could not compare), the page read „…a hozzá tartozó házon belüli szolgáltatás régebbi… A gép magától frissül”. The same page is reached by a merely-down agent, for which „older” and „updates itself” are not true. | OPEN | — | A separate sentence for `unknown` („a gép most nem éri el a házon belüli szolgáltatást — próbáld újra pár perc múlva”); a render test per state; rides a controller release | CC |
| **R-1235** | Backup & restore | P3 | **The release copy on ep0 (D33) is proven to load, not yet to restore a registry — and it follows the newest versions, not the ones the fleet needs.** Built 2026-10-23 (`scripts/release-copy/`, `audits/day-2026-10-23/08-release-copy-D33.txt`). (a) `total-loss-of-dooplex.md` „Restore the release copy” steps 2 (skopeo push back, same digest) and 3 (agent packages back) were never walked into a real new registry — only a `docker load` on 9202. (b) The copy keeps the newest 3 image tags and 5 agent versions; it does not ask the hub for the floor and the vouched agent and always include them — a vouch more than 5 releases behind would fall out. (c) The golden (`felhom-golden`, 637 MB for 0.331.0) is not copied: a new install after a DooPlex loss needs it (the brief named images + agent packages only; one golden fits under the 3 GB cap). | OPEN | — | (a) a throwaway registry on demo-hp, push one tag back, compare digests; (b) read `/configuration`'s floor + vouched agent; (c) the operator says whether the golden goes in | CC |
@@ -406,6 +406,5 @@ stopping line that lies.
| R-1035 | 2026-11-11 | „felhő tárhely” + „saját felhő tárhely” impressions and average position, and the new page's own impressions/clicks/position, 4 weeks after publishing; operator supplies the read; detail in the R-1035 row |
| R-528 | 2026-11-12 | no failed oom_check / unexplained app_oom since 2026-10-12; detail in the R-528 row |
| R-1035 | 2026-12-09 | the same three numbers 8 weeks after publishing, compared with the 4-week read; a null at 4 weeks is not a verdict, both reads are needed; detail in the R-1035 row |
| R-1228 | 2026-11-14 | D35 way back ends: ask the operator to retire DooPlex's felhom.eu/www webpage copy (ingress + git-sync), or keep it longer; detail in the R-1228 row |
<!-- DUE-CHECKS-END -->
| **R-1297** | Process & tooling | P3 | **CI waits dominate a working day: felhom.eu's CI run takes ~13 min and the ONE runner makes every repo's job wait behind it (up to 10–17 min queued).** Measured 2026-10-26 (operator: „these CI waiting times are getting out of hand"): since 10-25 felhom.eu runs median 793 s, the controller/agent/catalog 57–102 s; queue median 5 s, max 548–1023 s. In run 2366, ~10 of 13 minutes are four suites — `test_gate_decoys.py` 232 s, `test_due_checks_gate.py` 133 s, `test_repo_gates.py` 132 s (it runs the whole gate set a SECOND time), `dooplex-offsite/test_dooplex_offsite.py` 69 s — plus `hub-gofmt` ~110 s (a cold Go cache per run). **Ruled 2026-10-26 by the operator: BOTH** — (A) trim felhom.eu CI: the heavy suites only when their own files change, no duplicate full run, a Go cache for hub-gofmt; (B) a second CI runner on DooPlex. | **OPEN — (A) BUILT 2026-10-26 evening, closes after the first nightly run (2026-10-27 03:20 UTC) is read.** Both runners proven at once (jobs 2374 + 2375 on `felhom-gates-runner` / `-2`, 17:11:41Z). felhom.eu CI, one push each: **(a) docs only** 810 s (old runner) → 351 s (2 CPUs) → see the close-out; **(b) a gate script** 351 → 203 s (`ecd82aba`, job 2376); **(c) a hub change** 351 → 118 s (`7e57e824`, job 2378). Heavy suites by path + tag + nightly; `test_repo_gates.py` and due-checks group G test the wiring (130 s each → under 3 s). Decoys through CI's entry point with the filter ON: a docs-only register break → red; an inert gate → GREEN at first (a decoy hole, fixed in `e33ec821`) → red. No Go cache: CI has no Go (the 110 s was security-scan, now 27 s). `audits/r1297-ci-trim-2026-10-26/`. (B) DONE by the operator (`09` §3 decision 267). | — | (A) CC, a run of its own (a CI scope change, ruled); (B) a DooPlex change — a brief or the homelab session; and CC waits on CI only before a release or at the end of a run | CC |
+9 -10
View File
@@ -233,8 +233,9 @@ directions: it makes an operator think a pushed fix is live when it is not, and
is live when it is not. (Measured 2026-08-12: this URL served `1.25.0` while `main` held `1.27.0`,
three and a half hours after the push.)
`manifests/webpage.yaml` runs **two** git-syncs against **different refs**: the website tracks `main`,
and `/scripts/` is checked out from the tag **`installer-v<SCRIPT_VERSION>`**. So this URL serves the
felhom.eu is Cloudflare Pages (D35). `scripts/publish-site-cloudflare.sh` uploads the website from `main` and
`/scripts/` from the tag **`installer-v<SCRIPT_VERSION>`** named in `INSTALLER-TAG` (one line; until 2026-11-07 the
pin was two `--ref` lines in `manifests/webpage.yaml`, DooPlex's retired copy — R-1228). So this URL serves the
**tagged** installer, not `main`.
**What actually publishes it — three acts, and the middle one is two lines, not one:**
@@ -242,15 +243,13 @@ and `/scripts/` is checked out from the tag **`installer-v<SCRIPT_VERSION>`**. S
1. Bump `SCRIPT_VERSION` in `scripts/felhom-host-install.sh` (the single version source) and push to
`main`. *Nothing is published yet.*
2. Cut and push the tag `installer-v<new SCRIPT_VERSION>`.
3. Move **BOTH** `--ref=installer-v…` pins in `manifests/webpage.yaml` — the git-sync **sidecar** and
the **init container** (today lines 327 and 372) — commit, and sync ArgoCD. **Moving one pin is the
trap**: the running pod keeps serving until it restarts, and then a fresh pod seeded by the stale
init container serves the OLD script with no error anywhere.
3. Put the new tag in `INSTALLER-TAG`, commit, push, then upload:
`sec run CF_PAGES_TOKEN -- scripts/publish-site-cloudflare.sh` (`--dry-run` first).
**To roll back:** move the tag back and wait ~30 s. No ArgoCD sync, no deploy — that is the emergency
lever; fix forward afterwards. **Do not pin the website to the tag**, or every copy edit becomes a
release. `hostinstall_gates.py` gate 6 fails if the manifest stops naming an `installer-v…` tag or if
the website stops tracking `main`.
**To roll back:** move the tag back, then run the same script (~1 min; it re-reads the tag). That is the
emergency lever; fix forward afterwards. **Do not pin the website to the tag**, or every copy edit becomes a
release. `hostinstall_gates.py` gate 6 fails if `INSTALLER-TAG` does not name exactly one tag, if a manifest
pins a second one, or if the publish script stops taking the site from `main` and `/scripts/` from the tag.
**How to verify from OUTSIDE that the published version actually changed** — a push, a green sync and a
correct-looking manifest are each consistent with nothing having been published, so ask the public URL
@@ -114,9 +114,9 @@ one stops an ISO from carrying a golden the floor has already outrun.
## 6. The website and the installer reach the public through Cloudflare (D35 / R-1228, 2026-10-30)
`felhom.eu` (site + `/scripts/felhom-host-install.sh`) is Cloudflare Pages since 2026-10-30. A push to `main` and a
new `installer-v…` tag change only DooPlex's copy (the way back); the public copy changes with
`sec run CF_PAGES_TOKEN -- scripts/publish-site-cloudflare.sh` (`--dry-run` first). The script reads the installer tag
from `manifests/webpage.yaml`, so bump both `--ref`s there FIRST, then upload — and it refuses an installer that is not
`felhom.eu` (site + `/scripts/felhom-host-install.sh`) is Cloudflare Pages since 2026-10-30; DooPlex's copy (the way
back) was retired on 2026-11-07. A push to `main` or a new `installer-v…` tag changes nothing public; the public copy
changes with `sec run CF_PAGES_TOKEN -- scripts/publish-site-cloudflare.sh` (`--dry-run` first). The script reads the
installer tag from `INSTALLER-TAG`, so put the new tag there FIRST, then upload — and it refuses an installer that is not
the tag's byte for byte. Check after: `felhom.eu/scripts/felhom-host-install.sh`'s sha256 equals
`git show installer-v<X>:scripts/felhom-host-install.sh | sha256sum`.
+1 -1
View File
@@ -280,7 +280,7 @@ attempted; reachability is the point.
The operator ruled that `felhom.eu` stays anonymously readable for the moment. Making it private
breaks two live paths that clone it with **no credentials**: the website git-sync in
`manifests/webpage.yaml`, and the installer fetched from the `installer-v…` tag by every new box
`manifests/webpage.yaml` (retired 2026-11-07, R-1228 — no longer a reason), and the installer fetched from the `installer-v…` tag by every new box
(R-110). Both would have to be given credentials first. The standing consequence of that ruling:
**no secret may ever enter this repo again** — which `manifest_bearer_gate.py` now enforces with no
exemption. If the decision is ever reversed, the 32 `<!-- source: … -->` comments served on
-31
View File
@@ -112,37 +112,6 @@ spec:
- port: 80
targetPort: 8080
---
# Ingress: routes felhom.eu/api/* to the contact mailer
# This is a SEPARATE ingress from the website - nginx-ingress merges them
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: contact-mailer
namespace: felhom-system
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
# Allow larger uploads for attachments
nginx.ingress.kubernetes.io/proxy-body-size: "25m"
# Timeout for large file uploads
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
nginx.ingress.kubernetes.io/proxy-send-timeout: "60"
spec:
ingressClassName: nginx-internal
tls:
- hosts:
- felhom.eu
secretName: felhom-webpage-tls
rules:
- host: felhom.eu
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: contact-mailer
port:
number: 80---
# D35 / R-1228 (operator yes 2026-10-30): the website moves to Cloudflare Pages, which cannot run this mailer. The form
# posts here instead (website `formEndpoint` = https://api.felhom.eu/api/contact); the mailer's CORS already answers
# for https://felhom.eu and https://www.felhom.eu. DNS: api.felhom.eu CNAME → the home line, NOT proxied (HTTP-01).
-314
View File
@@ -43,68 +43,6 @@ data:
}
---
# ===================
# NGINX CONFIG FOR CLEAN URLs
# ===================
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-config
namespace: felhom-system
data:
default.conf: |
server {
listen 80;
server_name _;
root /usr/share/nginx/html/current/website;
index index.html;
# R-931 (2026-10-12): compress text. Measured before: site.css went out as 114 784 raw bytes, no
# Content-Encoding. text/html is always compressed once gzip is on; the list adds the rest.
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
gzip_types text/css application/javascript text/javascript application/json image/svg+xml text/plain application/xml;
# Enable clean URLs - serve .html files without extension
location / {
try_files $uri $uri.html $uri/ =404;
}
location = /sitemap.xml {
types { application/xml xml; }
default_type application/xml;
}
# 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/scripts/current;
default_type text/plain;
}
# Cache static assets
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 7d;
add_header Cache-Control "public, immutable";
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
# Error pages
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
}
---
# ===================
# FILEBROWSER
# ===================
apiVersion: apps/v1
@@ -219,255 +157,3 @@ spec:
name: filebrowser
port:
number: 80
---
apiVersion: v1
kind: ConfigMap
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/
---
# ===================
# WEBPAGE (nginx)
# ===================
apiVersion: apps/v1
kind: Deployment
metadata:
name: felhom-webpage
namespace: felhom-system
labels:
app: felhom-webpage
spec:
replicas: 1
selector:
matchLabels:
app: felhom-webpage
template:
metadata:
labels:
app: felhom-webpage
annotations:
# The nginx config is mounted with subPath, which never refreshes in a running pod: bump this when
# the nginx-config ConfigMap changes, so the sync restarts the pod onto it.
felhom.eu/nginx-config-rev: "2026-10-12-gzip"
spec:
containers:
- name: nginx
image: nginx:alpine
ports:
- containerPort: 80
volumeMounts:
- 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
resources:
requests:
memory: "32Mi"
cpu: "10m"
limits:
memory: "128Mi"
cpu: "200m"
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 30
readinessProbe:
httpGet:
path: /
port: 80
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).
#
# All four git-syncs read Gitea IN-CLUSTER (plain HTTP, never leaves the cluster — the hub's own Gitea
# client does the same): no internet hop, so the pod can start during an internet outage (2026-10-19 09:24
# the public path failed once and the sidecar restarted). If the website ever leaves the DooPlex cluster,
# this must go back to https://gitea.dooplex.hu/admin/felhom.eu.git. `hostinstall_gates.py` gate 6 fails
# if the four `--repo` values differ.
- name: git-sync
image: registry.k8s.io/git-sync/git-sync:v4.4.0
args:
- --repo=http://gitea.gitea-system.svc.cluster.local:3000/admin/felhom.eu.git
- --ref=main
- --root=/git
- --link=current
- --period=30s
- --max-failures=-1 # an outage must not restart the sidecar (git-sync v4.4.0 main.go L922: <0 = retry forever)
# Only sync the website subdirectory
- --sparse-checkout-file=/etc/git-sync/sparse-checkout
volumeMounts:
- name: git-data
mountPath: /git
- name: sparse-checkout
mountPath: /etc/git-sync
resources:
requests:
memory: "32Mi"
cpu: "10m"
limits:
memory: "128Mi"
cpu: "100m"
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:<old> remote:<new>` →
# `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=http://gitea.gitea-system.svc.cluster.local:3000/admin/felhom.eu.git
- --ref=installer-v1.36.1
- --root=/git-scripts
- --link=current
- --period=30s
- --max-failures=-1 # an outage must not restart the sidecar (git-sync v4.4.0 main.go L922: <0 = retry forever)
- --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=http://gitea.gitea-system.svc.cluster.local:3000/admin/felhom.eu.git
- --ref=main
- --root=/git
- --link=current
- --one-time
- --sparse-checkout-file=/etc/git-sync/sparse-checkout
volumeMounts:
- name: git-data
mountPath: /git
- name: sparse-checkout
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=http://gitea.gitea-system.svc.cluster.local:3000/admin/felhom.eu.git
- --ref=installer-v1.36.1
- --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
metadata:
name: felhom-webpage
namespace: felhom-system
spec:
selector:
app: felhom-webpage
ports:
- port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: felhom-webpage
namespace: felhom-system
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx-internal
tls:
- hosts:
- felhom.eu
- www.felhom.eu
secretName: felhom-webpage-tls
rules:
- host: felhom.eu
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: felhom-webpage
port:
number: 80
- host: www.felhom.eu
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: felhom-webpage
port:
number: 80
+17
View File
@@ -1,3 +1,20 @@
## 2026-11-07 — the installer pin moves to `INSTALLER-TAG`; DooPlex's copy of the website retired (R-1228, operator yes)
- **Why.** Since 2026-10-30 Cloudflare Pages serves felhom.eu; DooPlex's git-sync + nginx copy was the way back until
2026-11-06, and the operator said retire it. That copy's manifest was ALSO the installer pin (two `--ref` lines), read by
`publish-site-cloudflare.sh` and checked by gate 6 — so the pin moved first.
- **`INSTALLER-TAG`** (repo root, one line `installer-v1.36.1`) is now the only pin. `publish-site-cloudflare.sh` reads it
(refuses anything but exactly one `installer-vX.Y.Z`). Dry run: installer sha256 `e18cdd17…` = the public file's.
- **`hostinstall_gates.py` gate 6 (6c)** now checks: the pin file holds one tag; no manifest pins an installer tag; the
publish script reads the pin file and takes the site from `origin/main`, `/scripts/` from the tag. Red seen on five
mutants (the old manifest pin, two tags, a bad tag shape, site from the tag, the script reading the manifest).
- **Manifests:** `webpage.yaml` keeps only FileBrowser (removed: the webpage Deployment, Service, Ingress, `nginx-config`,
`git-sync-sparse-checkout`). `contact-mailer.yaml`: the old `felhom.eu/api` Ingress block removed — it had been glued
to the next document by a `number: 80---` typo, so git already parsed only `contact-mailer-api`; the live object
was an orphan. The form's route (`api.felhom.eu`, its own certificate) is unchanged.
- **Docs:** the R-110 fence in `CLAUDE.md`, `.claude/rules/manifests.md`, `.claude/rules/website.md`, the build skill,
`day0-install.md`, `publish-train-rules.md`, `secrets.md`, the poster facts.
## 2026-11-02 — the watch: stated unreadable pins are GAP, timeouts retried, every failed source printed (R-1334); R-1310's rules
- **Measured first** (`documentation/audits/security-2026-11-02/part1/DIAGNOSIS.md`): five real nights, both runners, 0 Docker Hub 429. A
+45 -25
View File
@@ -182,8 +182,8 @@ else:
# 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.
# 6c. INSTALLER-TAG is the one pin, no manifest pins a second one, and the publish script takes the site
# from `main` and `/scripts/` from the tag — the deploy-side half of the same channel (R-1228).
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 "
@@ -197,34 +197,54 @@ 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")
# Since 2026-11-07 (R-1228, DooPlex's copy retired) the installer tag is pinned in ONE place, INSTALLER-TAG, and
# Cloudflare Pages is the only publish path (scripts/publish-site-cloudflare.sh). What must hold:
# - INSTALLER-TAG names exactly one `installer-vX.Y.Z` (the script refuses anything else; the gate says so first);
# - no manifest names an installer tag any more — a second pin is how two copies serve different installers;
# - the publish script reads the pin file, takes the site from origin/main and scripts/ from the tag.
PIN = os.path.join(ROOT, "INSTALLER-TAG")
try:
with io.open(WEBPAGE, "r", encoding="utf-8") as f:
wp = f.read()
with io.open(PIN, "r", encoding="utf-8") as f:
pin_lines = [l.strip() for l in f if l.strip() and not l.lstrip().startswith("#")]
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")
fail("cannot read INSTALLER-TAG (the installer pin, R-110/R-1228): %s" % e)
pin_lines = None
if pin_lines is not None:
if len(pin_lines) == 1 and re.match(r'^installer-v\d+\.\d+\.\d+$', pin_lines[0]):
ok("INSTALLER-TAG pins one installer tag (%s)" % pin_lines[0])
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)")
fail("INSTALLER-TAG must hold exactly ONE line `installer-vX.Y.Z` (found: %s) — the publish script "
"refuses it, and the public installer cannot be moved" % (pin_lines or "nothing"))
stale = []
for dirpath, _dirs, files in os.walk(os.path.join(ROOT, "manifests")):
for fn in files:
if fn.endswith((".yaml", ".yml")):
with io.open(os.path.join(dirpath, fn), "r", encoding="utf-8") as f:
if re.search(r'--ref=installer-v', f.read()):
stale.append(os.path.relpath(os.path.join(dirpath, fn), ROOT))
if stale:
fail("a manifest still pins an installer tag (%s) — the pin lives ONLY in INSTALLER-TAG since R-1228; "
"two pins can serve two different installers" % ", ".join(stale))
else:
ok("no manifest pins an installer tag (INSTALLER-TAG is the only pin)")
PUB = os.path.join(ROOT, "scripts", "publish-site-cloudflare.sh")
try:
with io.open(PUB, "r", encoding="utf-8") as f:
pub = f.read()
except IOError as e:
fail("cannot read scripts/publish-site-cloudflare.sh (the only publish path): %s" % e)
pub = None
if pub is not None:
code = "\n".join(l for l in pub.splitlines() if not l.lstrip().startswith("#"))
if '"$REPO/INSTALLER-TAG"' in code:
ok("publish script reads INSTALLER-TAG")
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")
# All four git-sync containers (two sidecars, two init containers) must read the SAME repo: they moved to
# the in-cluster Gitea address together (2026-10-19), and an init container left on another URL seeds the
# pod from a different source than the sidecar that keeps it fresh.
repos = re.findall(r'--repo=(\S+)', wp)
if len(repos) == 4 and len(set(repos)) == 1:
ok("manifest: all four git-syncs read the same repo (%s)" % repos[0])
fail("publish-site-cloudflare.sh no longer reads INSTALLER-TAG — it can upload an installer nobody pinned")
if re.search(r'archive origin/main website', code) and re.search(r'archive "\$TAG" scripts', code):
ok("publish script: the site from main, scripts/ from the tag (a copy edit must not need a release)")
else:
fail("manifests/webpage.yaml: the git-sync `--repo` values differ or are not four (%s) — the "
"sidecars and init containers must read one repo" % ", ".join(repos))
fail("publish-site-cloudflare.sh: the site must come from origin/main and scripts/ from the tag — "
"pinning the SITE to the tag turns every copy edit into a release; scripts/ from main publishes on push")
# ── R-185: every path that RESOLVES the backup target must also grant on it ──────────────────
#
+9 -9
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env bash
# publish-site-cloudflare.sh [--dry-run] [--project NAME] — D35 / R-1228 (2026-10-30): the public website and the
# installer, uploaded to Cloudflare Pages. It replaces „push to main and DooPlex's git-sync serves it" for the copy on
# Cloudflare; DooPlex keeps serving its own copy until the DNS switch, and for 7 days after it as the way back.
# installer, uploaded to Cloudflare Pages. It is the ONLY publish path: DooPlex's own copy (git-sync + nginx) was the
# way back until 2026-11-06 and was retired on 2026-11-07 (operator yes).
#
# WHAT IT UPLOADS — the same files DooPlex serves today, from the same refs:
# / website/ from origin/main (DooPlex: git-sync --ref=main, nginx root website/)
# /scripts/ scripts/ from the installer TAG (DooPlex: git-sync --ref=installer-v<X>, nginx location /scripts/)
# The tag is read from manifests/webpage.yaml, so the two copies can never name different installers. Clean URLs
# WHAT IT UPLOADS:
# / website/ from origin/main (a copy edit never needs a release)
# /scripts/ scripts/ from the installer TAG (pushing the installer publishes nothing — R-110)
# The tag is read from INSTALLER-TAG at the repo root (one line; hostinstall_gates.py gate 6 checks it). Clean URLs
# (/kapcsolat → kapcsolat.html), 404.html and compression are Pages' own behaviour — nothing to configure.
#
# Run: sec run CF_PAGES_TOKEN -- scripts/publish-site-cloudflare.sh [--dry-run]
@@ -30,9 +30,9 @@ NODE_IMAGE="node:20.17.0-alpine"
REPO=$(git -C "$(dirname "$0")" rev-parse --show-toplevel)
git -C "$REPO" fetch -q origin main --tags
TAG=$(grep -oE -- '--ref=installer-v[0-9]+\.[0-9]+\.[0-9]+' "$REPO/manifests/webpage.yaml" | sort -u | sed 's/--ref=//')
if [ "$(printf '%s\n' "$TAG" | wc -l)" != 1 ] || [ -z "$TAG" ]; then
echo "REFUSED: manifests/webpage.yaml must name exactly ONE installer tag (found: ${TAG:-none})" >&2; exit 1
TAG=$(grep -v '^[[:space:]]*#' "$REPO/INSTALLER-TAG" | grep -oE '^installer-v[0-9]+\.[0-9]+\.[0-9]+$' || true)
if [ "$(grep -v '^[[:space:]]*#' "$REPO/INSTALLER-TAG" | grep -c .)" != 1 ] || [ -z "$TAG" ]; then
echo "REFUSED: INSTALLER-TAG must hold exactly ONE line installer-vX.Y.Z (found: ${TAG:-none})" >&2; exit 1
fi
git -C "$REPO" rev-parse -q --verify "refs/tags/$TAG" >/dev/null || { echo "REFUSED: tag $TAG does not exist" >&2; exit 1; }
SITE_REV=$(git -C "$REPO" rev-parse --short origin/main)
+1 -1
View File
@@ -227,7 +227,7 @@ Green gate before any hub commit: `go build ./... && go vet ./... && go test ./.
## Website (felhom.eu/website)
Since 2026-10-30 (D35) the public site is Cloudflare Pages: push to `main`, then `sec run CF_PAGES_TOKEN --
scripts/publish-site-cloudflare.sh` (`--dry-run` first; DooPlex's git-sync copy is only the way back). **Run `python3 scripts/site_gates.py`
scripts/publish-site-cloudflare.sh` (`--dry-run` first; DooPlex's git-sync copy was retired 2026-11-07, R-1228 — the installer tag is in `INSTALLER-TAG`). **Run `python3 scripts/site_gates.py`
first, after ANY website change** (BOM, emoji, nav parity, cache-bust `?v=N` — bump it when css/svg
change). New pages must be added to the script's `PAGES` list. Emergency edits: https://files.felhom.eu.