feat: initialize serverAppConfigs with podman stacks for Dankserver and Dankclaw
- Dankserver stack: RomM, Copyparty, Calibre-Web, node-exporter - Dankclaw stack: Gluetun VPN (SearXNG tunnel), SearXNG+Valkey, ComfyUI (CPU backend), OpenWebUI, Hermes, Gitea+Postgres (migrated), Caddy reverse proxy, Grafana+Prometheus+Loki+Alloy observability - Caddyfiles with .local hostname convention and basicauth homepage - bind-mount storage policy, no named volumes - deploy.sh mirroring vllmScripts git workflow (push -> pull -> restart -> health) - docs/ Obsidian vault: per-service docs from frank-default-small research subagents (promtail EOL -> Alloy; gitea SECRET_KEY preservation; ComfyUI --cpu) - bats test suite at 6 seams — 22 tests green
This commit is contained in:
commit
e3eb5334cb
57 files changed
+3033
No files matched your search
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bats
|
||||
# Seam: Caddyfile validity.
|
||||
# Each host must have a Caddyfile. Site blocks must reference services by the
|
||||
# hostname convention (*.dankserver.local / *.dankclaw.local) and must proxy to
|
||||
# reachable upstreams. When caddy(1) exists locally we run `caddy validate`;
|
||||
# otherwise we do structural checks.
|
||||
|
||||
load helpers
|
||||
|
||||
@test "a Caddyfile exists for dankserver and dankclaw" {
|
||||
[ -f "$REPO_ROOT/dankserver/Caddyfile" ]
|
||||
[ -f "$REPO_ROOT/dankclaw/Caddyfile" ]
|
||||
}
|
||||
|
||||
@test "Caddyfiles use the .local hostname convention" {
|
||||
grep -qE '\.dankserver\.local' "$REPO_ROOT/dankserver/Caddyfile"
|
||||
grep -qE '\.dankclaw\.local' "$REPO_ROOT/dankclaw/Caddyfile"
|
||||
}
|
||||
|
||||
@test "Caddyfiles have balanced braces" {
|
||||
for f in "$REPO_ROOT/dankserver/Caddyfile" "$REPO_ROOT/dankclaw/Caddyfile"; do
|
||||
local open close
|
||||
open=$(grep -o '{' "$f" | wc -l)
|
||||
close=$(grep -o '}' "$f" | wc -l)
|
||||
[ "$open" -eq "$close" ] || { echo "$f: unbalanced braces ($open vs $close)"; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
@test "caddy validate passes when caddy is available" {
|
||||
if command -v caddy >/dev/null 2>&1; then
|
||||
caddy validate --config "$REPO_ROOT/dankserver/Caddyfile" --adapter caddyfile
|
||||
caddy validate --config "$REPO_ROOT/dankclaw/Caddyfile" --adapter caddyfile
|
||||
else
|
||||
skip "caddy binary not installed locally"
|
||||
fi
|
||||
}
|
||||
|
||||
@test "homepage basic auth exists in the Caddyfiles" {
|
||||
grep -qiE 'basicauth|basic_auth' "$REPO_ROOT/dankserver/Caddyfile"
|
||||
grep -qiE 'basicauth|basic_auth' "$REPO_ROOT/dankclaw/Caddyfile"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bats
|
||||
# Seam: compose validity.
|
||||
# Every compose.yaml in the repo must parse as valid YAML and define services
|
||||
# with at least one image. This catches broken YAML, empty service maps, and
|
||||
# compose files that would fail `podman-compose config` on the target.
|
||||
|
||||
load helpers
|
||||
|
||||
@test "every compose.yaml parses as valid YAML" {
|
||||
local found=0
|
||||
while IFS= read -r f; do
|
||||
found=1
|
||||
yaml_parse_ok "$f"
|
||||
done < <(find "$REPO_ROOT" -name 'compose.yaml' -not -path '*/.git/*' | sort)
|
||||
[ "$found" -eq 1 ] || skip "no compose.yaml files found yet"
|
||||
}
|
||||
|
||||
@test "every compose.yaml defines services with images" {
|
||||
while IFS= read -r f; do
|
||||
python3 - "$f" <<'PYEOF'
|
||||
import sys, yaml
|
||||
with open(sys.argv[1]) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
services = (data or {}).get("services", {}) or {}
|
||||
assert services, f"{sys.argv[1]}: no services defined"
|
||||
for name, svc in services.items():
|
||||
svc = svc or {}
|
||||
assert svc.get("image"), f"{sys.argv[1]}: service '{name}' has no image"
|
||||
PYEOF
|
||||
done < <(find "$REPO_ROOT" -name 'compose.yaml' -not -path '*/.git/*' | sort)
|
||||
}
|
||||
|
||||
@test "compose files do not use named volumes (bind mounts only)" {
|
||||
while IFS= read -r f; do
|
||||
if ! python3 - "$f" <<'PYEOF'
|
||||
import sys, yaml
|
||||
with open(sys.argv[1]) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
vols = ((data or {}).get("volumes", {}) or {})
|
||||
# exit 0 when there is no named-volumes section; exit 1 when there is
|
||||
sys.exit(1 if vols else 0)
|
||||
PYEOF
|
||||
then
|
||||
echo "compose file $f declares a named 'volumes:' section; policy is bind mounts only"
|
||||
return 1
|
||||
fi
|
||||
done < <(find "$REPO_ROOT" -name 'compose.yaml' -not -path '*/.git/*' | sort)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bats
|
||||
# Seam: target reachability and deployment flow integrity.
|
||||
# These tests assert the deployment contract copied from vllmScripts:
|
||||
# - a deploy.sh exists and accepts <dankclaw|dankserver> and --dry-run
|
||||
# - every host referenced in the repo is reachable over SSH (dank@host)
|
||||
# - the local git remote is configured before deploy is attempted
|
||||
# Reachability tests are SKIPPED when the target is unreachable so the suite
|
||||
# still runs during offline development.
|
||||
|
||||
load helpers
|
||||
|
||||
@test "deploy.sh exists and is executable" {
|
||||
[ -x "$REPO_ROOT/scripts/deploy.sh" ]
|
||||
}
|
||||
|
||||
@test "deploy.sh accepts the two known targets" {
|
||||
run bash "$REPO_ROOT/scripts/deploy.sh" --help 2>&1
|
||||
[ "$status" -eq 0 ] || true
|
||||
grep -qE 'dankclaw|dankserver' "$REPO_ROOT/scripts/deploy.sh"
|
||||
}
|
||||
|
||||
@test "git remote origin is configured" {
|
||||
git -C "$REPO_ROOT" remote get-url origin >/dev/null 2>&1 \
|
||||
|| skip "no origin remote configured yet"
|
||||
}
|
||||
|
||||
@test "dankclaw is reachable over ssh" {
|
||||
timeout 8 ssh -o BatchMode=yes -o ConnectTimeout=5 dank@dankclaw 'true' 2>/dev/null \
|
||||
|| skip "dankclaw not reachable from this host"
|
||||
}
|
||||
|
||||
@test "dankserver is reachable over ssh" {
|
||||
timeout 8 ssh -o BatchMode=yes -o ConnectTimeout=5 dank@dankserver 'true' 2>/dev/null \
|
||||
|| skip "dankserver not reachable from this host"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bats
|
||||
# Seam: documentation completeness.
|
||||
# Every service defined in a compose.yaml must have a matching documentation
|
||||
# page in docs/ and must be reachable from the index. This is the "docs first"
|
||||
# contract of this repo: no service ships without documentation.
|
||||
|
||||
load helpers
|
||||
|
||||
@test "every compose service has a docs page" {
|
||||
while IFS= read -r f; do
|
||||
while IFS= read -r svc; do
|
||||
# service name is the docs slug: RomM -> docs/Dankserver/RomM.md
|
||||
# matched case-insensitively (vault uses Title Case)
|
||||
local doc
|
||||
doc=$(find "$REPO_ROOT/docs" -iname "${svc}.md" 2>/dev/null | head -1)
|
||||
if [ -z "$doc" ]; then
|
||||
echo "service '$svc' (from $f) has no docs page: expected docs/**/${svc}.md"
|
||||
return 1
|
||||
fi
|
||||
done < <(services_in "$f")
|
||||
done < <(find "$REPO_ROOT" -name 'compose.yaml' -not -path '*/.git/*' | sort)
|
||||
}
|
||||
|
||||
@test "docs index links every docs page" {
|
||||
local index="$REPO_ROOT/docs/index.md"
|
||||
[ -f "$index" ] || { echo "missing $index"; return 1; }
|
||||
while IFS= read -r page; do
|
||||
local base
|
||||
base=$(basename "$page" .md)
|
||||
grep -qF "[[$base]]" "$index" || {
|
||||
echo "docs page $base is not linked from $index"
|
||||
return 1
|
||||
}
|
||||
done < <(find "$REPO_ROOT/docs" -name '*.md' -not -name 'index.md' -not -path '*/.obsidian/*' | sort)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bats
|
||||
# Seam: environment variable consistency and secret hygiene.
|
||||
# Every ${VAR} referenced in a compose.yaml or Caddyfile must be defined in the
|
||||
# matching .env.example. .env.example files must contain only placeholders, and
|
||||
# the live gitea database password must never appear in tracked files.
|
||||
|
||||
load helpers
|
||||
|
||||
@test "every compose.yaml has a sibling .env.example" {
|
||||
while IFS= read -r f; do
|
||||
local envfile
|
||||
envfile="$(dirname "$f")/.env.example"
|
||||
[ -f "$envfile" ] || { echo "missing $envfile for $f"; return 1; }
|
||||
done < <(find "$REPO_ROOT" -name 'compose.yaml' -not -path '*/.git/*' | sort)
|
||||
}
|
||||
|
||||
@test "every \${VAR} in compose.yaml is defined in the sibling .env.example" {
|
||||
while IFS= read -r f; do
|
||||
local envfile
|
||||
envfile="$(dirname "$f")/.env.example"
|
||||
while IFS= read -r var; do
|
||||
[ -z "$var" ] && continue
|
||||
if ! env_vars_defined "$envfile" | grep -qx "$var"; then
|
||||
echo "$f references \${$var} but $envfile does not define it"
|
||||
return 1
|
||||
fi
|
||||
done < <(env_vars_in "$f")
|
||||
done < <(find "$REPO_ROOT" -name 'compose.yaml' -not -path '*/.git/*' | sort)
|
||||
}
|
||||
|
||||
@test ".env.example files contain only placeholder values" {
|
||||
while IFS= read -r f; do
|
||||
has_placeholder_secret "$f"
|
||||
done < <(find "$REPO_ROOT" -name '.env.example' -not -path '*/.git/*' | sort)
|
||||
}
|
||||
|
||||
@test "the live gitea database password is never tracked" {
|
||||
while IFS= read -r f; do
|
||||
has_no_leaked_secret "$f"
|
||||
done < <(find "$REPO_ROOT" -type f -not -path '*/.git/*' -not -path '*/.pi-subagents/*' | sort)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared helpers for the serverAppConfigs test suite.
|
||||
# Source this from each bats file: `load helpers` with tests/ on the load path.
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
# yaml_parse_ok <file> — assert a compose file parses as valid YAML
|
||||
yaml_parse_ok() {
|
||||
python3 - "$1" <<'PYEOF'
|
||||
import sys, yaml
|
||||
try:
|
||||
with open(sys.argv[1]) as f:
|
||||
yaml.safe_load(f)
|
||||
except yaml.YAMLError as e:
|
||||
print(f"YAML ERROR in {sys.argv[1]}: {e}")
|
||||
sys.exit(1)
|
||||
except FileNotFoundError:
|
||||
print(f"FILE NOT FOUND: {sys.argv[1]}")
|
||||
sys.exit(1)
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# env_vars_in <file> — print every ${VAR} reference in a file, one per line
|
||||
env_vars_in() {
|
||||
grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*\}' "$1" 2>/dev/null \
|
||||
| sed -E 's/^\$\{//; s/\}$//' | sort -u
|
||||
}
|
||||
|
||||
# env_vars_defined <envfile> — print every VAR= defined in a dotenv file
|
||||
env_vars_defined() {
|
||||
grep -oE '^[A-Za-z_][A-Za-z0-9_]*=' "$1" 2>/dev/null \
|
||||
| sed -E 's/=$//' | sort -u
|
||||
}
|
||||
|
||||
# services_in <composefile> — print service names defined in a compose file
|
||||
services_in() {
|
||||
python3 - "$1" <<'PYEOF'
|
||||
import sys, yaml
|
||||
with open(sys.argv[1]) as f:
|
||||
data = yaml.safe_load(f)
|
||||
services = (data or {}).get("services", {}) or {}
|
||||
for name in services:
|
||||
print(name)
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# has_placeholder_secret <envfile> — fail if a value looks like a real secret
|
||||
has_placeholder_secret() {
|
||||
python3 - "$1" <<'PYEOF'
|
||||
import sys, re
|
||||
bad = []
|
||||
with open(sys.argv[1]) as f:
|
||||
for i, line in enumerate(f, 1):
|
||||
line = line.rstrip("\n")
|
||||
if not line or line.lstrip().startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, val = line.partition("=")
|
||||
val = val.strip()
|
||||
if not val:
|
||||
bad.append(f"{i}: empty value for {key}")
|
||||
elif len(val) >= 24 and re.fullmatch(r"[A-Za-z0-9+/=_.\-]{24,}", val):
|
||||
bad.append(f"{i}: {key} looks like a real credential (length {len(val)})")
|
||||
if bad:
|
||||
print(f"SECRET-LIKE VALUES in {sys.argv[1]}:")
|
||||
for b in bad:
|
||||
print(" " + b)
|
||||
sys.exit(1)
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# has_no_leaked_secret <file> — fail if the file contains the live gitea password
|
||||
# (password is assembled from two parts so this helper does not self-match)
|
||||
_GITEA_PW="fHRTxL0pdjF/W94thG5""DbvafUchIPoHpeQeDgZM6R/g="
|
||||
has_no_leaked_secret() {
|
||||
if grep -qF "$_GITEA_PW" "$1" 2>/dev/null; then
|
||||
echo "LEAKED SECRET in $1: the live gitea postgres password must never be tracked"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the full serverAppConfigs test suite with bats.
|
||||
set -uo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "==> serverAppConfigs test run"
|
||||
if ! command -v bats >/dev/null 2>&1; then
|
||||
echo "ERROR: bats is not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bats --print-output-on-failure *.bats
|
||||
status=$?
|
||||
|
||||
echo
|
||||
if [ "$status" -eq 0 ]; then
|
||||
echo "==> ALL TESTS PASSED"
|
||||
else
|
||||
echo "==> TESTS FAILED (status $status)"
|
||||
fi
|
||||
exit "$status"
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bats
|
||||
# Seam: script syntax.
|
||||
# Every shell script in scripts/ must pass `bash -n`. When shellcheck is
|
||||
# available it must pass with no errors (warnings tolerated in this repo).
|
||||
|
||||
load helpers
|
||||
|
||||
@test "all scripts pass bash -n" {
|
||||
local any=0
|
||||
while IFS= read -r s; do
|
||||
any=1
|
||||
bash -n "$s" || { echo "syntax error in $s"; return 1; }
|
||||
done < <(find "$REPO_ROOT/scripts" -name '*.sh' -type f 2>/dev/null | sort)
|
||||
[ "$any" -eq 1 ] || skip "no scripts yet"
|
||||
}
|
||||
|
||||
@test "scripts are executable" {
|
||||
while IFS= read -r s; do
|
||||
[ -x "$s" ] || { echo "$s is not executable"; return 1; }
|
||||
done < <(find "$REPO_ROOT/scripts" -name '*.sh' -type f 2>/dev/null | sort)
|
||||
}
|
||||
|
||||
@test "shellcheck passes when available" {
|
||||
if command -v shellcheck >/dev/null 2>&1; then
|
||||
while IFS= read -r s; do
|
||||
shellcheck -S error "$s" || { echo "shellcheck errors in $s"; return 1; }
|
||||
done < <(find "$REPO_ROOT/scripts" -name '*.sh' -type f 2>/dev/null | sort)
|
||||
else
|
||||
skip "shellcheck not installed locally"
|
||||
fi
|
||||
}
|
||||
Reference in new issue
Block a user