diff --git a/documentation/pilot/GUIDE-omv-kvm-hu.md b/documentation/pilot/GUIDE-omv-kvm-hu.md index ad6eca5c..7f895e08 100644 --- a/documentation/pilot/GUIDE-omv-kvm-hu.md +++ b/documentation/pilot/GUIDE-omv-kvm-hu.md @@ -173,9 +173,13 @@ Lépések: - **Mentések:** egy lemezen a Felhom a saját mentéseit ugyanarra a lemezre teszi. Ez véd a véletlen törlés és a hibás fájlok ellen, de **nem véd az NVMe meghibásodása ellen**. Ezt a Felhom vezérlőpultja ki is írja. Ehhez kell egy második meghajtó vagy a távoli mentés — ezt beszéljük meg. -- **Jellyfin (filmek):** egy lemezzel a Jellyfin telepítése most egy plusz lépést kér a vezérlőpulton (egy adattároló - kijelölését). **Ezt a hívás közben együtt csináljuk.** A Felhom Jellyfinje az OMV hálózati megosztását ma **nem** - tudja filmforrásként használni. +- **Második lemez a Felhom gépének:** csak akkor véd lemezhiba ellen, ha az OMV gépében egy **másik fizikai lemez**, és + **egészében** adod át (nem egy fájl ugyanazon a lemezen), **Bus: `sata`**. Egy virtuális gépen belül a Felhom ezt + nem tudja ellenőrizni, ezért a második lemezre tett másolatot nem számolja második másolatnak, és ezt ki is írja. +- **Jellyfin (filmek):** az új telepítő egy lemezen is létrehoz egy saját adatterületet („Belső tárhely"), így a + Jellyfin és a többi adatot tároló alkalmazás plusz lépés nélkül települ, és a filmek nem kerülnek bele a teljes + rendszermentésbe. Ha a géped egy korábbi telepítővel készült, az adatterületet mi adjuk hozzá távolról — szólj. + A Felhom Jellyfinje az OMV hálózati megosztását ma **nem** tudja filmforrásként használni. - **A Toshiba lemez** az OMV-nél marad. Ha később mégis a Felhomnak adnád, a **Bus** legyen **`sata`** (a `virtio`-t a Felhom nem tudja biztonságosan azonosítani). A Felhom egy virtuális gépen belül nem látja a lemezek állapotjelzőit (SMART) — azokat az OMV figyeli. diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index b49552bf..ce89ac83 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,3 +1,18 @@ +## 2026-10-16 — felhom-host-install.sh 1.33.0: the data volume by the ruled table; the internal data place (step 5b) + +`architecture/16-storage-physical-identity.md` §5, §7 (sizes ruled by the operator 2026-10-16 after the build B space check, +`documentation/audits/storage-identity-buildB-2026-10-15/SPACE-CHECK.md`). **Pushed, NOT published** — publishing is the +`installer-v1.33.0` tag + `manifests/webpage.yaml` (R-110), and waits for the operator's vouch of agent 0.158.1: before it, +step 5b only logs that the vouched bundle has no helper, and the smaller data volume would leave the rest of the pool unused. +- **Step 3** sizes the data volume by the thin POOL: 50 GiB below 300, 100 up to 800, 130 from 800 (`felhom_datavol_gib`; + before: 70/130/250 by free space). +- **Step 5b (new)**, appliance mode only: the bundle's root helper `felhom-internal-place create <32 + data volume>` makes the + internal place in the rest of the pool (reserve max(16 GiB, 10 %) ≤ 48 GiB; only when ≥ 20 GiB) and the step writes the + record the agent mounts from. Never fatal; a byo host's pool is never divided; a re-run never formats again. +- The uninstall removes the helper and names the internal place's LV under KEPT (household data — never removed). +- Tests: `scripts/test_hostinstall.py` (the ruled table, step 3 from the pool, step 5b in every outcome, its place in the + flow), each red-proved by a mutation. + ## 2026-10-15 (night) — read_credential.py refuses to write over the credentials file (R-1088) - `read_credential.py KEY ~/.config/credentials` — the credentials path given as the OUTFILE — truncated the operator's diff --git a/scripts/felhom-host-install.sh b/scripts/felhom-host-install.sh index 77def038..dbca22d6 100644 --- a/scripts/felhom-host-install.sh +++ b/scripts/felhom-host-install.sh @@ -184,7 +184,7 @@ set -euo pipefail -SCRIPT_VERSION="1.32.0" # the SINGLE version source (F-1): -h and the run banners follow it. +SCRIPT_VERSION="1.33.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/ @@ -866,6 +866,10 @@ _uninstall_statement() { else echo " - the enrolled drives + ALL data under /mnt/felhom-drives — left MOUNTED (remaining guests may use them)" fi + if lvs pve/felhom-belso >/dev/null 2>&1; then # 1.33.0 (`16` §5) + echo " - the internal data place (LV pve/felhom-belso, household data) — NEVER removed by an uninstall; once the" + echo " data is saved elsewhere: lvremove pve/felhom-belso" + fi if pvesm status 2>/dev/null | awk '$2=="pbs"{found=1} END{exit !found}'; then echo " - the PBS backups + this customer's namespace on the PBS side — delete there if wanted" fi @@ -1353,6 +1357,8 @@ run_uninstall() { if [[ -f /usr/local/sbin/felhom-backup-target-apply ]]; then run rm -f /usr/local/sbin/felhom-backup-target-apply; else log_skip " felhom-backup-target-apply already absent"; fi # agent v0.156.1 (R-1006): the dead half-written vzdump remover the config bundle installs. if [[ -f /usr/local/sbin/felhom-vzdump-partial-clean ]]; then run rm -f /usr/local/sbin/felhom-vzdump-partial-clean; else log_skip " felhom-vzdump-partial-clean already absent"; fi + # 1.33.0 (`16` §5): the internal-place helper. Its VOLUME (pve/felhom-belso) holds household data and is KEPT. + if [[ -f /usr/local/sbin/felhom-internal-place ]]; then run rm -f /usr/local/sbin/felhom-internal-place; else log_skip " felhom-internal-place already absent"; fi if [[ -f /usr/local/sbin/felhom-os-apply ]]; then run rm -f /usr/local/sbin/felhom-os-apply; else log_skip " felhom-os-apply already absent"; fi # R-881: the content checker the config bundle installs since agent v0.146.1 (R-861). if [[ -f /usr/local/sbin/felhom-priv-apply ]]; then run rm -f /usr/local/sbin/felhom-priv-apply; else log_skip " felhom-priv-apply already absent"; fi @@ -2245,6 +2251,16 @@ step_token() { _state_mark token } +# felhom_datavol_gib → the guest's data volume (mp0) in GiB (felhom.eu architecture/16 §7, ruled +# 2026-10-16): 50 below a 300 GiB pool, 100 up to 800, 130 from 800. Pinned by scripts/test_hostinstall.py. +felhom_datavol_gib() { + local pool=${1:-0} + if (( pool >= 800 )); then echo 130 + elif (( pool >= 300 )); then echo 100 + else echo 50 + fi +} + #------------------------------------------------------------------------------- # STEP 3 — compute grows (floors) if not passed #------------------------------------------------------------------------------- @@ -2259,20 +2275,16 @@ step_grows() { # space. (Before R-165 this same block already did the deriving; the merge only collapsed its # 80/20 docker-vs-sysdata split into a single total.) if [[ -z "$ROOTFS_GROW$DATAVOL_GROW$SYSDATA_GROW" ]]; then - local free_gib - free_gib=$(lvs --noheadings --units g -o lv_size,data_percent /dev/pve/data 2>/dev/null | awk '{gsub(/[^0-9.]/,"",$1); used=$2; print int($1*(100-used)/100)}' 2>/dev/null || echo 0) - # Reserve headroom; the totals below are the pre-merge pair SUMMED, so an appliance gets the - # same capacity it did before — it is simply no longer split by a wall. + # 1.33.0 (felhom.eu architecture/16 §7, sizes ruled by the operator 2026-10-16 after the build B space check, + # audits/storage-identity-buildB-2026-10-15/SPACE-CHECK.md): the data volume is sized by the thin POOL, and + # capped — the whole catalog needs ≈ 74 GB; the rest of the pool goes to the internal data place (step 5b), + # which is outside every whole-box copy. Before 1.33.0: 70/130/250 by FREE space. + local pool_gib + pool_gib=$(lvs --noheadings --units g --nosuffix -o lv_size /dev/pve/data 2>/dev/null | awk '{print int($1)}' 2>/dev/null || echo 0) ROOTFS_GROW=0 - if [[ "${free_gib:-0}" -ge 300 ]]; then - DATAVOL_GROW=226 # 184+42 -> the standard 250G appliance (was 200G+50G) - elif [[ "${free_gib:-0}" -ge 150 ]]; then - DATAVOL_GROW=106 # 84+22 - else - DATAVOL_GROW=46 # 34+12 — minimal floor - fi + DATAVOL_GROW=$(( $(felhom_datavol_gib "${pool_gib:-0}") - 24 )) # the golden's data volume is 24G SYSDATA_GROW=0 - log_info " auto-computed from ~${free_gib} GiB free (ONE volume since R-165)" + log_info " auto-computed from a ${pool_gib:-?} GiB thin pool (16 §7: 50/100/130 GiB)" fi ROOTFS_GROW="${ROOTFS_GROW:-0}"; DATAVOL_GROW="${DATAVOL_GROW:-0}"; SYSDATA_GROW="${SYSDATA_GROW:-0}" # A hand-passed --sysdata-grow is still ACCEPTED and still counts: the agent folds it into the one @@ -2281,6 +2293,65 @@ step_grows() { _state_mark grows } +#------------------------------------------------------------------------------- +# STEP 5b — the internal data place („Belső tárhely", felhom.eu architecture/16 §5, §7; installer 1.33.0) +#------------------------------------------------------------------------------- +# A second thin volume in local-lvm for the household's own data (films, photos, documents): media and data-folder +# apps install there with no extra step, and it is outside every whole-box copy (a host mount, like a drive). The +# root helper from the agent's config bundle (felhom-internal-place) does the work — the SAME helper a signed +# add_internal_place job runs on an old box — so the size rule and the only mkfs live in one place. The installer +# passes the thin space the guest will still take (32 + the data volume) and writes the record the agent mounts from. +# Never fatal: a box without the place still works (a media app then asks for a drive in words, `16` §5). +step_internal_place() { + log_step "5b/8 internal data place (Belső tárhely)" + if [[ "$MODE" != "appliance" ]]; then + log_skip " byo: the host owner's thin pool is not ours to divide — no internal place" + _state_mark internal_place; return 0 + fi + local helper=/usr/local/sbin/felhom-internal-place + if [[ ! -x "$helper" ]]; then + log_warn " the vouched agent's config bundle has no internal-place helper (agent older than 0.158.0) — skipped; a signed add_internal_place job can add it later" + _state_mark internal_place; return 0 + fi + local planned=$(( 32 + ROOTFS_GROW + 24 + DATAVOL_GROW + SYSDATA_GROW )) + # A guest that already exists (a re-run after provision) is counted by the helper itself — plan nothing twice. + [[ -n "$(felhom_guests)" ]] && planned=0 + if $DRY_RUN; then + log_dry "$helper create $planned ; write $AGENT_STATE_DIR/internal-place.json (0600, $AGENT_USER)" + log_info " $("$helper" plan "$planned" 2>&1 | head -1)" + return 0 + fi + local out + if ! out=$("$helper" create "$planned" 2>&1); then + log_warn " the internal place was NOT made: ${out}" + _state_mark internal_place; return 0 + fi + case "$out" in + created\ *|existing\ *|formatted\ *) + local uuid gib + uuid=$(sed -n 's/.* uuid=\([0-9a-fA-F-]*\) .*/\1/p' <<<"$out") + gib=$(sed -n 's/.* gib=\([0-9]*\)$/\1/p' <<<"$out") + [[ -n "$uuid" && -n "$gib" ]] || { log_warn " unexpected helper output: $out"; _state_mark internal_place; return 0; } + install -d -o "$AGENT_USER" -g "$AGENT_USER" -m 0700 "$AGENT_STATE_DIR" + UUID="$uuid" GIB="$gib" OUT="$AGENT_STATE_DIR/internal-place.json" python3 - <<'PY' +import json, os, time +rec = {"fs_uuid": os.environ["UUID"], "gib": int(os.environ["GIB"]), "made_by": "installer", + "made_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())} +tmp = os.environ["OUT"] + ".tmp" +with open(tmp, "w") as f: + json.dump(rec, f, indent=2); f.write("\n") +os.chmod(tmp, 0o600) +os.replace(tmp, os.environ["OUT"]) +PY + chown "$AGENT_USER:$AGENT_USER" "$AGENT_STATE_DIR/internal-place.json" + log_success " internal place: ${out%% *}, ${gib} GiB (the agent mounts it at /mnt/belso)" + ;; + skipped\ *) log_info " no internal place: ${out#skipped }" ;; + *) log_warn " unexpected helper output: $out" ;; + esac + _state_mark internal_place +} + #------------------------------------------------------------------------------- # STEP 4 — host enroll (option C; single secret, no global key) #------------------------------------------------------------------------------- @@ -3541,6 +3612,7 @@ else log_skip "break-glass (step 4b) SKIPPED (byo: the host owner manages break-glass; root@pam untouched, nothing vaulted)" fi should_skip agent_install || step_agent_install +should_skip internal_place || step_internal_place should_skip agent_config || step_agent_config should_skip golden || step_golden if $SKIP_PROVISION; then diff --git a/scripts/test_hostinstall.py b/scripts/test_hostinstall.py index 739e05d7..18426e7d 100644 --- a/scripts/test_hostinstall.py +++ b/scripts/test_hostinstall.py @@ -14,6 +14,7 @@ right place) — a helper defined and never called is the seam-built-never-wired Rows: R-275, R-276, R-881 (uninstall residue); R-306 (--preflight-only writes no state); R-130 (lvm minimum wording); R-180 (archive storage outside the ACL set); R-179 (NAS units); R-310 (golden refusal wording, the uninstall's terminal); R-274 (local golden reuse). Run: python3 scripts/test_hostinstall.py """ +import json import os import re import shutil @@ -35,6 +36,7 @@ BUNDLE_DESTS = [ "/usr/local/sbin/felhom-pbs-apply", "/usr/local/sbin/felhom-backup-target-apply", "/usr/local/sbin/felhom-vzdump-partial-clean", # agent v0.156.1 (R-1006) + "/usr/local/sbin/felhom-internal-place", # agent v0.158.0 (felhom.eu `16` §5) "/usr/local/sbin/felhom-os-apply", "/usr/local/sbin/felhom-crash-guard", "/usr/local/sbin/felhom-priv-apply", @@ -606,6 +608,96 @@ def test_byo_disclosure_names_the_golden_reuse(): assert re.search(r"reuse:.*golden.*vouched", body, re.S), "the BYO disclosure does not say a local golden is reused (R-274)" + +# ── felhom.eu architecture/16 §5, §7 (installer 1.33.0): the data volume by the ruled table; the internal place ─── +def test_datavol_follows_the_ruled_table(): + sb = Sandbox() + try: + cases = [(53, 50), (113, 50), (299, 50), (300, 100), (349, 100), (799, 100), (800, 130), (1720, 130)] + script = func("felhom_datavol_gib") + "".join('echo "%d $(felhom_datavol_gib %d)"\n' % (p, p) for p, _ in cases) + rc, out = sb.run(script) + assert rc == 0, out + got = dict(tuple(map(int, l.split())) for l in out.split("\n") if l.strip()) + assert got == dict(cases), got + finally: + sb.close() + + +def test_step_grows_sizes_the_data_volume_from_the_pool(): + sb = Sandbox() + try: + sb.stub("lvs", 'echo " 349.00"') + rc, out = sb.run(prelude() + func("felhom_datavol_gib") + "_state_mark() { :; }\n" + func("step_grows") + + 'ROOTFS_GROW=; DATAVOL_GROW=; SYSDATA_GROW=\nstep_grows\necho "GROW=$DATAVOL_GROW"\n') + assert rc == 0, out + assert "GROW=76" in out, out # 100 GiB data volume = the golden's 24 + 76 + finally: + sb.close() + + +def _internal_place(mode="appliance", helper_out=None, helper_rc=0, guests="", have_helper=True, dry=False): + """Run step_internal_place with the helper replaced by a stub; returns (rc, out, record or None, helper calls).""" + sb = Sandbox() + try: + helper = sb.path("felhom-internal-place") + if have_helper: + with open(helper, "w") as f: + f.write('#!/bin/sh\necho "helper $*" >> "%s"\n%s\nexit %d\n' % (sb.calls, 'echo "%s"' % helper_out if helper_out else "", helper_rc)) + os.chmod(helper, 0o755) + sb.stub("install"); sb.stub("chown") + body = func("step_internal_place").replace("/usr/local/sbin/felhom-internal-place", helper) + assert helper in body, "the step no longer names the helper's path" + script = (prelude(dry) + "_state_mark() { echo \"MARK $1\"; }\nfelhom_guests() { echo '%s'; }\n" % guests + body + + 'MODE=%s; AGENT_USER=felhom-agent; AGENT_STATE_DIR="$SB"; ROOTFS_GROW=0; DATAVOL_GROW=76; SYSDATA_GROW=0\n' + 'step_internal_place\necho "RC=$?"\n' % mode) + rc, out = sb.run(script) + rec_path = sb.path("internal-place.json") + rec = json.load(open(rec_path)) if os.path.exists(rec_path) else None + calls = [l for l in sb.logged().splitlines() if l.startswith("helper ")] + return rc, out, rec, calls + finally: + sb.close() + + +def test_internal_place_created_and_recorded(): + rc, out, rec, calls = _internal_place(helper_out="created uuid=0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c gib=182") + assert rc == 0 and "RC=0" in out, out + assert calls == ["helper create 132"], calls # 32 rootfs + 24 + 76: the guest's thin space still to come + assert rec and rec["fs_uuid"] == "0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c" and rec["gib"] == 182 and rec["made_by"] == "installer", rec + assert "MARK internal_place" in out, out + + +def test_internal_place_never_fatal_and_writes_nothing_when_not_made(): + for kw in ({"helper_out": "skipped only 7 GiB free for it (pool 53, reserve 16, promised 0, planned 82; needs 20)"}, + {"helper_out": "felhom-internal-place: REFUSED: something", "helper_rc": 1}, + {"have_helper": False}, + {"mode": "byo", "helper_out": "created uuid=0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c gib=182"}): + rc, out, rec, calls = _internal_place(**kw) + assert rc == 0 and "RC=0" in out, (kw, out) + assert rec is None, (kw, rec) + if kw.get("mode") == "byo": + assert calls == [], ("byo: the owner's pool is never touched", calls) + + +def test_internal_place_counts_an_existing_guest_once(): + rc, out, rec, calls = _internal_place(helper_out="existing uuid=0b6e4c5a-1d2e-4f3a-9b8c-7d6e5f4a3b2c gib=182", guests="9201") + assert calls == ["helper create 0"], calls + assert rec and rec["gib"] == 182, rec + + +def test_internal_place_dry_run_creates_nothing(): + rc, out, rec, calls = _internal_place(dry=True, helper_out="plan pool=349 reserve=34 promised=0 planned=132 internal=183 exists=false") + assert rec is None and all(not c.startswith("helper create") for c in calls), (calls, out) + + +def test_internal_place_runs_after_the_bundle_and_before_provision(): + main = SRC[SRC.index("step_token\nstep_grows\n"):] + call = "should_skip internal_place || step_internal_place" + assert call in main, "the internal place step is not called from the main flow" + i_bundle, i_place, i_prov = main.find("step_agent_install"), main.find(call), main.find("step_provision") + assert -1 < i_bundle < i_place < i_prov, (i_bundle, i_place, i_prov) + + def main(): tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)] fails = 0