From b9d963e7c56477016ce9b4d7d5647c9e0c5672d0 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Sun, 6 Sep 2026 06:36:20 -0500 Subject: [PATCH] feat: add grind-stack verify lifecycle for package work [#632] Serial one-at-a-time test harness: builds under ukrrs-batch.slice, ephemeral pg/redis/mysql per manifest addons, app driven by the real CLOUDRON_* env contract. All resources labeled cloudron.grind=1 so down/sweep cannot touch fleet containers. AGENTS.md documents the per-package lifecycle (up/curl/logs/down/sweep) + host resource rules. --- AGENTS.md | 20 +++++- scripts/grind-stack.sh | 148 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100755 scripts/grind-stack.sh diff --git a/AGENTS.md b/AGENTS.md index 14a7609..ce0eed8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,10 +136,28 @@ auth app. **Standard package steps:** 1. `mkdir -p Package-Workspace///` and clone upstream into `repo/`. 2. Write `Dockerfile` + `CloudronManifest.json` (+ `start.sh` if runtime setup needed). -3. `docker build` to validate locally. +3. Verify with the grind lifecycle (below): `scripts/grind-stack.sh up ` + → `curl` / `logs` → `down`. 4. Write `README.md` + `CHANGELOG.md` + `logo.png` (+ `.env.example`). 5. Commit as `feat: add Cloudron package ()`, push. 6. Run the gardening protocol above (update STATUS / README / JOURNAL). +7. `scripts/grind-stack.sh sweep ` — drop the test image + dangling + build cache so disk usage stays flat across the grind. + +**Grind lifecycle** ([`scripts/grind-stack.sh`](scripts/grind-stack.sh)): serial +verify-stack harness. `up` builds the image under `ukrrs-batch.slice` and starts +ephemeral postgres/redis/mysql matching the manifest's addons, plus the app +container driven by the real `CLOUDRON_*` env contract — every resource labeled +`cloudron.grind=1`, so `down`/`sweep` can never touch fleet containers. One +stack at a time (serial lock; `up` refuses while one is live). + +**Host resource rules (day/night, disk):** all builds + test stacks run under +`ukrrs-batch.slice` (cores 0-5, CPUWeight 25 — cores 6-7 stay interactive). +Night window 22:00-07:00 Central tolerates wider build parallelism; avoid +LLM-heavy sub-agent fan-outs 01:00-05:00 (z.ai peak ladder). Docker storage +lives on `/data2` (SSD; moved off the NVMe root 2026-09-06 via ultix +`15-docker-to-data2.sh` — rollback: delete the docker.service.d data-root +drop-in, daemon-reload, restart). **Recurring gotchas (from JOURNAL.md):** - `chmod` in `RUN` fails on Cloudron base → make scripts executable on the host. diff --git a/scripts/grind-stack.sh b/scripts/grind-stack.sh new file mode 100755 index 0000000..e6138ba --- /dev/null +++ b/scripts/grind-stack.sh @@ -0,0 +1,148 @@ +#!/bin/bash +set -euo pipefail + +# grind-stack.sh - serial verify-stack lifecycle for package work [#632] +# +# One test stack at a time, built on labeled resources ONLY (label +# cloudron.grind=1): an ephemeral postgres/redis/mysql matching the +# package manifest's addons, the built app image with the same +# CLOUDRON_* env Cloudron injects, and a strict teardown. Nothing +# outside the label is ever stopped or removed. +# +# Usage: +# scripts/grind-stack.sh up build image + start verify stack +# scripts/grind-stack.sh curl [path] probe the app (default: / ) +# scripts/grind-stack.sh logs app container logs (tail 50) +# scripts/grind-stack.sh down teardown: containers, net, volume +# scripts/grind-stack.sh sweep post-commit prune of the test +# image + dangling build cache +# scripts/grind-stack.sh preflight disk + slice sanity check +# +# All containers run under ukrrs-batch.slice (cores 0-5, CPUWeight 25) +# so the desktop/gateway never starves. Serial by design: `up` refuses +# to start a second stack while one is live. + +LABEL="cloudron.grind=1" +NET="grind-test" +TESTPORT=18080 +SLICE="ukrrs-batch.slice" +APP="grind-app"; PG="grind-pg"; RD="grind-redis"; MY="grind-mysql" + +die() { echo "ERROR: $*" >&2; exit 1; } + +label_args() { echo "--label $LABEL"; } + +stack_running() { + [ -n "$(docker ps -q --filter label="$LABEL" 2>/dev/null)" ] +} + +manifest_field() { jq -r "$1" "$PKG/CloudronManifest.json"; } + +cmd_up() { + PKG="${1:?usage: grind-stack.sh up }" + [ -f "$PKG/CloudronManifest.json" ] || die "no CloudronManifest.json in $PKG" + APPNAME="$(basename "$PKG")" + PORT="$(manifest_field .httpPort)" + MEM="$(manifest_field .memoryLimit)" + ADDONS="$(jq -r '.addons | keys[]' "$PKG/CloudronManifest.json" | sort | tr '\n' ' ')" + + stack_running && die "a grind stack is already up - run 'down' first (serial policy)" + + echo "== grind up: $APPNAME (port $PORT, addons: ${ADDONS:-none})" + + # disk guard: the docker root must have room for image + test stack + ROOT="$(docker info --format '{{.DockerRootDir}}')" + AVAIL_GB=$(df -BG --output=avail "$(dirname "$ROOT")" | tail -1 | tr -dc '0-9') + [ "$AVAIL_GB" -ge 20 ] || die "only ${AVAIL_GB}G free on the docker root - sweep first" + + docker build --cgroup-parent "$SLICE" -t "grind-$APPNAME:test" "$PKG" + + docker network create --label "$LABEL" "$NET" 2>/dev/null || true + + case "$ADDONS" in *postgresql*) + docker run -d --rm --name "$PG" --cgroup-parent "$SLICE" --network "$NET" \ + $(label_args) -e POSTGRES_DB=testdb -e POSTGRES_USER=testdb \ + -e POSTGRES_PASSWORD=testpg postgres:16-alpine >/dev/null ;; + esac + case "$ADDONS" in *mysql*) + docker run -d --rm --name "$MY" --cgroup-parent "$SLICE" --network "$NET" \ + $(label_args) -e MYSQL_DATABASE=testdb -e MYSQL_USER=testdb \ + -e MYSQL_PASSWORD=testmy -e MYSQL_ROOT_PASSWORD=testroot \ + mysql:8.4 >/dev/null ;; + esac + case "$ADDONS" in *redis*) + docker run -d --rm --name "$RD" --cgroup-parent "$SLICE" --network "$NET" \ + $(label_args) redis:7-alpine --requirepass testrd >/dev/null ;; + esac + + # the same env contract Cloudron injects, pointed at the test services + docker run -d --rm --name "$APP" --cgroup-parent "$SLICE" --network "$NET" \ + $(label_args) -v "grind-$APPNAME-data:/app/data" \ + -p "127.0.0.1:$TESTPORT:$PORT" \ + -e CLOUDRON_POSTGRESQL_HOST="$PG" -e CLOUDRON_POSTGRESQL_PORT=5432 \ + -e CLOUDRON_POSTGRESQL_DATABASE=testdb -e CLOUDRON_POSTGRESQL_USERNAME=testdb \ + -e CLOUDRON_POSTGRESQL_PASSWORD=testpg \ + -e CLOUDRON_MYSQL_HOST="$MY" -e CLOUDRON_MYSQL_PORT=3306 \ + -e CLOUDRON_MYSQL_DATABASE=testdb -e CLOUDRON_MYSQL_USERNAME=testdb \ + -e CLOUDRON_MYSQL_PASSWORD=testmy \ + -e CLOUDRON_REDIS_HOST="$RD" -e CLOUDRON_REDIS_PORT=6379 \ + -e CLOUDRON_REDIS_PASSWORD=testrd -e CLOUDRON_REDIS_URL=redis://:testrd@"$RD":6379 \ + -e CLOUDRON_APP_DOMAIN="$APPNAME.test" \ + -e CLOUDRON_APP_ORIGIN="https://$APPNAME.test" \ + -e CLOUDRON_OIDC_ISSUER=https://sso.test -e CLOUDRON_OIDC_CLIENT_ID=grind \ + -e CLOUDRON_OIDC_CLIENT_SECRET=grind-secret \ + "grind-$APPNAME:test" >/dev/null + + echo "stack up: app on http://127.0.0.1:$TESTPORT (Host: $APPNAME.test)" + echo "next: curl / logs / down / sweep" +} + +cmd_curl() { + PATH_="${1:-/}" + docker ps --filter name="$APP" --filter label="$LABEL" --format '{{.Image}}' | grep -q . \ + || die "no grind app running" + HOST="$(docker inspect "$APP" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^CLOUDRON_APP_DOMAIN=//p')" + CODE=$(curl -s -o /dev/null -w '%{http_code}' -m 8 -H "Host: ${HOST:-app.test}" "http://127.0.0.1:$TESTPORT$PATH_") + echo "GET $PATH_ -> $CODE (with Host: ${HOST:-app.test})" +} + +cmd_logs() { docker logs --tail 50 "$APP" 2>&1; } + +cmd_down() { + echo "== grind down: removing labeled stack" + docker rm -f "$APP" "$PG" "$RD" "$MY" >/dev/null 2>&1 || true + docker network rm "$NET" >/dev/null 2>&1 || true + for v in $(docker volume ls --filter name='grind-*-data' -q 2>/dev/null); do + docker volume rm "$v" >/dev/null 2>&1 || true + done + echo "stack down (labeled resources only)" +} + +cmd_sweep() { + PKG="${1:?usage: grind-stack.sh sweep }" + APPNAME="$(basename "$PKG")" + echo "== grind sweep: $APPNAME" + stack_running && die "stack still up - run 'down' before sweep" + docker image rm "grind-$APPNAME:test" >/dev/null 2>&1 || echo " (test image already gone)" + docker builder prune -f >/dev/null && echo " build cache pruned (dangling only)" + echo "swept. Base images are kept for reuse; fleets untouched." +} + +cmd_preflight() { + ROOT="$(docker info --format '{{.DockerRootDir}}')" + echo "docker root: $ROOT" + df -h "$ROOT" | tail -1 | sed 's/^/ /' + systemctl cat "$SLICE" >/dev/null 2>&1 && echo "slice $SLICE: present" || echo "WARN: slice $SLICE missing" + stack_running && echo "stack: UP (serial lock held)" || echo "stack: down" + docker system df | sed 's/^/ /' +} + +case "${1:-preflight}" in + up) shift; cmd_up "$@" ;; + curl) shift; cmd_curl "$@" ;; + logs) cmd_logs ;; + down) cmd_down ;; + sweep) shift; cmd_sweep "$@" ;; + preflight) cmd_preflight ;; + *) echo "usage: $0 up | curl [path] | logs | down | sweep | preflight"; exit 2 ;; +esac