#!/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 serverAppConfigs-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 \ dankclaw/frank-large/quadlet/frank-large.container [ "$status" -eq 0 ] [ "$output" = "frank-large" ] } @test "the retired NPU endpoint is absent from the deployment inventory" { ! grep -q '"frank-npu"' "$PLANNER" ! grep -q 'frank_npu\|frank-npu' "$REMOTE_DEPLOY" } @test "shared chat-template changes map only to the service that reads it" { # frank-large runs halogen-flash-server, which owns its own chat handling; # frank-small is the only consumer of the tracked template now, so a # template edit is a no-op on Dankclaw. run python3 "$PLANNER" --target dankclaw --paths scripts/chat-template.jinja [ "$status" -eq 0 ] [ -z "$output" ] run python3 "$PLANNER" --target dankserver --paths scripts/chat-template.jinja [ "$status" -eq 0 ] [ "$output" = "frank-small" ] } @test "deployment planner maps small-model changes to the small model only" { run python3 "$PLANNER" --target dankserver --paths \ scripts/frank-small.service [ "$status" -eq 0 ] [ "$output" = "frank-small" ] } @test "documentation-only changes plan no runtime deployment" { run python3 "$PLANNER" --target dankclaw --paths \ serverAppConfigs-docs/Architecture/Deployment\ Flow.md serverAppConfigs-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 shared observability configs independently" { run python3 "$PLANNER" --target dankclaw --paths shared/loki/loki-config.yaml [ "$status" -eq 0 ] [ "$output" = "loki" ] run python3 "$PLANNER" --target dankclaw --paths shared/alloy/config.alloy [ "$status" -eq 0 ] [ "$output" = "alloy" ] } @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" ] } @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\nmarreta' ] } @test "retiring the standalone Gitea proxy deploys the canonical Caddy" { run python3 "$PLANNER" --target dankclaw --paths \ dankclaw/gitea/quadlet/gitea-caddy.container [ "$status" -eq 0 ] [ "$output" = "caddy" ] } @test "Gitea network changes exclude the retired standalone proxy" { run python3 "$PLANNER" --target dankclaw --paths \ dankclaw/gitea/quadlet/gitea.network [ "$status" -eq 0 ] [ "$output" = $'gitea-postgres\ngitea-server' ] } @test "frankbook-pro gluetun changes carry the whole netns family; arr changes stay scoped" { run python3 "$PLANNER" --target frankbook-pro --paths \ frankbook-pro/gluetun/quadlet/gluetun.container [ "$status" -eq 0 ] [ "$output" = $'gluetun\nprowlarr\nqbittorrent\nradarr\nsonarr\nlidarr\nreadarr' ] run python3 "$PLANNER" --target frankbook-pro --paths \ frankbook-pro/sonarr/quadlet/sonarr.container [ "$status" -eq 0 ] [ "$output" = "sonarr" ] } @test "remote Caddy deployment no longer blocks on the retired Gitea proxy" { ! grep -q 'refusing to replace it with caddy' "$REMOTE_DEPLOY" ! grep -q 'podman container exists gitea-caddy &&' "$REMOTE_DEPLOY" } @test "remote Caddy deployment retires the installed Gitea proxy" { grep -q 'retire_legacy_gitea_caddy' "$REMOTE_DEPLOY" grep -q 'systemctl --user stop gitea-caddy.service' "$REMOTE_DEPLOY" grep -q 'systemctl --user disable gitea-caddy.service' "$REMOTE_DEPLOY" grep -q 'gitea-caddy.container' "$REMOTE_DEPLOY" grep -q 'HOME/.AGENTTRASH' "$REMOTE_DEPLOY" grep -q 'stage_legacy_gitea_caddy_state' "$REMOTE_DEPLOY" } @test "remote deployment inventory no longer accepts gitea-caddy as a service" { ! grep -qE 'dankclaw:gitea-caddy|gitea-caddy\) deploy' "$REPO_ROOT/scripts/deploy.sh" "$REPO_ROOT/scripts/deploy-remote.sh" ! grep -q '"gitea-caddy"' "$REPO_ROOT/scripts/deploy-plan.py" } @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-(small|large|npu)-?' "$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 "remote runner recognizes a pending service at the same revision" { local state_dir="$(mktemp -d)" printf 'revision=%s\nstatus=pending\n' "$(git rev-parse HEAD)" > "$state_dir/unknown-service" run env DEPLOY_STATE_DIR="$state_dir" REMOTE_DIR="$REPO_ROOT" \ bash "$REMOTE_DEPLOY" dankclaw "$(git rev-parse HEAD)" --pending [ "$status" -eq 2 ] [[ "$output" == *"unknown-service"* || "$output" == *"not deployed"* ]] } @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 }