R-840: the config bundle — a signed agent_config_update brings a box's root-owned files (sudoers, wrappers, units)
gates / gates (push) Successful in 18s
gates / gates (push) Successful in 18s
felhom-os-apply gains mode 'bundle' (signed, verified by the wrapper itself against the root-owned signers file — or, when that file is missing, only the installer's pinned key, which it then creates) and --install-bundle (the installer's root entry). BUNDLE_FILES is the one table of paths; every check (visudo, sh/bash -n, python, unit sections, RuntimeDirectory guard, nft -c, the route itself) runs before the first write; a failed write or self-check puts every previous copy back. The trust root is never a bundle path (R17). scripts/build-config-bundle.py builds it reproducibly; release-agent.sh publishes it beside the binary. The agent reports the bundle record in system.config_bundle. felhom-opsign signs agent_config_update. 43 wrapper tests (22 mutants red), Go executor tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""build-config-bundle.py — build the agent's CONFIG BUNDLE (R-840, `11` §5.4.2).
|
||||
|
||||
Usage: python3 scripts/build-config-bundle.py <agent-version> <out.json> (prints the bundle's sha256)
|
||||
|
||||
The bundle is every root-owned file the installer's step 5 writes for the agent (sudoers, wrappers, units), as ONE
|
||||
JSON file published beside the binary (felhom-agent/<version>/felhom-config-bundle.json). A box takes it by a signed
|
||||
`agent_config_update` job; a new box takes the SAME file from the installer. The list of files is NOT kept here: it is
|
||||
`BUNDLE_FILES` in configs/felhom-os-apply, the root wrapper that installs it — one table, so the builder cannot put in a
|
||||
path the wrapper would refuse, nor leave out one it expects.
|
||||
|
||||
Reproducible by construction: no timestamps, sorted keys, the table's order. The same source at the same version gives
|
||||
the same sha256 every time (pinned by configs/test_felhom_config_bundle.py).
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
REPO = pathlib.Path(__file__).resolve().parent.parent
|
||||
CONFIGS = REPO / "configs"
|
||||
|
||||
|
||||
def load_wrapper(configs=CONFIGS):
|
||||
loader = importlib.machinery.SourceFileLoader("osapply_for_bundle", str(configs / "felhom-os-apply"))
|
||||
spec = importlib.util.spec_from_loader("osapply_for_bundle", loader)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def build(version, configs=CONFIGS):
|
||||
if not re.match(r"^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$", version):
|
||||
raise SystemExit(f"build-config-bundle: version {version!r} is not semver")
|
||||
w = load_wrapper(configs)
|
||||
files = []
|
||||
for dest, src, mode, check, policy in w.BUNDLE_FILES:
|
||||
data = (configs / src).read_bytes()
|
||||
files.append({"path": dest, "source": f"configs/{src}", "mode": oct(mode), "check": check, "policy": policy,
|
||||
"sha256": hashlib.sha256(data).hexdigest(), "content_b64": base64.b64encode(data).decode()})
|
||||
body = {"format": w.BUNDLE_FORMAT, "agent_version": version, "files": files}
|
||||
return (json.dumps(body, indent=1, sort_keys=True) + "\n").encode()
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 3:
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
data = build(argv[1])
|
||||
pathlib.Path(argv[2]).write_bytes(data)
|
||||
print(hashlib.sha256(data).hexdigest())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -100,6 +100,15 @@ built_ver="$("$BIN" --version 2>/dev/null | awk '{print $2}')"
|
||||
BUILT_SHA="$(sha256sum "$BIN" | awk '{print $1}')"
|
||||
log "built ok: sha256 $BUILT_SHA"
|
||||
|
||||
# ── 3b. The config bundle (R-840) ───────────────────────────────────────────────────────────────
|
||||
# Every root-owned file the installer's step 5 writes (sudoers, wrappers, units), as ONE file beside the binary. A box
|
||||
# takes it by a signed agent_config_update; a new box from the installer. Reproducible: same source → same sha.
|
||||
BUNDLE="$(mktemp -t felhom-config-bundle-XXXXXX)"
|
||||
trap 'rm -f "$BIN" "$BUNDLE"' EXIT
|
||||
BUNDLE_SHA="$(python3 "$REPO_ROOT/scripts/build-config-bundle.py" "$VERSION" "$BUNDLE")" || die "config bundle build failed"
|
||||
[[ "$BUNDLE_SHA" =~ ^[0-9a-f]{64}$ ]] || die "config bundle build printed no sha256"
|
||||
log "config bundle built: sha256 $BUNDLE_SHA"
|
||||
|
||||
# ── 4. Tag LOCALLY (the push comes after the publish — see step 6) ──────────────────────────────
|
||||
#
|
||||
# THE ORDER CHANGED, AND ONLY THE PUSH MOVED (R-188, 2026-08-03).
|
||||
@@ -153,6 +162,12 @@ if ! bash "$REPO_ROOT/scripts/publish-agent.sh" "$VERSION" "$BIN"; then
|
||||
die "publish failed"
|
||||
fi
|
||||
|
||||
# ── 5b. Publish the config bundle beside the binary (same package version, same credentials) ──
|
||||
BURL="$GITEA_BASE/api/packages/$GITEA_OWNER/generic/felhom-agent/$VERSION/felhom-config-bundle.json"
|
||||
bcode="$(curl -sS -o /dev/null -w '%{http_code}' -u "${GITEA_USER}:${GITEA_TOKEN}" -X PUT --upload-file "$BUNDLE" "$BURL")"
|
||||
[[ "$bcode" == "201" || "$bcode" == "200" ]] || die "config bundle upload failed: HTTP $bcode (the binary IS published; re-run only the bundle upload)"
|
||||
log "config bundle published (HTTP $bcode)"
|
||||
|
||||
# ── 6. Push the tag, now that the package exists ────────────────────────────────────────────────
|
||||
# This is the step that makes the release VISIBLE — to CI, and to every `raw/tag/v<version>/` fetch
|
||||
# the installer makes. It runs last of the two so CI can never see a tag whose package is not there.
|
||||
@@ -193,6 +208,11 @@ DL_SHA="$(sha256sum "$DL" | awk '{print $1}')"
|
||||
[[ "$DL_SHA" == "$BUILT_SHA" ]] \
|
||||
|| die "published sha $DL_SHA != built sha $BUILT_SHA — the artifact is not what was built"
|
||||
|
||||
BDL="$(mktemp -t felhom-config-bundle-dl-XXXXXX)"
|
||||
trap 'rm -f "$BIN" "$DL" "$BUNDLE" "$BDL"' EXIT
|
||||
curl -fsS -o "$BDL" "$BURL" || die "round-trip GET of the config bundle failed"
|
||||
[[ "$(sha256sum "$BDL" | awk '{print $1}')" == "$BUNDLE_SHA" ]] || die "published config bundle sha != built sha"
|
||||
|
||||
# The tag must also serve the configs the installer will fetch from it.
|
||||
cfg_code="$(curl -fsS -o /dev/null -w '%{http_code}' \
|
||||
"$GITEA_BASE/$GITEA_OWNER/felhom-agent/raw/tag/$TAG/configs/felhom-agent.service" 2>/dev/null || true)"
|
||||
@@ -206,6 +226,7 @@ cat <<EOF
|
||||
version : $VERSION
|
||||
tag : $TAG
|
||||
sha256 : $BUILT_SHA
|
||||
bundle : $BUNDLE_SHA (felhom-config-bundle.json — vouch it with the agent)
|
||||
|
||||
NOT VOUCHED. Vouching is what points machines at this version and stays your deliberate act:
|
||||
hub operator UI → Configs → Day-0 artifacts. Until then boxes keep installing the previous one.
|
||||
|
||||
Reference in New Issue
Block a user