Audit: what a Felhom box needs — floor, 64 apps' memory and CPU, power (N100 bench); R-993..R-995; R-231 due check read green
Measured on demo-felhom (N100, 16 GB): 61 apps deployed and removed through the product, sampled every 5 s; heavy apps with one real action (Immich 200 photos, Jellyfin transcode, Paperless OCR, Nextcloud 500 MB, ...). sizing.json for the estimator (tools/build_sizing.py), SIZING.md with the proposed minimum machine, METHOD.md. Power is RAPL CPU-package only (no meter). R-993 asks the operator to approve the minimum machine; R-994 lists catalog limits/requests that are too low; R-995 records leftover unnamed volumes after removal. R-231's dated check measured: dooplex-backup.service success, journal green.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
#!/bin/bash
|
||||
# actions.sh APP — one real action per heavy app, run INSIDE the guest, through the app's own front
|
||||
# door (its container address on the Docker network; the setup gate sits in traefik, in front of it).
|
||||
# Content is in /root/sizing/content (made by make_content.py: generated or CC0, no personal data).
|
||||
# PW (env) is a password generated for this run; it is never printed and never stored.
|
||||
# Every line it prints is evidence; it prints no secret.
|
||||
set -u
|
||||
APP=${1:?app}; C=/root/sizing/content; PW=${PW:?pw}
|
||||
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' "$1" | awk '{print $1}'; }
|
||||
src() { docker inspect -f "{{range .Mounts}}{{if eq .Destination \"$2\"}}{{.Source}}{{end}}{{end}}" "$1"; }
|
||||
waitup() { for i in $(seq 1 120); do c=$(curl -s -o /dev/null -w '%{http_code}' "$1"); case $c in 200|302|401) echo "up after $((i*5))s ($c)"; return 0;; esac; sleep 5; done; echo "never up ($c)"; return 1; }
|
||||
ts() { echo "@$(date +%s) $*"; }
|
||||
|
||||
case $APP in
|
||||
immich)
|
||||
U=http://$(ip immich-server):2283; waitup $U/api/server/ping
|
||||
curl -s -o /dev/null -w 'admin-sign-up %{http_code}\n' -X POST $U/api/auth/admin-sign-up -H 'Content-Type: application/json' \
|
||||
-d "{\"email\":\"bench@example.invalid\",\"password\":\"$PW\",\"name\":\"Bench\"}"
|
||||
T=$(curl -s -X POST $U/api/auth/login -H 'Content-Type: application/json' -d "{\"email\":\"bench@example.invalid\",\"password\":\"$PW\"}" | grep -o '"accessToken":"[^"]*"' | cut -d'"' -f4)
|
||||
echo "token length ${#T}"
|
||||
ts action_start upload
|
||||
ok=0; bad=0
|
||||
for f in $C/photos/*.jpg; do
|
||||
c=$(curl -s -o /tmp/immich-up.json -w '%{http_code}' -H "Authorization: Bearer $T" -F "deviceAssetId=$(basename $f)" -F deviceId=bench \
|
||||
-F fileCreatedAt=2026-01-01T00:00:00.000Z -F fileModifiedAt=2026-01-01T00:00:00.000Z -F "assetData=@$f" $U/api/assets)
|
||||
case $c in 200|201) ok=$((ok+1));; *) bad=$((bad+1)); [ $bad -le 3 ] && echo "upload $(basename $f) -> $c $(head -c 200 /tmp/immich-up.json)";; esac
|
||||
done
|
||||
ts upload_done ok=$ok bad=$bad
|
||||
# wait until every job queue is empty (thumbnails, metadata, smart search, faces)
|
||||
for i in $(seq 1 720); do
|
||||
J=$(curl -s -H "Authorization: Bearer $T" $U/api/jobs); [ -z "$J" ] || echo "$J" | grep -q '"statusCode":404' && J=$(curl -s -H "Authorization: Bearer $T" $U/api/queues)
|
||||
n=$(echo "$J" | grep -oE '"(active|waiting|delayed)":[0-9]+' | awk -F: '{s+=$2} END{print s+0}')
|
||||
[ $((i % 12)) -eq 1 ] && echo " t=$((i*5))s jobs pending=$n"
|
||||
if [ "$n" = 0 ] && [ $i -gt 6 ]; then break; fi
|
||||
sleep 5
|
||||
done
|
||||
ts action_end jobs-idle
|
||||
curl -s -H "Authorization: Bearer $T" $U/api/server/statistics | head -c 300; echo
|
||||
curl -s -H "Authorization: Bearer $T" "$U/api/people" | grep -oE '"total":[0-9]+' | head -1
|
||||
;;
|
||||
jellyfin)
|
||||
U=http://$(ip jellyfin):8096; waitup $U/health
|
||||
M=$(src jellyfin /media); echo "media source: $M"; mkdir -p "$M/movies"; cp -r "$C/video/Bench Films/." "$M/movies/"; ls "$M/movies"
|
||||
H='Content-Type: application/json'
|
||||
curl -s -o /dev/null -w 'startup-config %{http_code}\n' -X POST $U/Startup/Configuration -H "$H" -d '{"UICulture":"en-US","MetadataCountryCode":"US","PreferredMetadataLanguage":"en"}'
|
||||
curl -s -o /dev/null $U/Startup/User
|
||||
curl -s -o /dev/null -w 'startup-user %{http_code}\n' -X POST $U/Startup/User -H "$H" -d "{\"Name\":\"bench\",\"Password\":\"$PW\"}"
|
||||
curl -s -o /dev/null -w 'startup-complete %{http_code}\n' -X POST $U/Startup/Complete
|
||||
A='Authorization: MediaBrowser Client="bench", Device="bench", DeviceId="sizing-bench", Version="1.0"'
|
||||
T=$(curl -s -X POST $U/Users/AuthenticateByName -H "$H" -H "$A" -d "{\"Username\":\"bench\",\"Pw\":\"$PW\"}" | grep -o '"AccessToken":"[^"]*"' | cut -d'"' -f4)
|
||||
echo "token length ${#T}"
|
||||
ts action_start scan
|
||||
curl -s -o /dev/null -w 'add-library %{http_code}\n' -X POST "$U/Library/VirtualFolders?name=Films&collectionType=movies&refreshLibrary=true" \
|
||||
-H "$H" -H "X-Emby-Token: $T" -d '{"LibraryOptions":{"PathInfos":[{"Path":"/media/movies"}]}}'
|
||||
for i in $(seq 1 120); do
|
||||
n=$(curl -s -H "X-Emby-Token: $T" "$U/Items?Recursive=true&IncludeItemTypes=Movie" | grep -oE '"TotalRecordCount":[0-9]+' | cut -d: -f2)
|
||||
run=$(curl -s -H "X-Emby-Token: $T" "$U/ScheduledTasks?IsHidden=false" | grep -o '"State":"Running"' | wc -l)
|
||||
[ "${n:-0}" -ge 3 ] && [ "$run" = 0 ] && break; sleep 5
|
||||
done
|
||||
ts scan_done movies=${n:-0}
|
||||
ID=$(curl -s -H "X-Emby-Token: $T" "$U/Items?Recursive=true&IncludeItemTypes=Movie&Limit=1" | grep -oE '"Id":"[0-9a-f]{32}"' | head -1 | cut -d'"' -f4)
|
||||
ts transcode_start
|
||||
curl -s -o /tmp/jf.mp4 -w 'transcode %{http_code} %{size_download} bytes %{time_total}s\n' \
|
||||
"$U/Videos/$ID/stream.mp4?static=false&mediaSourceId=$ID&videoCodec=h264&audioCodec=aac&maxWidth=1920&videoBitRate=3000000&audioBitRate=128000&api_key=$T"
|
||||
rm -f /tmp/jf.mp4
|
||||
ts action_end transcode_done
|
||||
;;
|
||||
paperless-ngx)
|
||||
U=http://$(ip paperless-webserver):8000; waitup $U/api/
|
||||
for i in $(seq 1 60); do c=$(curl -s -o /dev/null -w '%{http_code}' -u "admin:$PW" $U/api/documents/); [ "$c" = 200 ] && break; sleep 5; done; echo "api auth $c"
|
||||
ts action_start ocr
|
||||
for f in $C/pdfs/*.pdf; do curl -s -o /dev/null -w "%{http_code} " -u "admin:$PW" -F "document=@$f" $U/api/documents/post_document/; done; echo
|
||||
for i in $(seq 1 360); do
|
||||
n=$(curl -s -u "admin:$PW" "$U/api/documents/?page_size=1" | grep -oE '"count":[0-9]+' | cut -d: -f2)
|
||||
p=$(curl -s -u "admin:$PW" "$U/api/tasks/" | grep -oE '"status":"(PENDING|STARTED)"' | wc -l)
|
||||
[ $((i % 12)) -eq 1 ] && echo " t=$((i*5))s documents=${n:-0} pending=$p"
|
||||
[ "${n:-0}" -ge 20 ] && [ "$p" = 0 ] && break; sleep 5
|
||||
done
|
||||
ts action_end documents=${n:-0}
|
||||
;;
|
||||
nextcloud)
|
||||
NIP=$(ip nextcloud); TD=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' nextcloud | sed -n 's/^NEXTCLOUD_TRUSTED_DOMAINS=//p' | awk '{print $1}')
|
||||
echo "trusted domain: $TD"
|
||||
for i in $(seq 1 120); do s=$(curl -s -H "Host: $TD" http://$NIP/status.php); echo "$s" | grep -q '"installed":true' && break; sleep 5; done
|
||||
echo "status: $(echo $s | head -c 160)"
|
||||
D="http://$NIP/remote.php/dav/files/admin"
|
||||
ts action_start upload
|
||||
curl -s -o /dev/null -w 'mkcol %{http_code}\n' -X MKCOL -H "Host: $TD" -u "admin:$PW" $D/bench
|
||||
ok=0; for f in $C/upload/*.bin; do c=$(curl -s -o /dev/null -w '%{http_code}' -H "Host: $TD" -u "admin:$PW" -T "$f" "$D/bench/$(basename $f)"); case $c in 201|204) ok=$((ok+1));; *) echo "put $(basename $f) -> $c";; esac; done
|
||||
ts action_end uploaded=$ok
|
||||
;;
|
||||
home-assistant)
|
||||
U=http://$(ip home-assistant):8123; waitup $U/
|
||||
ts action_start onboarding
|
||||
curl -s -o /dev/null -w 'onboarding-user %{http_code}\n' -X POST $U/api/onboarding/users -H 'Content-Type: application/json' \
|
||||
-d "{\"client_id\":\"$U/\",\"name\":\"Bench\",\"username\":\"bench\",\"password\":\"$PW\",\"language\":\"en\"}"
|
||||
ts action_end onboarding
|
||||
;;
|
||||
calibre-web)
|
||||
I=$(src calibre-web /cwa-book-ingest); L=$(src calibre-web /calibre-library); echo "ingest: $I library: $L"
|
||||
waitup http://$(ip calibre-web):8083/
|
||||
ts action_start ingest
|
||||
cp $C/books/*.epub "$I/"
|
||||
for i in $(seq 1 240); do n=$(ls "$I" 2>/dev/null | grep -c epub); [ "$n" = 0 ] && break; sleep 5; done
|
||||
sleep 20
|
||||
ts action_end ingest-folder-left=$n library-epubs=$(find "$L" -name '*.epub' 2>/dev/null | wc -l)
|
||||
;;
|
||||
romm)
|
||||
R=$(src romm /romm/library); echo "library: $R"; waitup http://$(ip romm):8080/
|
||||
ts action_start copy-library
|
||||
mkdir -p "$R/roms"; cp -r $C/roms/. "$R/roms/"; find "$R" -type f | wc -l
|
||||
ts action_end copied-only-scan-not-triggered
|
||||
;;
|
||||
*) echo "no action for $APP";;
|
||||
esac
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
"""analyze.py — turn one bench run's evidence into per-app numbers.
|
||||
|
||||
analyze.py evidence/<TAG> [--all-window]
|
||||
|
||||
Reads guest.tsv (5 s cgroup samples), containers.tsv (container -> compose project), events.json,
|
||||
memory-peak.tsv and host.tsv. Writes <TAG>/summary.json and prints a table.
|
||||
|
||||
Definitions (stated once, used everywhere — METHOD.md repeats them):
|
||||
- used = memory.current - inactive_file (what `docker stats` shows; what the fleet telemetry carries)
|
||||
- anon = anonymous memory (the app's own heap; never reclaimable without swap)
|
||||
- an app's value at a sample = the SUM over its containers at that timestamp
|
||||
- rest window = events rest_start..rest_end (the last 15 min, nothing else being deployed)
|
||||
- work window = the app's deploy_start .. rest_end (first start, any action, rest)
|
||||
- CPU = d(usage_usec)/dt per container, summed per app, as % of the WHOLE N100 (4 cores = 100 %)
|
||||
- cpu_peak_1m = the highest 60 s moving average in the work window
|
||||
- host power = d(RAPL package energy)/dt — CPU package only, not wall power
|
||||
"""
|
||||
import json, os, sys, statistics as st, collections
|
||||
|
||||
NCPU = 4
|
||||
|
||||
|
||||
def load_tsv(p):
|
||||
rows = []
|
||||
with open(p) as f:
|
||||
hdr = f.readline().rstrip("\n").split("\t")
|
||||
for line in f:
|
||||
parts = line.rstrip("\n").split("\t")
|
||||
if len(parts) == len(hdr):
|
||||
rows.append(dict(zip(hdr, parts)))
|
||||
return rows
|
||||
|
||||
|
||||
def pct(xs, q):
|
||||
xs = sorted(xs)
|
||||
return xs[min(len(xs) - 1, int(q * len(xs)))] if xs else 0
|
||||
|
||||
|
||||
def movavg_max(series, width):
|
||||
"""series: sorted [(ts, value)]; highest mean over any window of `width` seconds."""
|
||||
best, j, s = 0.0, 0, 0.0
|
||||
for i in range(len(series)):
|
||||
s += series[i][1]
|
||||
while series[i][0] - series[j][0] >= width:
|
||||
s -= series[j][1]
|
||||
j += 1
|
||||
n = i - j + 1
|
||||
if series[i][0] - series[0][0] >= width - 5 and n:
|
||||
best = max(best, s / n)
|
||||
if not best and series:
|
||||
best = sum(v for _, v in series) / len(series)
|
||||
return best
|
||||
|
||||
|
||||
def analyze(d):
|
||||
g = load_tsv(os.path.join(d, "guest.tsv"))
|
||||
proj = {}
|
||||
cp = os.path.join(d, "containers.tsv")
|
||||
if os.path.exists(cp):
|
||||
for line in open(cp):
|
||||
p = line.rstrip("\n").split("\t")
|
||||
if len(p) >= 2:
|
||||
proj[p[0]] = p[1] or p[0]
|
||||
ev = json.load(open(os.path.join(d, "events.json"))) if os.path.exists(os.path.join(d, "events.json")) else []
|
||||
t_rest0 = next((e["ts"] for e in ev if e["kind"] == "rest_start"), None)
|
||||
t_rest1 = next((e["ts"] for e in ev if e["kind"] == "rest_end"), None)
|
||||
starts = {e["app"]: e["ts"] for e in ev if e["kind"] == "deploy_start"}
|
||||
|
||||
# per container time series
|
||||
mem = collections.defaultdict(dict) # app -> ts -> [used, anon]
|
||||
cpu = collections.defaultdict(lambda: collections.defaultdict(float)) # app -> ts -> cores
|
||||
last = {}
|
||||
cpk = collections.defaultdict(lambda: [0, 0]) # container -> [peak used, peak anon]
|
||||
for r in g:
|
||||
ts = int(r["ts"]); name = r["name"]; app = proj.get(name, name)
|
||||
used = int(r["mem_current"]) - int(r["inactive_file"] or 0)
|
||||
anon = int(r["anon"] or 0)
|
||||
m = mem[app].setdefault(ts, [0, 0]); m[0] += used; m[1] += anon
|
||||
cpk[name][0] = max(cpk[name][0], used); cpk[name][1] = max(cpk[name][1], anon)
|
||||
u = int(r["cpu_usage_usec"] or 0)
|
||||
if name in last and ts > last[name][0] and u >= last[name][1]:
|
||||
cpu[app][ts] += (u - last[name][1]) / 1e6 / (ts - last[name][0])
|
||||
last[name] = (ts, u)
|
||||
|
||||
peaks = {}
|
||||
climit = {}
|
||||
pp = os.path.join(d, "memory-peak.tsv")
|
||||
if os.path.exists(pp):
|
||||
for r in load_tsv(pp):
|
||||
mx = r.get("memory_max", "")
|
||||
climit[r["name"]] = int(mx) if mx.isdigit() else None
|
||||
app = proj.get(r["name"], r["name"])
|
||||
try:
|
||||
peaks.setdefault(app, [0, 0])
|
||||
peaks[app][0] += int(r["memory_peak_bytes"] or 0)
|
||||
peaks[app][1] += int(r["oom_kill"] or 0)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Fallback for runs before memory_max was recorded: the applied compose, image -> next memory line
|
||||
al = os.path.join(d, "applied-limits.txt")
|
||||
if os.path.exists(al):
|
||||
img_lim, cur = {}, None
|
||||
for line in open(al):
|
||||
if "image:" in line:
|
||||
cur = line.split("image:", 1)[1].strip().split("@")[0]
|
||||
elif cur and ("memory:" in line or "mem_limit:" in line):
|
||||
v = line.split(":", 2)[-1].strip().strip('"')
|
||||
unit = 1024 ** 3 if v[-1:] in "Gg" else 1024 ** 2
|
||||
try:
|
||||
img_lim[cur] = int(float(v[:-1]) * unit)
|
||||
except ValueError:
|
||||
pass
|
||||
cur = None
|
||||
for line in open(cp) if os.path.exists(cp) else []:
|
||||
p = line.rstrip("\n").split("\t")
|
||||
if len(p) >= 3 and not climit.get(p[0]) and p[2] in img_lim:
|
||||
climit[p[0]] = img_lim[p[2]]
|
||||
|
||||
MB = 1024 * 1024
|
||||
out = {}
|
||||
for app, series in mem.items():
|
||||
t0 = starts.get(app, min(series))
|
||||
work = [(t, v) for t, v in sorted(series.items()) if t >= t0 and (t_rest1 is None or t <= t_rest1)]
|
||||
rest = [(t, v) for t, v in work if t_rest0 and t >= t_rest0]
|
||||
cw = sorted((t, c) for t, c in cpu[app].items() if t >= t0 and (t_rest1 is None or t <= t_rest1))
|
||||
cr = [c for t, c in cw if t_rest0 and t >= t_rest0]
|
||||
if not work:
|
||||
continue
|
||||
o = dict(
|
||||
samples=len(work),
|
||||
rest_samples=len(rest),
|
||||
mem_rest_avg_mb=round(st.mean(v[0] for _, v in rest) / MB) if rest else None,
|
||||
mem_rest_p95_mb=round(pct([v[0] for _, v in rest], .95) / MB) if rest else None,
|
||||
mem_peak_used_mb=round(max(v[0] for _, v in work) / MB),
|
||||
mem_peak_anon_mb=round(max(v[1] for _, v in work) / MB),
|
||||
mem_rest_anon_avg_mb=round(st.mean(v[1] for _, v in rest) / MB) if rest else None,
|
||||
kernel_memory_peak_mb=round(peaks[app][0] / MB) if app in peaks else None,
|
||||
oom_kills=peaks[app][1] if app in peaks else None,
|
||||
cpu_rest_avg_pct=round(100 * st.mean(cr) / NCPU, 2) if cr else None,
|
||||
cpu_peak_1m_pct=round(100 * movavg_max(cw, 60) / NCPU, 1) if cw else None,
|
||||
cpu_peak_5s_pct=round(100 * max((c for _, c in cw), default=0) / NCPU, 1),
|
||||
work_seconds=work[-1][0] - work[0][0],
|
||||
containers={n: dict(peak_used_mb=round(v[0] / MB), peak_anon_mb=round(v[1] / MB),
|
||||
limit_mb=round(climit[n] / MB) if climit.get(n) else None)
|
||||
for n, v in cpk.items() if proj.get(n, n) == app},
|
||||
)
|
||||
out[app] = o
|
||||
|
||||
# host side
|
||||
h = load_tsv(os.path.join(d, "host.tsv")) if os.path.exists(os.path.join(d, "host.tsv")) else []
|
||||
hs = {}
|
||||
if len(h) > 2:
|
||||
def watts(rows):
|
||||
w = []
|
||||
for a, b in zip(rows, rows[1:]):
|
||||
dt = int(b["ts"]) - int(a["ts"]); de = int(b["rapl_pkg_uj"]) - int(a["rapl_pkg_uj"])
|
||||
if dt > 0 and de >= 0:
|
||||
w.append(de / 1e6 / dt)
|
||||
return w
|
||||
rest_h = [r for r in h if t_rest0 and int(r["ts"]) >= t_rest0 and (t_rest1 is None or int(r["ts"]) <= t_rest1)] or h
|
||||
w_all, w_rest = watts(h), watts(rest_h)
|
||||
hs = dict(
|
||||
host_mem_used_rest_avg_mb=round(st.mean((int(r["mem_total_kb"]) - int(r["mem_available_kb"])) / 1024 for r in rest_h)),
|
||||
host_mem_used_peak_mb=round(max((int(r["mem_total_kb"]) - int(r["mem_available_kb"])) / 1024 for r in h)),
|
||||
guest_mem_current_rest_avg_mb=round(st.mean(int(r["guest_mem_current"]) for r in rest_h) / MB),
|
||||
guest_anon_rest_avg_mb=round(st.mean(int(r["guest_anon"]) for r in rest_h) / MB),
|
||||
rapl_pkg_w_rest_avg=round(st.mean(w_rest), 2) if w_rest else None,
|
||||
rapl_pkg_w_peak_5s=round(max(w_all), 2) if w_all else None,
|
||||
rapl_pkg_w_peak_1m=round(movavg_max([(int(b["ts"]), w) for b, w in zip(h[1:], w_all)], 60), 2) if w_all else None,
|
||||
pkg_temp_c_max=round(max(int(r["pkg_temp_mC"]) for r in h) / 1000, 1),
|
||||
)
|
||||
res = dict(apps=out, host=hs)
|
||||
json.dump(res, open(os.path.join(d, "summary.json"), "w"), indent=1)
|
||||
return res
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
res = analyze(sys.argv[1])
|
||||
print(f"{'app':22} {'rest':>6} {'p95':>6} {'peak':>6} {'anonpk':>6} {'kpeak':>6} {'cpuR%':>6} {'cpu1m%':>7} {'oom':>4}")
|
||||
for a, o in sorted(res["apps"].items()):
|
||||
print(f"{a:22} {str(o['mem_rest_avg_mb']):>6} {str(o['mem_rest_p95_mb']):>6} {o['mem_peak_used_mb']:>6} "
|
||||
f"{o['mem_peak_anon_mb']:>6} {str(o['kernel_memory_peak_mb']):>6} {str(o['cpu_rest_avg_pct']):>6} "
|
||||
f"{str(o['cpu_peak_1m_pct']):>7} {str(o['oom_kills']):>4}")
|
||||
print(json.dumps(res["host"]))
|
||||
@@ -0,0 +1,326 @@
|
||||
#!/usr/bin/env python3
|
||||
"""bench.py — sizing bench on the N100 demo box (demo-felhom, guest 9201), through the product.
|
||||
|
||||
EVIDENCE, NOT PRODUCT. It presses the buttons a household presses (the endpoints the UI calls):
|
||||
GET /api/stacks/<n>/deploy-fields · POST /api/stacks/<n>/deploy
|
||||
POST /api/stacks/<n>/stop · POST /api/stacks/<n>/remove · GET /api/stacks/<n>
|
||||
Adapted from `audits/pg-last-six-2026-09-30/tools/walk.py` (same helpers, other box).
|
||||
|
||||
Usage:
|
||||
bench.py batch TAG app [app ...] up + 15 min at rest + down (the light batches, unattended)
|
||||
bench.py up TAG app [app ...] start sampling, deploy the apps one after another
|
||||
bench.py mark TAG KIND [app] record an event (action_start / action_end ...)
|
||||
bench.py down TAG app [app ...] 15 min at rest, stop sampling, copy evidence, remove
|
||||
bench.py remove app [app ...] remove through the product (tidy-up after an interrupted run)
|
||||
|
||||
While a batch runs, `guest_sampler.sh` (in the guest) and `host_sampler.sh` (on the host) record
|
||||
every 5 s; their TSVs, the event log and the container->app map are copied to `evidence/<TAG>/`
|
||||
BEFORE anything is removed (R-320).
|
||||
|
||||
The data location: this box has no registered drive, and the deploy page then says
|
||||
„Nincs regisztrált adattároló — add meg kézzel az útvonalat" — so, like a household, we type one:
|
||||
BENCH below, a folder under /mnt on the guest system disk (the controller sees only /mnt). It is deleted at the end of each batch.
|
||||
|
||||
Secrets: deploy passwords are generated per run, kept in this process (and, for the action
|
||||
scripts, in a 0600 file in the scratchpad), never written to evidence.
|
||||
"""
|
||||
import json, os, re, secrets, subprocess, sys, time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
AUDIT = os.path.dirname(HERE)
|
||||
SC = os.environ.get("SC", "/tmp/claude-1000/-mnt-5-hdd-felhom-eu-git/73737b90-b02c-42f4-bb5f-3c922f8f6af0/scratchpad")
|
||||
HOST = "felhom-pve"
|
||||
GUEST = "9201"
|
||||
BASE = "https://192.168.0.149"
|
||||
DOMAIN = "demo-felhom.eu"
|
||||
HOSTHDR = f"Host: felhom.{DOMAIN}"
|
||||
BENCH = "/mnt/sizing-bench" # the controller container sees only /mnt (guest system disk)
|
||||
REST_S = int(os.environ.get("REST_S", "900")) # 15 min at rest after the last app is up
|
||||
|
||||
LOG = []
|
||||
EVENTS = []
|
||||
GENERATED = {} # app -> {ENV: value}; never written to evidence
|
||||
|
||||
|
||||
def say(*a):
|
||||
line = " ".join(str(x) for x in a)
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"{ts} {line}", flush=True)
|
||||
LOG.append(f"{ts} {line}")
|
||||
|
||||
|
||||
def event(kind, app="", **kw):
|
||||
EVENTS.append(dict(ts=int(time.time()), kind=kind, app=app, **kw))
|
||||
|
||||
|
||||
def sh(args, timeout=300, inp=None):
|
||||
try:
|
||||
return subprocess.run(args, capture_output=True, text=True, timeout=timeout, input=inp)
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
return subprocess.CompletedProcess(args, 124, "", f"{e}")
|
||||
|
||||
|
||||
def host(script, timeout=600):
|
||||
r = sh(["ssh", "-o", "ConnectTimeout=20", HOST, "export LC_ALL=C; bash -s"], timeout=timeout, inp=script)
|
||||
return r.stdout or ""
|
||||
|
||||
|
||||
def guest(script, timeout=600):
|
||||
"""Run a bash script inside the guest. One temp file per call (guest-helper-shares-one-tmp-file)."""
|
||||
t = f"/tmp/sz{GUEST}-{os.getpid()}-{secrets.token_hex(4)}.sh"
|
||||
r = sh(["ssh", "-o", "ConnectTimeout=20", HOST,
|
||||
f"export LC_ALL=C; cat > {t}; pct push {GUEST} {t} {t} >/dev/null 2>&1; "
|
||||
f"pct exec {GUEST} -- bash {t}; pct exec {GUEST} -- rm -f {t}; rm -f {t}"],
|
||||
timeout=timeout, inp=script)
|
||||
return r.stdout or ""
|
||||
|
||||
|
||||
def login():
|
||||
p = f"{SC}/.ctlpw"
|
||||
hdr = f"{SC}/hdr{os.getpid()}.txt"
|
||||
sh(["curl", "-sk", "-D", hdr, "-o", "/dev/null", "-H", HOSTHDR,
|
||||
"-X", "POST", "--data-urlencode", f"password@{p}", f"{BASE}/login"])
|
||||
h = open(hdr).read()
|
||||
os.remove(hdr)
|
||||
m = re.search(r"felhom_session=[A-Za-z0-9._-]+", h, re.I)
|
||||
if not m:
|
||||
sys.exit("login failed: no session cookie")
|
||||
r = sh(["curl", "-sk", "-L", "-H", HOSTHDR, "-H", f"Cookie: {m.group(0)}", f"{BASE}/"])
|
||||
c = re.search(r'<meta name="csrf-token" content="([^"]+)"', r.stdout or "")
|
||||
if not c or len(c.group(1)) != 64:
|
||||
sys.exit("login failed: no csrf token")
|
||||
return m.group(0), c.group(1)
|
||||
|
||||
|
||||
SESS = CSRF = None
|
||||
|
||||
|
||||
def ctl(method, path, data=None, tries=2):
|
||||
"""One controller API call; re-logs in once on 302/401 (the session store is in memory)."""
|
||||
global SESS, CSRF
|
||||
if SESS is None:
|
||||
SESS, CSRF = login()
|
||||
code, body = "000", ""
|
||||
for attempt in range(tries):
|
||||
args = ["curl", "-sk", "-H", HOSTHDR, "-H", f"Cookie: {SESS}", "-w", "\n%{http_code}"]
|
||||
if method != "GET":
|
||||
args += ["-H", f"X-CSRF-Token: {CSRF}", "-H", "Content-Type: application/json", "-X", method]
|
||||
if data is not None:
|
||||
args += ["--data", json.dumps(data)]
|
||||
args.append(f"{BASE}{path}")
|
||||
r = sh(args)
|
||||
body, _, code = (r.stdout or "").rpartition("\n")
|
||||
code = code.strip()
|
||||
if code in ("302", "401") and attempt + 1 < tries:
|
||||
SESS, CSRF = login()
|
||||
continue
|
||||
break
|
||||
try:
|
||||
return code, json.loads(body)
|
||||
except Exception:
|
||||
return code, {"_raw": body[:600]}
|
||||
|
||||
|
||||
def stack(name):
|
||||
_, d = ctl("GET", f"/api/stacks/{name}")
|
||||
return (d.get("data") or {}) if isinstance(d, dict) else {}
|
||||
|
||||
|
||||
def deploy_values(name):
|
||||
"""Fill every required deploy field as the wizard would (walk.py's rules)."""
|
||||
code, d = ctl("GET", f"/api/stacks/{name}/deploy-fields")
|
||||
fields = (((d.get("data") or {}).get("metadata") or {}).get("deploy_fields")) or []
|
||||
values = {"DOMAIN": DOMAIN}
|
||||
for f in fields:
|
||||
ev, ty = f.get("env_var"), f.get("type")
|
||||
if ev == "SUBDOMAIN":
|
||||
values[ev] = f.get("default") or name
|
||||
continue
|
||||
if ev in values:
|
||||
continue
|
||||
# `type: password` is mandatory whatever `required` says (deploy.go refuses an empty one).
|
||||
if ty == "password" or (ty == "secret" and ev in ACTION_SECRETS.get(name, ())):
|
||||
values[ev] = "Sz-" + secrets.token_hex(12)
|
||||
GENERATED.setdefault(name, {})[ev] = values[ev]
|
||||
continue
|
||||
if not f.get("required"):
|
||||
continue
|
||||
if ty == "path":
|
||||
values[ev] = BENCH
|
||||
elif ty == "secret":
|
||||
values[ev] = "Sz-" + secrets.token_hex(12)
|
||||
GENERATED.setdefault(name, {})[ev] = values[ev]
|
||||
elif f.get("default"):
|
||||
values[ev] = f["default"]
|
||||
else:
|
||||
values[ev] = f"sz-{name}"
|
||||
return values
|
||||
|
||||
|
||||
# Optional secrets the action scripts need to know (the app's own admin login).
|
||||
ACTION_SECRETS = {
|
||||
"paperless-ngx": ("PAPERLESS_ADMIN_PASSWORD",),
|
||||
"nextcloud": ("NEXTCLOUD_ADMIN_PASSWORD",),
|
||||
}
|
||||
|
||||
|
||||
def deploy(name, extra=None):
|
||||
st = stack(name)
|
||||
if st.get("deployed"):
|
||||
say(f" {name}: already deployed — NOT touching it (someone else's app)")
|
||||
return "already"
|
||||
guest(f"mkdir -p {BENCH}")
|
||||
values = deploy_values(name)
|
||||
if extra:
|
||||
values.update(extra)
|
||||
event("deploy_start", name)
|
||||
code, d = ctl("POST", f"/api/stacks/{name}/deploy", {"values": values})
|
||||
say(f" {name}: deploy -> {code} {str(d)[:140]}")
|
||||
if code != "202":
|
||||
event("deploy_refused", name, code=code, body=str(d)[:300])
|
||||
return "refused"
|
||||
seen = None
|
||||
for _ in range(180): # up to 15 min (image pulls on a home line)
|
||||
time.sleep(5)
|
||||
st = stack(name)
|
||||
seen = st.get("state")
|
||||
pins = (st.get("app_config") or {}).get("pinned_images")
|
||||
if st.get("deployed") and pins and seen in ("running", "unhealthy", "degraded"):
|
||||
event("deploy_done", name, state=seen)
|
||||
say(f" {name}: deployed, state={seen}")
|
||||
return "ok"
|
||||
if st.get("deploy_error") or seen in ("error", "failed"):
|
||||
break
|
||||
event("deploy_failed", name, state=seen, error=str(st.get("deploy_error"))[:300])
|
||||
say(f" {name}: never became deployed (state={seen!r}, error={str(st.get('deploy_error'))[:200]})")
|
||||
return "failed"
|
||||
|
||||
|
||||
def remove(name):
|
||||
"""Remove through the PRODUCT (live-probes rule): stop, then remove with data and backups."""
|
||||
c1, _ = ctl("POST", f"/api/stacks/{name}/stop")
|
||||
for _ in range(24):
|
||||
time.sleep(5)
|
||||
if stack(name).get("state") != "running":
|
||||
break
|
||||
code, d = ctl("POST", f"/api/stacks/{name}/remove", {"remove_hdd_data": True, "remove_backups": True})
|
||||
if code == "409":
|
||||
# R-442 fail-closed: the typed-in bench path is not a registered drive, so the product will
|
||||
# not delete data it cannot resolve. Remove the app, keep the data; the bench folder is
|
||||
# deleted by name below.
|
||||
code, d = ctl("POST", f"/api/stacks/{name}/remove", {"remove_hdd_data": False, "remove_backups": True})
|
||||
time.sleep(5)
|
||||
st = stack(name)
|
||||
left = guest(f"ls -d /opt/docker/stacks/{name}/app.yaml 2>/dev/null; "
|
||||
f"docker ps -a --filter label=com.docker.compose.project={name} --format '{{{{.Names}}}}'; "
|
||||
f"docker volume ls -q --filter label=com.docker.compose.project={name}")
|
||||
ok = (not st.get("deployed")) and not left.strip()
|
||||
say(f" {name}: stop {c1}, remove {code}, deployed={st.get('deployed')}, leftovers={left.strip()!r}")
|
||||
event("removed", name, code=code, clean=ok, leftovers=left.strip()[:300])
|
||||
return ok
|
||||
|
||||
|
||||
def samplers(tag, start):
|
||||
if start:
|
||||
host(f"rm -f /root/sizing/stop-{tag}; nohup /root/sizing/host_sampler.sh {tag} >/dev/null 2>&1 &")
|
||||
guest(f"rm -f /root/sizing/stop-{tag}; nohup /root/sizing/guest_sampler.sh {tag} >/dev/null 2>&1 &")
|
||||
else:
|
||||
host(f"touch /root/sizing/stop-{tag}")
|
||||
guest(f"touch /root/sizing/stop-{tag}")
|
||||
time.sleep(7)
|
||||
|
||||
|
||||
def pull(tag, names):
|
||||
"""Copy the evidence to DooPlex (R-320) — called BEFORE any removal."""
|
||||
d = os.path.join(AUDIT, "evidence", tag)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
m = guest("docker ps -a --format '{{.Names}}\t{{.Label \"com.docker.compose.project\"}}\t{{.Image}}'")
|
||||
open(os.path.join(d, "containers.tsv"), "w").write(m)
|
||||
# the kernel's own high-water mark per container (memory.peak counts page cache too — the
|
||||
# sampled anon/used figures are the primary ones; this catches spikes shorter than 5 s)
|
||||
pk = guest("docker ps --no-trunc --format '{{.ID}} {{.Names}}' | while read id n; do "
|
||||
"c=/sys/fs/cgroup/system.slice/docker-$id.scope; "
|
||||
"echo -e \"$n\\t$(cat $c/memory.peak 2>/dev/null)\\t$(awk '$1==\"oom_kill\"{print $2}' $c/memory.events 2>/dev/null)\\t$(cat $c/memory.max 2>/dev/null)\"; done")
|
||||
open(os.path.join(d, "memory-peak.tsv"), "w").write("name\tmemory_peak_bytes\toom_kill\tmemory_max\n" + pk)
|
||||
# per-app compose limits as applied (no env values)
|
||||
lim = guest("for n in " + " ".join(names) + "; do f=/opt/docker/stacks/$n/applied-compose.yml; "
|
||||
"[ -f $f ] && echo \"== $n\" && grep -nE 'mem_limit|memory:|cpus:|image:' $f; done")
|
||||
open(os.path.join(d, "applied-limits.txt"), "w").write(lim)
|
||||
for side, cmd in (("guest", f"pct exec {GUEST} -- cat /root/sizing/guest-{tag}.tsv"),
|
||||
("host", f"cat /root/sizing/host-{tag}.tsv")):
|
||||
r = sh(["ssh", HOST, f"export LC_ALL=C; {cmd}"], timeout=600)
|
||||
open(os.path.join(d, f"{side}.tsv"), "w").write(r.stdout or "")
|
||||
json.dump(EVENTS, open(os.path.join(d, "events.json"), "w"), indent=1)
|
||||
open(os.path.join(d, "run.log"), "w").write("\n".join(LOG) + "\n")
|
||||
say(f" evidence copied to {d}")
|
||||
|
||||
|
||||
def _evfile(tag):
|
||||
d = os.path.join(AUDIT, "evidence", tag); os.makedirs(d, exist_ok=True)
|
||||
return os.path.join(d, "events.json")
|
||||
|
||||
|
||||
def _load(tag):
|
||||
global EVENTS
|
||||
f = _evfile(tag)
|
||||
EVENTS = json.load(open(f)) if os.path.exists(f) else []
|
||||
|
||||
|
||||
def _save(tag):
|
||||
json.dump(EVENTS, open(_evfile(tag), "w"), indent=1)
|
||||
|
||||
|
||||
def up(tag, names):
|
||||
"""Start sampling and deploy; leaves the apps running for an action."""
|
||||
_load(tag)
|
||||
samplers(tag, True)
|
||||
event("batch_start", tag=tag)
|
||||
res = {n: deploy(n) for n in names}
|
||||
event("all_up", results=res)
|
||||
_save(tag)
|
||||
if GENERATED: # the action scripts need the app's own admin login; scratchpad only, 0600
|
||||
f = os.path.join(SC, f".generated-{tag}.json")
|
||||
fd = os.open(f, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
os.write(fd, json.dumps(GENERATED).encode()); os.close(fd)
|
||||
open(os.path.join(AUDIT, "evidence", tag, "up.log"), "a").write("\n".join(LOG) + "\n")
|
||||
return res
|
||||
|
||||
|
||||
def mark(tag, kind, app="", **kw):
|
||||
_load(tag); event(kind, app, **kw); _save(tag)
|
||||
|
||||
|
||||
def down(tag, names, rest=True):
|
||||
"""Hold the rest window, stop sampling, copy evidence, remove through the product."""
|
||||
_load(tag)
|
||||
if rest:
|
||||
say(f" resting {REST_S}s"); event("rest_start"); _save(tag)
|
||||
time.sleep(REST_S)
|
||||
event("rest_end"); _save(tag)
|
||||
samplers(tag, False)
|
||||
pull(tag, names)
|
||||
up_res = next((e.get("results") for e in EVENTS if e["kind"] == "all_up"), {}) or {}
|
||||
for n in names:
|
||||
if up_res.get(n) in ("ok", "failed"):
|
||||
remove(n)
|
||||
guest(f"rm -rf {BENCH}; ls -d {BENCH} 2>/dev/null || echo bench-folder-gone")
|
||||
_save(tag)
|
||||
open(os.path.join(AUDIT, "evidence", tag, "run.log"), "a").write("\n".join(LOG) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
if cmd == "batch" and len(sys.argv) >= 4:
|
||||
up(sys.argv[2], sys.argv[3:]); down(sys.argv[2], sys.argv[3:])
|
||||
elif cmd == "up" and len(sys.argv) >= 4:
|
||||
print(json.dumps(up(sys.argv[2], sys.argv[3:])))
|
||||
elif cmd == "down" and len(sys.argv) >= 4:
|
||||
down(sys.argv[2], sys.argv[3:])
|
||||
elif cmd == "mark" and len(sys.argv) >= 4:
|
||||
mark(sys.argv[2], sys.argv[3], sys.argv[4] if len(sys.argv) > 4 else "")
|
||||
elif cmd == "remove" and len(sys.argv) >= 3:
|
||||
for n in sys.argv[2:]:
|
||||
remove(n)
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""build_sizing.py — build ../sizing.json (the estimator's data file) and ../evidence/apps-table.json.
|
||||
|
||||
Rebuild when the catalog changes: python3 -I tools/build_sizing.py [--catalog PATH]
|
||||
|
||||
Sources, per app, in the spec's order of preference (sizing-2026-10-13 TASK §7 B):
|
||||
1 telemetry — the hub's App Telemetry, >= 7 days of 15-min reports on one box
|
||||
(evidence/telemetry-30d.json, read from a hub DB copy 2026-10-13)
|
||||
2 onboarding — a measured peak recorded by an earlier onboarding/bench session
|
||||
(evidence/onboarding-peaks.json, collected 2026-10-13; per run, containers summed)
|
||||
3 measured — this run's bench on the N100 (evidence/<batch>/summary.json)
|
||||
4 unmeasured — the catalog's own mem_request / mem_limit
|
||||
Rules: telemetry under 7 days counts only when nothing better exists (`short-telemetry`). When two
|
||||
sources' peaks differ by more than 50 %, both are reported and the HIGHER is used for mem_peak_mb.
|
||||
mem_typical_mb is the chosen source's typical figure. CPU always comes from the N100 bench when it
|
||||
exists (the spec's unit: % of the whole N100), else from telemetry (docker % / 4, approximate).
|
||||
"""
|
||||
import glob, json, os, re, sys
|
||||
from datetime import date
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
AUDIT = os.path.dirname(HERE)
|
||||
EV = os.path.join(AUDIT, "evidence")
|
||||
CATALOG = "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/templates"
|
||||
if "--catalog" in sys.argv:
|
||||
CATALOG = sys.argv[sys.argv.index("--catalog") + 1]
|
||||
|
||||
# CPU classes — stated once here and in METHOD.md
|
||||
# light rest < 1 % of the whole N100 and the busiest minute < 15 %
|
||||
# heavy-in-bursts the busiest minute >= 50 % (two of the four cores fully busy for a minute)
|
||||
# medium everything between
|
||||
def cpu_class(rest, peak1m):
|
||||
if rest is None and peak1m is None:
|
||||
return None
|
||||
rest, peak1m = rest or 0, peak1m or 0
|
||||
if peak1m >= 50:
|
||||
return "heavy-in-bursts"
|
||||
if rest < 1 and peak1m < 15:
|
||||
return "light"
|
||||
return "medium"
|
||||
|
||||
|
||||
def mb(s):
|
||||
if not s:
|
||||
return None
|
||||
m = re.match(r"\s*([\d.]+)\s*([GgMm])", str(s))
|
||||
if not m:
|
||||
return None
|
||||
v = float(m.group(1))
|
||||
return round(v * 1024 if m.group(2) in "Gg" else v)
|
||||
|
||||
|
||||
def read_yaml_scalar(text, key):
|
||||
m = re.search(rf"^\s*{key}:\s*\"?([^\"#\n]+?)\"?\s*(#.*)?$", text, re.M)
|
||||
return m.group(1).strip() if m else None
|
||||
|
||||
|
||||
def catalog():
|
||||
apps = {}
|
||||
for f in sorted(glob.glob(os.path.join(CATALOG, "*", ".felhom.yml"))):
|
||||
slug = os.path.basename(os.path.dirname(f))
|
||||
t = open(f, encoding="utf-8").read()
|
||||
apps[slug] = dict(
|
||||
display_name=read_yaml_scalar(t, "display_name") or read_yaml_scalar(t, "name") or slug,
|
||||
mem_request=read_yaml_scalar(t, "mem_request"),
|
||||
mem_limit=read_yaml_scalar(t, "mem_limit"),
|
||||
lifecycle=read_yaml_scalar(t, "lifecycle") or "active",
|
||||
)
|
||||
return apps
|
||||
|
||||
|
||||
def telemetry():
|
||||
p = os.path.join(EV, "telemetry-30d.json")
|
||||
out = {}
|
||||
for r in json.load(open(p)) if os.path.exists(p) else []:
|
||||
if r["app"] == "felhom-controller":
|
||||
continue
|
||||
cur = out.get(r["app"])
|
||||
# the longest series wins (most days); ties -> more reports
|
||||
if cur is None or (r["days"], r["reports"]) > (cur["days"], cur["reports"]):
|
||||
out[r["app"]] = r
|
||||
return out
|
||||
|
||||
|
||||
def onboarding():
|
||||
p = os.path.join(EV, "onboarding-peaks.json")
|
||||
out = {}
|
||||
for a in json.load(open(p)) if os.path.exists(p) else []:
|
||||
runs = {}
|
||||
for m in a.get("measured", []):
|
||||
if m.get("what") != "peak" or m.get("basis") != "anon":
|
||||
continue
|
||||
run = m["cite"].split(":")[0]
|
||||
runs.setdefault(run, {"mb": 0.0, "date": m.get("date"), "cite": m["cite"]})
|
||||
runs[run]["mb"] += float(m["mb"])
|
||||
typ = [m for m in a.get("measured", []) if m.get("what") == "typical"]
|
||||
if runs:
|
||||
best = max(runs.values(), key=lambda r: r["mb"])
|
||||
out[a["slug"]] = dict(peak_mb=round(best["mb"]), date=best["date"], cite=best["cite"],
|
||||
typical_mb=round(sum(float(m["mb"]) for m in typ)) if typ else None)
|
||||
return out
|
||||
|
||||
|
||||
def bench():
|
||||
out = {}
|
||||
for f in sorted(glob.glob(os.path.join(EV, "*", "summary.json"))):
|
||||
tag = os.path.basename(os.path.dirname(f))
|
||||
if tag == "floor":
|
||||
continue
|
||||
s = json.load(open(f))
|
||||
evs = json.load(open(os.path.join(os.path.dirname(f), "events.json")))
|
||||
deployed = {e["app"] for e in evs if e["kind"] == "deploy_done"}
|
||||
day = date.fromtimestamp(min(e["ts"] for e in evs)).isoformat() if evs else None
|
||||
for app, o in s["apps"].items():
|
||||
if app in deployed and o.get("rest_samples"):
|
||||
o = dict(o, batch=tag, date=day)
|
||||
if app not in out or o["mem_peak_used_mb"] > out[app]["mem_peak_used_mb"]:
|
||||
out[app] = o
|
||||
return out
|
||||
|
||||
|
||||
def floor():
|
||||
p = os.path.join(EV, "floor", "summary.json")
|
||||
if not os.path.exists(p):
|
||||
return None
|
||||
s = json.load(open(p))
|
||||
plat = {k: v for k, v in s["apps"].items() if k in ("felhom-controller", "traefik", "cloudflared", "filebrowser")}
|
||||
return dict(
|
||||
host_mem_used_mb=s["host"]["host_mem_used_rest_avg_mb"],
|
||||
guest_mem_anon_mb=s["host"]["guest_anon_rest_avg_mb"],
|
||||
platform_containers_mb={k: v["mem_rest_avg_mb"] for k, v in plat.items()},
|
||||
platform_containers_total_mb=sum(v["mem_rest_avg_mb"] for v in plat.values()),
|
||||
note="host_mem_used_mb is MemTotal-MemAvailable on the N100 with only the platform running "
|
||||
"(Proxmox, the agent, the guest and its four built-in containers); opengist, the one app "
|
||||
"deployed there, is excluded from the platform figures and is ~23 MB",
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
cat, tel, onb, ben = catalog(), telemetry(), onboarding(), bench()
|
||||
apps, table = [], []
|
||||
for slug, c in cat.items():
|
||||
t, o, b = tel.get(slug), onb.get(slug), ben.get(slug)
|
||||
cands = [] # (name, typical, peak, date, box)
|
||||
if t and t["days"] >= 7:
|
||||
cands.append(("telemetry", t["mem_avg"], t["mem_p95"], t["last"], t["customer"]))
|
||||
if o:
|
||||
cands.append(("onboarding", o["typical_mb"], o["peak_mb"], o["date"], None))
|
||||
if b:
|
||||
cands.append(("measured", b["mem_rest_avg_mb"], b["mem_peak_used_mb"], b["date"], "demo-felhom (N100)"))
|
||||
if t and t["days"] < 7:
|
||||
cands.append(("short-telemetry", t["mem_avg"], t["mem_p95"], t["last"], t["customer"]))
|
||||
if cands:
|
||||
src, typ, peak, when, box = cands[0]
|
||||
peaks = [x[2] for x in cands if x[2]]
|
||||
disagree = bool(peaks) and max(peaks) > 1.5 * min(peaks)
|
||||
if disagree:
|
||||
peak = max(peaks)
|
||||
if typ is None: # an onboarding peak with no typical figure: take the next source's
|
||||
typ = next((x[1] for x in cands[1:] if x[1] is not None), None)
|
||||
if typ is not None and peak is not None and peak < typ:
|
||||
peak = typ # a peak below the typical figure is two sources' noise, never a real peak
|
||||
else:
|
||||
src, typ, peak, when, box, disagree = "unmeasured", mb(c["mem_request"]), mb(c["mem_limit"]), None, None, False
|
||||
cpu_rest = b["cpu_rest_avg_pct"] if b else (round(t["cpu_avg_dockerpct"] / 4, 2) if t else None)
|
||||
cpu_pk = b["cpu_peak_1m_pct"] if b else (round(t["cpu_max_15min"] / 4, 1) if t else None)
|
||||
rec = dict(
|
||||
slug=slug, display_name=c["display_name"],
|
||||
mem_typical_mb=typ, mem_peak_mb=peak,
|
||||
cpu_class=cpu_class(cpu_rest, cpu_pk), cpu_note=CPU_NOTES.get(slug, ""),
|
||||
source=src, measured_on=box, date=when,
|
||||
)
|
||||
if c["lifecycle"] != "active":
|
||||
rec["lifecycle"] = c["lifecycle"]
|
||||
apps.append(rec)
|
||||
table.append(dict(rec, catalog_request_mb=mb(c["mem_request"]), catalog_limit_mb=mb(c["mem_limit"]),
|
||||
sources_disagree=disagree, cpu_rest_pct=cpu_rest, cpu_peak_1m_pct=cpu_pk,
|
||||
telemetry=t, onboarding=o, bench=b))
|
||||
power = json.load(open(os.path.join(EV, "power.json"))) if os.path.exists(os.path.join(EV, "power.json")) else None
|
||||
out = dict(
|
||||
schema=1, generated=date.today().isoformat(),
|
||||
units=dict(mem="MB (MiB); docker-style used = memory.current - inactive_file", cpu="% of the whole Intel N100 (4 cores = 100 %)"),
|
||||
cpu_classes=dict(light="rest < 1 % and busiest minute < 15 %", medium="between",
|
||||
**{"heavy-in-bursts": "busiest minute >= 50 % (two cores busy for a minute)"}),
|
||||
floor=floor(), power=power, apps=apps,
|
||||
)
|
||||
json.dump(out, open(os.path.join(AUDIT, "sizing.json"), "w"), indent=1, ensure_ascii=False)
|
||||
json.dump(table, open(os.path.join(EV, "apps-table.json"), "w"), indent=1, ensure_ascii=False)
|
||||
from collections import Counter
|
||||
print("apps:", len(apps), dict(Counter(a["source"] for a in apps)))
|
||||
|
||||
|
||||
# One line per app on what makes it burst — filled from the bench's action logs.
|
||||
CPU_NOTES = {}
|
||||
_notes = os.path.join(HERE, "cpu_notes.json")
|
||||
if os.path.exists(_notes):
|
||||
CPU_NOTES = json.load(open(_notes, encoding="utf-8"))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""catalog_check.py — §7 C: measured memory against each template's mem_request and mem_limit.
|
||||
|
||||
Writes ../evidence/catalog-check.json and prints two lists:
|
||||
RISK the app's peak > 80 % of its mem_limit, OR any one container's bench peak > 80 % of that
|
||||
container's own compose limit (the limit Docker enforces is per container)
|
||||
OVER the app's typical use > its mem_request (the catalog under-states what it needs)
|
||||
Read only: nothing in the catalog is changed.
|
||||
"""
|
||||
import json, os
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
EV = os.path.join(os.path.dirname(HERE), "evidence")
|
||||
t = json.load(open(os.path.join(EV, "apps-table.json")))
|
||||
risk, over = [], []
|
||||
for a in sorted(t, key=lambda a: a["slug"]):
|
||||
if a["source"] == "unmeasured":
|
||||
continue
|
||||
lim, req = a["catalog_limit_mb"], a["catalog_request_mb"]
|
||||
why = []
|
||||
if lim and a["mem_peak_mb"] and a["mem_peak_mb"] > 0.8 * lim:
|
||||
why.append(f"app peak {a['mem_peak_mb']} MB = {100 * a['mem_peak_mb'] / lim:.0f} % of mem_limit {lim} MB")
|
||||
for cname, c in ((a.get("bench") or {}).get("containers") or {}).items():
|
||||
if c.get("limit_mb") and c["peak_used_mb"] > 0.8 * c["limit_mb"]:
|
||||
why.append(f"container {cname} peak {c['peak_used_mb']} MB = {100 * c['peak_used_mb'] / c['limit_mb']:.0f} % of its {c['limit_mb']} MB limit")
|
||||
if why:
|
||||
risk.append(dict(slug=a["slug"], why=why, source=a["source"]))
|
||||
if req and a["mem_typical_mb"] and a["mem_typical_mb"] > req:
|
||||
over.append(dict(slug=a["slug"], typical_mb=a["mem_typical_mb"], mem_request_mb=req,
|
||||
ratio=round(a["mem_typical_mb"] / req, 1), source=a["source"]))
|
||||
json.dump(dict(risk=risk, over_request=over), open(os.path.join(EV, "catalog-check.json"), "w"), indent=1)
|
||||
print("RISK (peak > 80 % of a limit):", len(risk))
|
||||
for r in risk:
|
||||
print(f" {r['slug']:16} {r['source']:12} " + "; ".join(r["why"]))
|
||||
print("OVER (typical > mem_request):", len(over))
|
||||
for o in sorted(over, key=lambda o: -o["ratio"]):
|
||||
print(f" {o['slug']:16} typical {o['typical_mb']:>5} > request {o['mem_request_mb']:>5} x{o['ratio']} ({o['source']})")
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"immich": "only while it reads new photos: thumbnails, face and search indexing (200 photos took about 6 minutes)",
|
||||
"jellyfin": "only while it converts a film for a device that cannot play it (a 3-minute 1080p film took 41 seconds, CPU only)",
|
||||
"paperless-ngx": "only while it reads new scanned documents (text recognition, about 4 pages a minute)",
|
||||
"calibre-web": "only while it imports new books",
|
||||
"romm": "at first start; a library scan was not measured",
|
||||
"onlyoffice": "at first start only",
|
||||
"grimmory": "at first start only",
|
||||
"tandoor": "at first start only",
|
||||
"adventurelog": "at first start only",
|
||||
"nextcloud": "while files are uploaded (500 MB: busiest minute 13 %)",
|
||||
"dawarich": "at first start only"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# guest_sampler.sh TAG — runs INSIDE the Felhom guest. Every 5 s, one line per running container:
|
||||
# ts name mem_current anon inactive_file shmem cpu_usage_usec
|
||||
# read straight from the container's cgroup v2 files (no docker stats: cheaper, and exact).
|
||||
# Stops when /root/sizing/stop-TAG exists. Read-only: it writes only its own TSV.
|
||||
TAG=${1:?tag}; D=/root/sizing; mkdir -p "$D"; OUT="$D/guest-$TAG.tsv"; rm -f "$D/stop-$TAG"
|
||||
echo -e "ts\tname\tmem_current\tanon\tinactive_file\tshmem\tcpu_usage_usec" > "$OUT"
|
||||
declare -A NAME; last=0
|
||||
while [ ! -e "$D/stop-$TAG" ]; do
|
||||
now=$(date +%s)
|
||||
if (( now - last >= 30 )); then # refresh the id->name map every 30 s
|
||||
NAME=(); while read -r id n; do NAME[$id]=$n; done < <(docker ps --no-trunc --format '{{.ID}} {{.Names}}')
|
||||
last=$now
|
||||
fi
|
||||
for id in "${!NAME[@]}"; do
|
||||
cg=/sys/fs/cgroup/system.slice/docker-$id.scope
|
||||
[ -r "$cg/memory.current" ] || continue
|
||||
cur=$(<"$cg/memory.current")
|
||||
read -r anon inf shm < <(awk '$1=="anon"{a=$2} $1=="inactive_file"{i=$2} $1=="shmem"{s=$2} END{print a,i,s}' "$cg/memory.stat")
|
||||
cpu=$(awk '$1=="usage_usec"{print $2}' "$cg/cpu.stat")
|
||||
echo -e "$now\t${NAME[$id]}\t$cur\t$anon\t$inf\t$shm\t$cpu"
|
||||
done >> "$OUT"
|
||||
sleep 5
|
||||
done
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# host_sampler.sh TAG [VMID] — runs on the Proxmox HOST. Every 5 s:
|
||||
# ts rapl_pkg_uj mem_total_kb mem_available_kb guest_mem_current guest_anon pkg_temp_mC cpu_busy_jiffies cpu_total_jiffies
|
||||
# rapl_pkg_uj = Intel RAPL package energy counter (CPU package only — NOT wall power).
|
||||
# Stops when /root/sizing/stop-TAG exists. Read-only: it writes only its own TSV.
|
||||
TAG=${1:?tag}; VMID=${2:-9201}; D=/root/sizing; mkdir -p "$D"; OUT="$D/host-$TAG.tsv"; rm -f "$D/stop-$TAG"
|
||||
R=/sys/class/powercap/intel-rapl:0/energy_uj; CG=/sys/fs/cgroup/lxc/$VMID
|
||||
T=$(for h in /sys/class/hwmon/hwmon*; do [ "$(cat $h/name)" = coretemp ] && echo $h/temp1_input; done | head -1)
|
||||
echo -e "ts\trapl_pkg_uj\tmem_total_kb\tmem_available_kb\tguest_mem_current\tguest_anon\tpkg_temp_mC\tcpu_busy\tcpu_total" > "$OUT"
|
||||
while [ ! -e "$D/stop-$TAG" ]; do
|
||||
now=$(date +%s); e=$(cat $R 2>/dev/null || echo 0)
|
||||
mt=$(awk '/^MemTotal/{print $2}' /proc/meminfo); ma=$(awk '/^MemAvailable/{print $2}' /proc/meminfo)
|
||||
gc=$(cat $CG/memory.current 2>/dev/null || echo 0); ga=$(awk '$1=="anon"{print $2}' $CG/memory.stat 2>/dev/null)
|
||||
t=$(cat "$T" 2>/dev/null || echo 0)
|
||||
read -r busy total < <(awk '/^cpu /{b=$2+$3+$4+$7+$8+$9; t=b+$5+$6; print b,t}' /proc/stat)
|
||||
echo -e "$now\t$e\t$mt\t$ma\t$gc\t${ga:-0}\t$t\t$busy\t$total" >> "$OUT"
|
||||
sleep 5
|
||||
done
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""make_content.py OUTDIR — the bench's test content. Generated, or public domain. No personal data.
|
||||
|
||||
photos/ 140 generated 4000x3000 JPEGs (gradients + noise, phone-photo sized)
|
||||
+ up to 60 CC0 portrait PAINTINGS from The Met's Open Access API (isPublicDomain=true),
|
||||
resized to 4000 px — faces for Immich's face detection, of people long dead.
|
||||
pdfs/ 20 image-only A4 "scans" at 300 dpi (rendered text, slight skew + noise) — OCR has work
|
||||
video/ 3 generated 1080p H.264 clips (testsrc2 + tone), 3 min each
|
||||
upload/ 50 x 10 MiB random files = 500 MiB
|
||||
books/ 20 minimal EPUB 3 books (generated text)
|
||||
roms/ a few generated dummy files in platform folders (RomM's scan only; no real ROMs)
|
||||
|
||||
Run with `python3 -I`. Needs Pillow and ffmpeg (both present on DooPlex).
|
||||
"""
|
||||
import io, json, os, random, subprocess, sys, urllib.request, zipfile
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
OUT = sys.argv[1]
|
||||
rnd = random.Random(20261013)
|
||||
|
||||
|
||||
def mk(p):
|
||||
os.makedirs(os.path.join(OUT, p), exist_ok=True)
|
||||
return os.path.join(OUT, p)
|
||||
|
||||
|
||||
def photos():
|
||||
d = mk("photos")
|
||||
for i in range(140):
|
||||
w, h = 4000, 3000
|
||||
c1 = tuple(rnd.randrange(256) for _ in range(3)); c2 = tuple(rnd.randrange(256) for _ in range(3))
|
||||
g = Image.linear_gradient("L").resize((w, h))
|
||||
im = Image.composite(Image.new("RGB", (w, h), c1), Image.new("RGB", (w, h), c2), g)
|
||||
dr = ImageDraw.Draw(im)
|
||||
for _ in range(40):
|
||||
x, y = rnd.randrange(w), rnd.randrange(h); r = rnd.randrange(50, 600)
|
||||
dr.ellipse([x - r, y - r, x + r, y + r], fill=tuple(rnd.randrange(256) for _ in range(3)))
|
||||
noise = Image.effect_noise((w, h), 40).convert("RGB")
|
||||
im = Image.blend(im, noise, 0.25)
|
||||
im.save(os.path.join(d, f"gen-{i:03d}.jpg"), quality=92)
|
||||
# CC0 portrait paintings (The Met, European Paintings, public domain)
|
||||
try:
|
||||
q = "https://collectionapi.metmuseum.org/public/collection/v1.1/search?departmentId=11&hasImages=true&q=portrait&limit=150&offset=0"
|
||||
ids = json.load(urllib.request.urlopen(q, timeout=60))["objectIDs"]
|
||||
except Exception as e:
|
||||
print("met search failed:", e); ids = []
|
||||
got, credits = 0, []
|
||||
for oid in ids:
|
||||
if got >= 60:
|
||||
break
|
||||
try:
|
||||
o = json.load(urllib.request.urlopen(f"https://collectionapi.metmuseum.org/public/collection/v1/objects/{oid}", timeout=60))
|
||||
if not o.get("isPublicDomain") or not o.get("primaryImage"):
|
||||
continue
|
||||
raw = urllib.request.urlopen(o["primaryImage"], timeout=120).read()
|
||||
im = Image.open(io.BytesIO(raw)).convert("RGB")
|
||||
im.thumbnail((4000, 4000))
|
||||
im.save(os.path.join(d, f"met-{oid}.jpg"), quality=92)
|
||||
credits.append(f"{oid}\t{o.get('title','')[:80]}\t{o.get('objectURL','')}\tCC0")
|
||||
got += 1
|
||||
except Exception as e:
|
||||
print("skip", oid, e)
|
||||
open(os.path.join(OUT, "photos-credits.tsv"), "w").write("\n".join(credits) + "\n")
|
||||
print("photos:", 140 + got, "of which paintings:", got)
|
||||
|
||||
|
||||
WORDS = ("alma korte szilva haz kert konyv level szamla ugyfel datum osszeg fizetes hatarido "
|
||||
"szerzodes biztositas orvos recept iskola munka berlet viz gaz aram adoszam").split()
|
||||
|
||||
|
||||
def text(n):
|
||||
return " ".join(rnd.choice(WORDS) for _ in range(n))
|
||||
|
||||
|
||||
def pdfs():
|
||||
d = mk("pdfs")
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf", 38)
|
||||
except OSError:
|
||||
font = ImageFont.load_default()
|
||||
for i in range(20):
|
||||
im = Image.new("L", (2480, 3508), 245)
|
||||
dr = ImageDraw.Draw(im)
|
||||
y = 200
|
||||
dr.text((200, y), f"Bench document {i + 1:02d} - 2026-10-13", font=font, fill=20); y += 120
|
||||
while y < 3300:
|
||||
dr.text((200, y), text(9), font=font, fill=30); y += 62
|
||||
im = im.rotate(rnd.uniform(-1.2, 1.2), fillcolor=235).filter(ImageFilter.GaussianBlur(0.6))
|
||||
im = Image.blend(im, Image.effect_noise(im.size, 25).convert("L"), 0.08)
|
||||
im.save(os.path.join(d, f"scan-{i + 1:02d}.pdf"), resolution=300)
|
||||
print("pdfs: 20")
|
||||
|
||||
|
||||
def video():
|
||||
d = mk("video/Bench Films")
|
||||
for i in range(3):
|
||||
sub = os.path.join(d, f"Bench Film {i + 1} (2026)")
|
||||
os.makedirs(sub, exist_ok=True)
|
||||
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-f", "lavfi", "-i", f"testsrc2=size=1920x1080:rate=30",
|
||||
"-f", "lavfi", "-i", f"sine=frequency={440 + 110 * i}:sample_rate=48000",
|
||||
"-t", "180", "-c:v", "libx264", "-preset", "veryfast", "-b:v", "8M", "-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac", "-b:a", "192k", os.path.join(sub, f"Bench Film {i + 1} (2026).mkv")], check=True)
|
||||
print("video: 3 x 180 s 1080p")
|
||||
|
||||
|
||||
def upload():
|
||||
d = mk("upload")
|
||||
for i in range(50):
|
||||
with open(os.path.join(d, f"blob-{i:02d}.bin"), "wb") as f:
|
||||
f.write(os.urandom(10 * 1024 * 1024))
|
||||
print("upload: 500 MiB")
|
||||
|
||||
|
||||
def books():
|
||||
d = mk("books")
|
||||
for i in range(20):
|
||||
p = os.path.join(d, f"bench-book-{i + 1:02d}.epub")
|
||||
with zipfile.ZipFile(p, "w") as z:
|
||||
z.writestr(zipfile.ZipInfo("mimetype"), "application/epub+zip", compress_type=zipfile.ZIP_STORED)
|
||||
z.writestr("META-INF/container.xml",
|
||||
'<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
|
||||
'<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>')
|
||||
z.writestr("OEBPS/content.opf",
|
||||
f'<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id">'
|
||||
f'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="id">bench-{i}</dc:identifier>'
|
||||
f'<dc:title>Bench Book {i + 1}</dc:title><dc:creator>Felhom Bench</dc:creator><dc:language>hu</dc:language>'
|
||||
f'<meta property="dcterms:modified">2026-10-13T00:00:00Z</meta></metadata>'
|
||||
f'<manifest><item id="c" href="c.xhtml" media-type="application/xhtml+xml"/>'
|
||||
f'<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/></manifest>'
|
||||
f'<spine><itemref idref="c"/></spine></package>')
|
||||
z.writestr("OEBPS/nav.xhtml", '<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">'
|
||||
'<body><nav epub:type="toc"><ol><li><a href="c.xhtml">1</a></li></ol></nav></body></html>')
|
||||
z.writestr("OEBPS/c.xhtml", '<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><body>'
|
||||
+ "".join(f"<p>{text(80)}</p>" for _ in range(200)) + "</body></html>")
|
||||
print("books: 20")
|
||||
|
||||
|
||||
def roms():
|
||||
for plat, ext in (("nes", "nes"), ("snes", "sfc"), ("gb", "gb")):
|
||||
d = mk(f"roms/{plat}")
|
||||
for i in range(5):
|
||||
with open(os.path.join(d, f"Bench Game {i + 1}.{ext}"), "wb") as f:
|
||||
f.write(os.urandom(256 * 1024))
|
||||
print("roms: 15 dummy files")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for fn in (pdfs, books, roms, upload, video, photos):
|
||||
fn()
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# queue.sh — run bench batches one after another on the N100 (only this session deploys there).
|
||||
# heavy:<app> up <app> -> actions.sh <app> -> 15 min rest -> down -> analyze
|
||||
# batch:<tag>:<a,b,c> up a b c -> 15 min rest -> down -> analyze
|
||||
# Each batch copies its evidence before removing anything (bench.py down). A batch that fails
|
||||
# still runs `down`, so nothing is left deployed.
|
||||
cd "$(dirname "$0")"
|
||||
for item in "$@"; do
|
||||
IFS=: read -r kind tag apps <<<"$item"
|
||||
if [ "$kind" = heavy ]; then apps=$tag; fi
|
||||
list=${apps//,/ }
|
||||
echo "=== $(date -u +%T) $kind $tag: $list"
|
||||
python3 -I bench.py up "$tag" $list | tail -3
|
||||
if [ "$kind" = heavy ]; then python3 -I run_action.py "$tag" "$tag" | grep -E '^@|->|up after|never|token length|status|trusted|media|ingest|library|transcode|onboarding|documents|api auth|mkcol|startup|add-library' | tail -25; fi
|
||||
python3 -I bench.py down "$tag" $list | tail -4
|
||||
python3 -I analyze.py "../evidence/$tag" | head -20
|
||||
done
|
||||
echo "=== $(date -u +%T) queue done"
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""render_table.py — put the per-app table from evidence/apps-table.json into SIZING.md (__APPS_TABLE__ or between markers)."""
|
||||
import json, os, re
|
||||
A = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
t = json.load(open(os.path.join(A, "evidence", "apps-table.json")))
|
||||
rows = ["<!-- apps-table: generated by tools/render_table.py -->",
|
||||
"| App | Typical MB | Peak MB | Catalog request / limit | CPU class | CPU rest % | Busiest minute % | Source |",
|
||||
"|---|---|---|---|---|---|---|---|"]
|
||||
for a in sorted(t, key=lambda a: -(a["mem_peak_mb"] or 0)):
|
||||
f = lambda v: "–" if v is None else (f"{v:,}" if isinstance(v, int) else str(v))
|
||||
src = a["source"] + (" !" if a["sources_disagree"] else "")
|
||||
if a.get("lifecycle"):
|
||||
src += f" — {a['lifecycle']}"
|
||||
rows.append(f"| {a['display_name']} (`{a['slug']}`) | {f(a['mem_typical_mb'])} | {f(a['mem_peak_mb'])} | "
|
||||
f"{f(a['catalog_request_mb'])} / {f(a['catalog_limit_mb'])} | {a['cpu_class'] or '—'} | "
|
||||
f"{f(a['cpu_rest_pct'])} | {f(a['cpu_peak_1m_pct'])} | {src} |")
|
||||
rows.append("<!-- /apps-table -->")
|
||||
p = os.path.join(A, "SIZING.md"); s = open(p, encoding="utf-8").read()
|
||||
block = "\n".join(rows)
|
||||
if "__APPS_TABLE__" in s:
|
||||
s = s.replace("__APPS_TABLE__", block)
|
||||
else:
|
||||
s = re.sub(r"<!-- apps-table:.*?<!-- /apps-table -->", block, s, flags=re.S)
|
||||
open(p, "w", encoding="utf-8").write(s)
|
||||
print(len(t), "rows")
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""run_action.py TAG APP — run actions.sh APP inside the guest; record its @ts lines as events.
|
||||
|
||||
The password the action needs is the app's own generated admin password (from the `up` step's
|
||||
0600 scratch file) or, for apps whose first user the action creates, a fresh one. It travels inside
|
||||
the piped script, never on a command line, and is never written to evidence.
|
||||
"""
|
||||
import json, os, re, secrets, sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import bench
|
||||
|
||||
tag, app = sys.argv[1], sys.argv[2]
|
||||
gen = {}
|
||||
f = os.path.join(bench.SC, f".generated-{tag}.json")
|
||||
if os.path.exists(f):
|
||||
gen = json.load(open(f)).get(app, {})
|
||||
pw = gen.get("PAPERLESS_ADMIN_PASSWORD") or gen.get("NEXTCLOUD_ADMIN_PASSWORD") or ("Sz-" + secrets.token_hex(12))
|
||||
script = f"export PW='{pw}'\n" + open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "actions.sh")).read().replace(
|
||||
'APP=${1:?app}', f'APP={app}')
|
||||
out = bench.guest(script, timeout=6 * 3600)
|
||||
assert pw not in out, "secret in output"
|
||||
d = os.path.join(bench.AUDIT, "evidence", tag); os.makedirs(d, exist_ok=True)
|
||||
open(os.path.join(d, f"action-{app}.txt"), "a").write(out)
|
||||
bench._load(tag)
|
||||
for m in re.finditer(r"^@(\d+) (\S+)(.*)$", out, re.M):
|
||||
bench.EVENTS.append(dict(ts=int(m.group(1)), kind=m.group(2), app=app, note=m.group(3).strip()))
|
||||
bench._save(tag)
|
||||
print(out)
|
||||
Reference in New Issue
Block a user