#!/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