Deploy services independently by changed paths
This commit is contained in:
1 parent
2277a2e9ac
commit
1ba47e4d22
18 files changed
+1462
-894
No files matched your search
+20
-33
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env bats
|
||||
# Seam: target reachability and deployment flow integrity.
|
||||
# These tests assert the deployment contract of the unified repo:
|
||||
# - a deploy.sh exists and accepts <dankclaw|dankserver> and --dry-run
|
||||
# - deploy.sh covers both the podman stack and the LLM fleet services
|
||||
# These tests assert the service-scoped deployment contract:
|
||||
# - deploy.sh accepts a target, optional service scope, and --dry-run
|
||||
# - deploy-remote.sh owns per-service handlers and deployment state
|
||||
# - no host-wide inference restart is hidden in the dispatcher
|
||||
# - 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
|
||||
@@ -38,44 +39,30 @@ load helpers
|
||||
# Regression: the Caddyfile render step in deploy.sh must survive a bcrypt
|
||||
# hash (which contains '/' characters). Reproduce the sed expression with a
|
||||
# slash-bearing hash and confirm the placeholder is replaced.
|
||||
@test "deploy.sh reloads Caddy after rendering (hot reload, not just container start)" {
|
||||
# The caddy container often survives 'up -d' (a Caddyfile change is a
|
||||
# bind-mount content change, not a container config change). Without an
|
||||
# explicit reload, a rendered fix can sit on disk while Caddy serves the
|
||||
# stale in-memory config.
|
||||
grep -q 'caddy reload' "$REPO_ROOT/scripts/deploy.sh"
|
||||
@test "scoped remote runner owns Caddy reload and safe fallback" {
|
||||
# Caddy is handled only when selected by the planner. A failed reload may
|
||||
# restart Caddy, but must not restart the whole compose project.
|
||||
grep -q 'caddy reload' "$REPO_ROOT/scripts/deploy-remote.sh"
|
||||
grep -q 'podman restart caddy' "$REPO_ROOT/scripts/deploy-remote.sh"
|
||||
! grep -qE 'podman-compose.*(up|restart).*\$\(' "$REPO_ROOT/scripts/deploy-remote.sh"
|
||||
}
|
||||
|
||||
@test "deploy.sh restarts caddy when the reload cannot be applied" {
|
||||
# If the running config has the admin API disabled (or the API is down),
|
||||
# 'caddy reload' can never apply the new file -- the fix cannot be
|
||||
# delivered over the API it is meant to enable. The fallback is a
|
||||
# container restart, which always reads the file.
|
||||
grep -q 'podman restart caddy' "$REPO_ROOT/scripts/deploy.sh"
|
||||
@test "remote runner has separate handlers for both inference services" {
|
||||
grep -q 'deploy_llm_large' "$REPO_ROOT/scripts/deploy-remote.sh"
|
||||
grep -q 'deploy_llm_small' "$REPO_ROOT/scripts/deploy-remote.sh"
|
||||
}
|
||||
|
||||
# The unified deploy.sh must manage the LLM fleet services on both hosts:
|
||||
# frank-default-small (dankserver) and frank-default-large (dankclaw). A
|
||||
# deploy that only restarts the podman stack leaves the LLM units pointing
|
||||
# at the retired vllmScripts checkout.
|
||||
@test "deploy.sh restarts the LLM services on both targets" {
|
||||
grep -q 'frank-default-small' "$REPO_ROOT/scripts/deploy.sh"
|
||||
grep -q 'frank-default-large' "$REPO_ROOT/scripts/deploy.sh"
|
||||
@test "inference restarts are absent from the top-level dispatcher" {
|
||||
! grep -qE 'systemctl .*restart (frank-default-small|frank-default-large)' "$REPO_ROOT/scripts/deploy.sh"
|
||||
}
|
||||
|
||||
@test "deploy.sh health-checks both LLM endpoints (both on :7770, by host)" {
|
||||
# 2026-08-22: frank-default-large moved from :52625 to :7770 on
|
||||
# dankclaw. Both LLM services now listen on :7770, distinguished by
|
||||
# host (dankserver vs dankclaw).
|
||||
grep -qE '^LLM_HEALTH\[dankserver\]="http://127\.0\.0\.1:7770/health"' "$REPO_ROOT/scripts/deploy.sh"
|
||||
grep -qE '^LLM_HEALTH\[dankclaw\]="http://127\.0\.0\.1:7770/health"' "$REPO_ROOT/scripts/deploy.sh"
|
||||
@test "remote runner health-checks an inference endpoint" {
|
||||
grep -q '127.0.0.1:7770/health' "$REPO_ROOT/scripts/deploy-remote.sh"
|
||||
}
|
||||
|
||||
@test "deploy.sh pulls on the merged checkout on both targets" {
|
||||
# Both targets run the whole stack (podman + LLM) from one checkout.
|
||||
local count
|
||||
count=$(grep -c '/home/dank/serverAppConfigs' "$REPO_ROOT/scripts/deploy.sh")
|
||||
[ "$count" -ge 2 ]
|
||||
@test "deploy.sh pulls the merged checkout on the target" {
|
||||
grep -q 'git pull --ff-only origin main' "$REPO_ROOT/scripts/deploy.sh"
|
||||
grep -q '/home/dank/serverAppConfigs' "$REPO_ROOT/scripts/deploy.sh"
|
||||
}
|
||||
|
||||
@test "deploy.sh Caddyfile render survives a slash-bearing bcrypt hash" {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bats
|
||||
# Seam: service-scoped deployment planning.
|
||||
# A repository commit can contain changes for several workloads while only a
|
||||
# subset is safe to restart. The planner must keep inference isolated from
|
||||
# unrelated service changes and treat docs-only changes as a no-op.
|
||||
|
||||
load helpers
|
||||
|
||||
PLANNER="$REPO_ROOT/scripts/deploy-plan.py"
|
||||
REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
|
||||
|
||||
@test "deployment planner maps SearXNG changes to SearXNG only" {
|
||||
run python3 "$PLANNER" --target dankclaw --paths \
|
||||
shared/searxng/settings.yml docs/Dankclaw/SearXNG.md
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "searxng" ]
|
||||
}
|
||||
|
||||
@test "deployment planner maps large-model changes to the large model only" {
|
||||
run python3 "$PLANNER" --target dankclaw --paths \
|
||||
scripts/frank-default-large.env scripts/frank-default-large.service
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "frank-default-large" ]
|
||||
}
|
||||
|
||||
@test "shared chat-template changes map to the selected host's inference service" {
|
||||
run python3 "$PLANNER" --target dankclaw --paths scripts/chat-template.jinja
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "frank-default-large" ]
|
||||
|
||||
run python3 "$PLANNER" --target dankserver --paths scripts/chat-template.jinja
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "frank-default-small" ]
|
||||
}
|
||||
|
||||
@test "deployment planner maps small-model changes to the small model only" {
|
||||
run python3 "$PLANNER" --target dankserver --paths \
|
||||
scripts/frank-default-small.service
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "frank-default-small" ]
|
||||
}
|
||||
|
||||
@test "documentation-only changes plan no runtime deployment" {
|
||||
run python3 "$PLANNER" --target dankclaw --paths \
|
||||
docs/Architecture/Deployment\ Flow.md docs/Dankclaw/SearXNG.md
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
@test "deployment planner maps shared config to one service" {
|
||||
run python3 "$PLANNER" --target dankclaw --paths \
|
||||
shared/prometheus/prometheus.yml
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "prometheus" ]
|
||||
}
|
||||
|
||||
@test "deployment planner maps service-owned non-compose files" {
|
||||
run python3 "$PLANNER" --target dankclaw --paths dankclaw/hermes/config.yaml.example
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "hermes" ]
|
||||
|
||||
run python3 "$PLANNER" --target dankclaw --paths scripts/serve-frank-default-npu.sh scripts/frank-default-npu.env
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "frank-default-npu" ]
|
||||
}
|
||||
|
||||
@test "homepage is owned by Dankclaw's consolidated proxy" {
|
||||
run python3 "$PLANNER" --target dankserver --paths shared/homepage/index.html
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
@test "compose planner narrows a compose edit to the changed service" {
|
||||
local base head
|
||||
base=$(git -C "$REPO_ROOT" rev-parse HEAD)
|
||||
head=$(git -C "$REPO_ROOT" rev-parse HEAD)
|
||||
run python3 "$PLANNER" --target dankclaw --repo "$REPO_ROOT" \
|
||||
--base "$base" --head "$head" --paths dankclaw/compose.yaml
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
@test "network-coupled SearXNG config changes include the required peers" {
|
||||
run python3 "$PLANNER" --target dankclaw --paths \
|
||||
dankclaw/searxng/quadlet/gluetun.container
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = $'gluetun\nsearxng' ]
|
||||
}
|
||||
|
||||
@test "remote deployer carries per-service deployment state" {
|
||||
[ -x "$REMOTE_DEPLOY" ]
|
||||
grep -q 'deploy-state' "$REMOTE_DEPLOY"
|
||||
grep -q 'STATE\[' "$REMOTE_DEPLOY"
|
||||
grep -q 'mark_status.*pending\|status=pending' "$REMOTE_DEPLOY"
|
||||
grep -q 'mark_status.*deployed\|status=deployed' "$REMOTE_DEPLOY"
|
||||
}
|
||||
|
||||
@test "remote deployer routes inference restarts through selected handlers" {
|
||||
# The runner may restart inference only from the service-dispatch path. It
|
||||
# must not contain a host-wide restart loop.
|
||||
grep -q 'deploy_llm_large' "$REMOTE_DEPLOY"
|
||||
grep -q 'deploy_llm_small' "$REMOTE_DEPLOY"
|
||||
grep -q 'deploy_service' "$REMOTE_DEPLOY"
|
||||
! grep -qE 'for .*frank-default-(small|large)' "$REMOTE_DEPLOY"
|
||||
}
|
||||
|
||||
@test "remote runner rejects services belonging to the other host" {
|
||||
run env DEPLOY_STATE_DIR="$(mktemp -d)" REMOTE_DIR="$REPO_ROOT" \
|
||||
bash "$REMOTE_DEPLOY" dankserver "$(git rev-parse HEAD)" frank-default-large
|
||||
[ "$status" -eq 2 ]
|
||||
}
|
||||
|
||||
@test "top-level deployer delegates to the service-scoped remote runner" {
|
||||
grep -q 'deploy-remote.sh' "$REPO_ROOT/scripts/deploy.sh"
|
||||
grep -qE 'SERVICE|service' "$REPO_ROOT/scripts/deploy.sh"
|
||||
grep -q 'OLD_REVISION' "$REPO_ROOT/scripts/deploy.sh"
|
||||
grep -q 'OLD_REVISION\|old checkout revision' "$REPO_ROOT/scripts/deploy.sh"
|
||||
}
|
||||
|
||||
@test "legacy service deploy wrappers delegate to the scoped deployer" {
|
||||
for script in deploy-searxng-quadlet.sh deploy-pihole-quadlet.sh deploy-open-webui-quadlet.sh; do
|
||||
grep -q 'deploy.sh' "$REPO_ROOT/scripts/$script" || {
|
||||
echo "$script does not delegate to deploy.sh"
|
||||
return 1
|
||||
}
|
||||
done
|
||||
}
|
||||
@@ -10,10 +10,12 @@ load helpers
|
||||
|
||||
@test "deploy.sh ships the homepage (assets, timer, basicauth hash, deployed.js)" {
|
||||
local d="$REPO_ROOT/scripts/deploy.sh"
|
||||
grep -q "/srv/homepage" "$d"
|
||||
grep -q "homepage-status" "$d"
|
||||
grep -q "CADDY_BASICAUTH_HASH" "$d"
|
||||
grep -q "deployed.js" "$d"
|
||||
local r="$REPO_ROOT/scripts/deploy-remote.sh"
|
||||
grep -q "deploy-remote.sh" "$d"
|
||||
grep -q "/srv/homepage" "$r"
|
||||
grep -q "homepage-status" "$r"
|
||||
grep -q "CADDY_BASICAUTH_HASH" "$r"
|
||||
grep -q "deployed.js" "$r"
|
||||
}
|
||||
|
||||
@test "the checker runs as a systemd timer pointing at the checker" {
|
||||
@@ -27,5 +29,5 @@ load helpers
|
||||
}
|
||||
|
||||
@test "deploy.sh is git-driven: it pulls main on the target" {
|
||||
grep -q "git pull origin main" "$REPO_ROOT/scripts/deploy.sh"
|
||||
grep -q "git pull --ff-only origin main" "$REPO_ROOT/scripts/deploy.sh"
|
||||
}
|
||||
@@ -90,8 +90,9 @@ PYEOF
|
||||
}
|
||||
|
||||
@test "deploy.sh copies limiter.toml into /opt/searxng/config" {
|
||||
grep -q "shared/searxng/limiter.toml" "$REPO_ROOT/scripts/deploy.sh" \
|
||||
|| { echo "deploy.sh does not copy shared/searxng/limiter.toml"; return 1; }
|
||||
grep -q "/opt/searxng/config/limiter.toml" "$REPO_ROOT/scripts/deploy.sh" \
|
||||
|| { echo "deploy.sh does not place limiter.toml at /opt/searxng/config"; return 1; }
|
||||
local d="$REPO_ROOT/scripts/deploy-remote.sh"
|
||||
grep -q "shared/searxng/limiter.toml" "$d" \
|
||||
|| { echo "deploy-remote.sh does not copy shared/searxng/limiter.toml"; return 1; }
|
||||
grep -q "/opt/searxng/config/limiter.toml" "$d" \
|
||||
|| { echo "deploy-remote.sh does not place limiter.toml at /opt/searxng/config"; return 1; }
|
||||
}
|
||||
Reference in new issue
Block a user