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.
This commit is contained in:
@@ -136,10 +136,28 @@ auth app.
|
||||
**Standard package steps:**
|
||||
1. `mkdir -p Package-Workspace/<Category>/<app>/` 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 <pkg-dir>`
|
||||
→ `curl` / `logs` → `down`.
|
||||
4. Write `README.md` + `CHANGELOG.md` + `logo.png` (+ `.env.example`).
|
||||
5. Commit as `feat: add <app> Cloudron package (<Category>)`, push.
|
||||
6. Run the gardening protocol above (update STATUS / README / JOURNAL).
|
||||
7. `scripts/grind-stack.sh sweep <pkg-dir>` — 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.
|
||||
|
||||
Executable
+148
@@ -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 <pkg-dir> 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 <pkg-dir> 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 <pkg-dir>}"
|
||||
[ -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 <pkg-dir>}"
|
||||
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 <pkg-dir> | curl [path] | logs | down | sweep <pkg-dir> | preflight"; exit 2 ;;
|
||||
esac
|
||||
Reference in New Issue
Block a user