da20722e76
gates / gates (push) Successful in 27s
INTERIM CHECKPOINT — evidence off the machine at the end of the phase that produced it (R-320), not at the end of the session. Phases 2-5 follow in a later commit. Phase 0, all three mechanisms proven with their controls: - the fleet floor to 0.261.0 with its declared MinAgent — both demo boxes in 13 s, the hub logging `managed floor SERVED ... from declared (golden 0.258.0)`. - a PRIVATE DRILL CATALOG (admin/app-catalog-drill), so that broken, dummy, cross-repo and engine-major edges can be measured without the live catalog ever carrying one. Positive control quoted, and two negative controls: the live catalog's main and both real boxes' caches unchanged. - a throwaway image store on the scratch guest, which is what makes an UNATTENDED HOLD measurable at all: an edge that PASSES the within-a-major test and still fails. CompareImageRefs was proven to order host:port/ references by RUNNING it (4 positive cases + 1 negative control), not by reading it. Phase 1: real within-a-major upstream edges walked on guest 9202 through the product's own guarded Update, each app seeded and read back through its OWN front door (R-156), with a per-edge verdict record in 09's shape. `inconclusive` is never collapsed into `failed`. TWO INSTRUMENT FIXES, both in this repo's own evidence code: - 00-api-recipe.md said the app page is /app/<n>; it is /apps/<n>, and every call it described 404s. Corrected, with the session-expiry note that cost the same time. - unattended-caller.py's follow() read update_phase/updating off the API ENVELOPE, so both were always None and EVERY followed update ran to its 900 s timeout and was then recorded `timeout` and never-press-again. Fixed before B1 relied on it. R-623. No controller, agent or hub code was written. The live catalog carries no broken reference. Gates: repo_gates.py --fast — all 15 OK, exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
187 lines
9.5 KiB
Python
187 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Phase 5 — teardown, three layers, stated. Plus Gitea.
|
|
|
|
R-320: evidence is copied off at the end of the phase that produced it, before any revert. By the
|
|
time this runs every phase's evidence is already in this directory; this file only tears down, and
|
|
it PROVES each layer rather than asserting it.
|
|
|
|
MACHINE (9202) every throwaway app removed THROUGH THE PRODUCT with its data; the image store
|
|
container and volume gone; drill images removed BY NAME (never `prune`);
|
|
controller.yaml restored from the saved copy; the controller restarted; and
|
|
`git.repo_url` READ BACK AND QUOTED as the LIVE catalog, with one sync + rescan
|
|
showing only the protected infra containers and no badge.
|
|
HOST (demo-hp) `pct list` and `pvesm status` before and after; guest 9201 untouched.
|
|
HUB nothing provisioned; the floor's state stated.
|
|
GITEA the drill repo KEPT, private, RESET to the live catalog's main; and the live
|
|
catalog's own main hash plus a diff of every `image:` line — expected: identical.
|
|
"""
|
|
import json, os, subprocess, sys, time
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
import walk as w # noqa: E402
|
|
|
|
OUT = os.path.join(HERE, "teardown")
|
|
KEEP = {"traefik", "filebrowser", "felhom-controller"} # the protected infra containers
|
|
LIVE_REPO = "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git"
|
|
LIVE_DIR = "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu"
|
|
DRILL = w.DRILL
|
|
SC = w.SC
|
|
|
|
|
|
def sec(name, text):
|
|
open(f"{OUT}/{name}", "w").write(text)
|
|
w.say(f" -> {name}")
|
|
return text
|
|
|
|
|
|
def main():
|
|
os.makedirs(OUT, exist_ok=True)
|
|
w.login()
|
|
w.say("==== Phase 5: teardown, three layers")
|
|
rec = {}
|
|
|
|
# ---------------------------------------------------------------- BEFORE, host layer
|
|
host_before = w.sh(["ssh", "-o", "ConnectTimeout=20", w.HP,
|
|
"pct list; echo '--- pvesm'; pvesm status"], timeout=180).stdout or ""
|
|
sec("00-host-before.txt", host_before)
|
|
rec["host_before"] = host_before
|
|
|
|
# ---------------------------------------------------------------- MACHINE: the apps
|
|
_, d = w.ctl("GET", "/api/stacks")
|
|
deployed = [s["name"] for s in (d.get("data") or []) if s.get("deployed")]
|
|
w.say(f" [M] throwaway apps still deployed: {deployed}")
|
|
removed = {}
|
|
for n in deployed:
|
|
if n in KEEP:
|
|
continue
|
|
removed[n] = w.remove(n)
|
|
rec["apps_removed"] = removed
|
|
|
|
# ---------------------------------------------------------------- MACHINE: the image store
|
|
store = w.guest("""
|
|
echo "=== registry container + volume, removed BY NAME (never a prune)"
|
|
docker rm -f drill-registry 2>&1 | head -2
|
|
docker volume rm drill-registry-data 2>&1 | head -2
|
|
echo "=== drill images, removed BY NAME"
|
|
for i in $(docker images --format '{{.Repository}}:{{.Tag}}' | grep '^localhost:5000/drill/'); do
|
|
echo -n "$i -> "; docker rmi "$i" >/dev/null 2>&1 && echo removed || echo "in use / already gone"
|
|
done
|
|
docker rmi registry:2 >/dev/null 2>&1 && echo "registry:2 removed" || echo "registry:2 kept/in use"
|
|
echo "=== anything left that says drill?"
|
|
docker images --format '{{.Repository}}:{{.Tag}}' | grep -i drill || echo "(none)"
|
|
docker ps -a --format '{{.Names}}' | grep -i drill || echo "(no drill containers)"
|
|
echo "=== NOTHING WAS PRUNED — this is the full image list, for the record"
|
|
docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' | sort | head -40
|
|
""", timeout=900)
|
|
sec("01-image-store-removed.txt", store)
|
|
|
|
# ---------------------------------------------------------------- MACHINE: the config back
|
|
V = "/var/lib/docker/volumes/felhom-controller-data/_data"
|
|
back = w.guest(f"""
|
|
echo "=== restoring controller.yaml from the pre-update-night copy"
|
|
ls -la {V}/controller.yaml {V}/controller.yaml.pre-update-night
|
|
cp {V}/controller.yaml.pre-update-night {V}/controller.yaml && echo "restored"
|
|
echo "=== the git section, read back (token redacted by this script, not by the box)"
|
|
sed -n '/^git:/,/^hub:/p' {V}/controller.yaml | sed 's/token: ".*"/token: "<redacted>"/'
|
|
echo "=== removing the drill catalog cache so the next sync clones the LIVE repo (R-615)"
|
|
rm -rf {V}/data/catalog-cache
|
|
systemctl restart felhom-controller-bootstrap.service
|
|
sleep 25
|
|
docker ps --filter name=felhom-controller --format '{{{{.Image}}}} {{{{.Status}}}}'
|
|
echo "=== the cache's origin, READ BACK — this is the quote the brief asks for"
|
|
git -C {V}/data/catalog-cache remote -v 2>/dev/null | sed 's#://[^@]*@#://#'
|
|
git -C {V}/data/catalog-cache log --oneline -1 2>/dev/null
|
|
""", timeout=900)
|
|
sec("02-config-restored.txt", back)
|
|
rec["repo_url_read_back"] = LIVE_REPO in back
|
|
w.say(f" [M] git.repo_url reads back as the LIVE catalog: {rec['repo_url_read_back']}")
|
|
|
|
# ---------------------------------------------------------------- MACHINE: sync, rescan, badges
|
|
w.login()
|
|
w.ctl("POST", "/api/sync")
|
|
time.sleep(3)
|
|
w.ctl("POST", "/api/stacks/rescan")
|
|
time.sleep(3)
|
|
_, d = w.ctl("GET", "/api/stacks")
|
|
left = []
|
|
for s in (d.get("data") or []):
|
|
if not s.get("deployed") and s.get("state") == "not_deployed":
|
|
continue
|
|
b = w.badges(s["name"])
|
|
left.append({"name": s["name"], "state": s.get("state"), "deployed": s.get("deployed"),
|
|
"badge_hu": [x["text"] for x in b["hu"]]})
|
|
rec["still_on_the_box"] = left
|
|
for x in left:
|
|
w.say(f" [M] {x['name']:20} deployed={x['deployed']} state={x['state']} badge={x['badge_hu']}")
|
|
containers = w.guest("docker ps --format '{{.Names}}\t{{.Image}}\t{{.Status}}'")
|
|
sec("03-containers-after.txt", containers)
|
|
names = {l.split("\t")[0] for l in containers.strip().split("\n") if l.strip()}
|
|
rec["only_protected_infra_left"] = names <= KEEP
|
|
w.say(f" [M] containers left: {sorted(names)} only protected infra: {rec['only_protected_infra_left']}")
|
|
|
|
# ---------------------------------------------------------------- HOST
|
|
host_after = w.sh(["ssh", "-o", "ConnectTimeout=20", w.HP,
|
|
"pct list; echo '--- pvesm'; pvesm status; echo '--- 9201 untouched:'; "
|
|
"pct exec 9201 -- docker ps --format '{{.Names}}' | sort | head -20"],
|
|
timeout=180).stdout or ""
|
|
sec("04-host-after.txt", host_after)
|
|
rec["host_after"] = host_after
|
|
w.say(" [H] no harness LXC was created tonight, so none was destroyed — "
|
|
"the PostgreSQL rehearsal ran on 9202 itself (stated in the audit)")
|
|
|
|
# ---------------------------------------------------------------- HUB
|
|
hp = open(f"{SC}/.hubpw").read().strip()
|
|
conf = w.sh(["curl", "-s", "-u", f":{hp}", "http://10.43.52.34:8080/configuration"],
|
|
timeout=90).stdout or ""
|
|
import re
|
|
floor = re.search(r'min_controller_version" value="([^"]*)"', conf)
|
|
magent = re.search(r'name="min_agent" value="([^"]*)"', conf)
|
|
hosts = w.sh(["curl", "-s", "-u", f":{hp}", "http://10.43.52.34:8080/hosts"],
|
|
timeout=90).stdout or ""
|
|
nhosts = hosts.count("ONLINE") + hosts.count("DOWN")
|
|
hubtxt = (f"floor = {floor.group(1) if floor else '?'}\n"
|
|
f"min_agent = {magent.group(1) if magent else '?'}\n"
|
|
f"host rows seen = {nhosts}\n"
|
|
"nothing was provisioned at the hub tonight: no customer, no config, no appliance,\n"
|
|
"no binding. The only hub act of the whole night was the floor save in Phase 0.1.\n")
|
|
sec("05-hub.txt", hubtxt)
|
|
rec["hub"] = hubtxt
|
|
w.say(" [U] " + hubtxt.replace("\n", " | "))
|
|
|
|
# ---------------------------------------------------------------- GITEA
|
|
w.sh(["git", "-C", LIVE_DIR, "fetch", "-q", "origin"], timeout=180)
|
|
live_main = (w.sh(["git", "-C", LIVE_DIR, "rev-parse", "--short=12", "origin/main"]).stdout or "").strip()
|
|
w.sh(["git", "-C", DRILL, "fetch", "-q", "origin"], timeout=180)
|
|
w.sh(["git", "-C", DRILL, "remote", "remove", "live"], timeout=60)
|
|
w.sh(["git", "-C", DRILL, "remote", "add", "live", LIVE_REPO], timeout=60)
|
|
w.sh(["git", "-C", DRILL, "fetch", "-q", "live", "main"], timeout=300)
|
|
w.sh(["git", "-C", DRILL, "reset", "--hard", "live/main"], timeout=180)
|
|
push = w.sh(["git", "-C", DRILL, "push", "--force", "origin", "main"], timeout=300)
|
|
drill_main = (w.sh(["git", "-C", DRILL, "rev-parse", "--short=12", "HEAD"]).stdout or "").strip()
|
|
|
|
# the diff that matters: every image: line, live vs drill
|
|
diff = w.sh(["bash", "-lc",
|
|
f"diff <(grep -rhoE '^[[:space:]]+image: .*' {LIVE_DIR}/templates/*/docker-compose.yml | sort) "
|
|
f"<(grep -rhoE '^[[:space:]]+image: .*' {DRILL}/templates/*/docker-compose.yml | sort) "
|
|
f"&& echo 'IDENTICAL — every image: line matches the live catalog'"],
|
|
timeout=180)
|
|
gitea = (f"live catalog origin/main : {live_main}\n"
|
|
f"drill repo HEAD after reset: {drill_main}\n"
|
|
f"reset+force-push rc={push.returncode}\n\n"
|
|
f"diff of every `image:` line, live vs drill:\n{diff.stdout}{diff.stderr}\n")
|
|
sec("06-gitea.txt", gitea)
|
|
rec["live_main"] = live_main
|
|
rec["drill_main"] = drill_main
|
|
rec["image_lines_identical"] = "IDENTICAL" in diff.stdout
|
|
w.say(f" [G] live main={live_main} drill main={drill_main} "
|
|
f"image lines identical: {rec['image_lines_identical']}")
|
|
|
|
json.dump(rec, open(f"{OUT}/result.json", "w"), indent=2, ensure_ascii=False)
|
|
open(f"{OUT}/log.txt", "w").write("\n".join(w.LOG) + "\n")
|
|
w.say(f" teardown written -> {OUT}/result.json")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|