feat(frank-tts): deploy wiring for the 7772 voice endpoint, and voice naming written down
Stage 2 of the voice stack. Repo state only: nothing deployed, no host touched, no container started. - `frank-tts` is now registered everywhere a dankclaw service has to be: `SERVICE_ORDER` (after `frank-tts-engine`, before the consumers), the planner's dankclaw set, `deploy-remote.sh`'s allowlist + dispatch + a `deploy_frank_tts` handler, and `deploy.sh`'s dispatch regex — plus the `build_compose_image` arm naming `dankclaw/voiceservice/Containerfile`, without which the build silently returns 0 and the unit's `Pull=never` tag never exists. - The unit joins TWO networks, in order: `dankclaw_default` first (default route, where consumers and open-notebook live), then the voice bridge. `TTS_UPSTREAM` moves from `host.containers.internal:8880` to `http://frank-tts-engine:8880` — the actual fix for a shape that could never work, since a bridge container cannot reach a host-loopback bind and the engine publishes nothing at all. - `Wants=`/`After=` on the engine, never `Requires=`: the `llama-tts-cli` rollback backend needs no upstream, so a broken engine may delay this endpoint but must not take its unit down. - The handler installs the engine's quadlet pair too, because a referenced `Network=` file the generator cannot find means no façade unit is produced at all, and `--only frank-tts` has no other way to get it. It creates `/home/dank/voices` as the deploy user (rootless podman would auto-create a missing bind source root-owned, which reads as "no voices"), and gates on podman's recorded health. - Voice naming is now written down where it can be found: a voice is a lowercase directory name matching `^[a-z0-9][a-z0-9._-]*$`, at most 64 chars. That shape is the intersection of two rules that disagree — the shim would accept mixed case, the engine lowercases names — so a mixed-case pack resolves on one side only, and the boot warmer refuses it loudly. `narrator1/2/3` are load-bearing for open-notebook. - Homepage card for the pair, `wip: down` and hidden, probing `127.0.0.1:7772`; the engine gets no card because it publishes nothing and could never be probed. - **No Prometheus job, deliberately.** No `/metrics` exists on the shim, this Prometheus 3 refuses HTML/JSON bodies (the existing `comfyui` and `services` probes are documented down for exactly that), and `LlmEndpointDown` is scoped `up{job=~"frank-.*"}` — so a `/health` job would page on Signal for a healthy endpoint. Recorded in the page instead of quietly skipped. - Vault: new [[Frank TTS]] page linked from the index; [[Frank TTS Engine]] now points at it for the pair's public signal; log entry for the day. - Seam grows to 25 tests: the generator's rendering of the pair (both attachments, first-network order, one publish, Wants-not-Requires, bounded per-unit blocks), the env file's upstream checked as a network JOIN rather than a string, planner separation and order, the handler's steps, the card's gating, routing that selects both seams for either deployable.
This commit is contained in:
+167
-8
@@ -20,6 +20,10 @@ load helpers
|
||||
|
||||
UNIT="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts-engine.container"
|
||||
NETF="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts-engine.network"
|
||||
# The façade this engine serves: its wiring is tested here because the bridge
|
||||
# both units attach to is what makes the pair one deployable shape.
|
||||
FACADE="$REPO_ROOT/dankclaw/voiceservice/quadlet/frank-tts.container"
|
||||
FACADE_ENV="$REPO_ROOT/scripts/frank-tts.env"
|
||||
CF="$REPO_ROOT/dankclaw/voiceservice/Containerfile.engine"
|
||||
START="$REPO_ROOT/dankclaw/voiceservice/tts-engine-start.sh"
|
||||
ENVF="$REPO_ROOT/scripts/frank-tts-engine.env"
|
||||
@@ -184,19 +188,34 @@ quadlet_generator() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# render_units — run the generator in a throwaway HOME over a copy of the two
|
||||
# source files and print everything it emits. --dryrun prints instead of
|
||||
# writing, so this neither contacts the podman daemon nor creates a network.
|
||||
# render_units — run the generator in a throwaway HOME over a copy of the voice
|
||||
# stack's source files and print everything it emits. Both units are rendered
|
||||
# together on purpose: the façade references the engine's .network, and a
|
||||
# referenced file that is not in the directory makes the generator drop that
|
||||
# container unit outright — which is the failure this seam must be able to see.
|
||||
# --dryrun prints instead of writing, so this neither contacts the podman daemon
|
||||
# nor creates a network.
|
||||
render_units() {
|
||||
local gen home
|
||||
gen="$(quadlet_generator)" || { echo "no Quadlet generator on this box"; return 1; }
|
||||
home="$TEST_TMP/fakehome"
|
||||
mkdir -p "$home/.config/containers/systemd"
|
||||
cp "$NETF" "$UNIT" "$home/.config/containers/systemd/"
|
||||
cp "$NETF" "$UNIT" "$FACADE" "$home/.config/containers/systemd/"
|
||||
HOME="$home" "$gen" --user --dryrun > "$TEST_TMP/generated.txt" 2> "$TEST_TMP/generated.err"
|
||||
cat "$TEST_TMP/generated.txt"
|
||||
}
|
||||
|
||||
# unit_block <name> — one unit's block from the last render_units output. The
|
||||
# end of the block is the next `---` marker: two units are rendered now, and a
|
||||
# slice that ran to the end of the output would read the façade's RestartSec
|
||||
# into the engine's assertions, which is exactly how a generator test starts
|
||||
# asserting the wrong container.
|
||||
unit_block() {
|
||||
awk -v u="$1" 'index($0, "---" u "---") == 1 { f = 1; next }
|
||||
/^---/ { f = 0 }
|
||||
f' "$TEST_TMP/generated.txt"
|
||||
}
|
||||
|
||||
# free_port — an address with nothing bound to it, for the tests that need the
|
||||
# warmer to be dialing before a listener exists.
|
||||
free_port() {
|
||||
@@ -251,7 +270,7 @@ s.close()'
|
||||
# Boot persistence and self-healing, as the generator actually emits them —
|
||||
# and the warm hook: ExecStartPost must carry the `-` prefix, because
|
||||
# Restart=always plus a failing warmer is a crash loop on the endpoint.
|
||||
unit="$(printf '%s\n' "$rendered" | sed -n '/^---frank-tts-engine.service---$/,$p')"
|
||||
unit="$(unit_block frank-tts-engine.service)"
|
||||
printf '%s' "$unit" | grep -qE '^Restart=always$'
|
||||
restart_sec="$(printf '%s' "$unit" | sed -nE 's/^RestartSec=([0-9]+)s?$/\1/p')"
|
||||
[ -n "$restart_sec" ] && [ "$restart_sec" -le 30 ]
|
||||
@@ -266,12 +285,12 @@ s.close()'
|
||||
|
||||
@test "the generator renders the bridge creation command with the pinned subnet" {
|
||||
rendered="$(render_units)" || { echo "$rendered"; skip "no Quadlet generator here"; }
|
||||
net="$(printf '%s\n' "$rendered" | sed -n '/^---frank-tts-engine-network.service---$/,$p')"
|
||||
net="$(unit_block frank-tts-engine-network.service)"
|
||||
printf '%s' "$net" | grep -qE '^ExecStart=/usr/bin/podman network create --ignore --driver bridge --subnet 10\.89\.5\.0/24 --gateway 10\.89\.5\.1 systemd-frank-tts-engine$'
|
||||
# The container unit must be tied to the network unit, not merely mention it:
|
||||
# a referenced .network that the generator cannot find kills the container
|
||||
# unit outright, and an untied one starts the container before the bridge.
|
||||
unit="$(printf '%s\n' "$rendered" | sed -n '/^---frank-tts-engine.service---$/,$p')"
|
||||
unit="$(unit_block frank-tts-engine.service)"
|
||||
printf '%s' "$unit" | grep -qE '^Requires=frank-tts-engine-network\.service$'
|
||||
printf '%s' "$unit" | grep -qE '^After=frank-tts-engine-network\.service$'
|
||||
}
|
||||
@@ -562,9 +581,11 @@ PY
|
||||
}
|
||||
|
||||
@test "the planner maps every engine path to frank-tts-engine, and nothing to dankserver" {
|
||||
# Everything named after the engine, EXCEPT the bridge file: both units
|
||||
# attach to it, so a subnet change is a change to two deployables and that
|
||||
# pairing is asserted in the façade's planner test below.
|
||||
run python3 "$PLANNER" --target dankclaw --paths \
|
||||
dankclaw/voiceservice/quadlet/frank-tts-engine.container \
|
||||
dankclaw/voiceservice/quadlet/frank-tts-engine.network \
|
||||
dankclaw/voiceservice/Containerfile.engine \
|
||||
dankclaw/voiceservice/tts-engine-start.sh \
|
||||
scripts/frank-tts-engine.env \
|
||||
@@ -654,3 +675,141 @@ PY
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
@test "the generator puts the façade on both bridges and behind the engine" {
|
||||
rendered="$(render_units)" || { echo "$rendered"; skip "no Quadlet generator here"; }
|
||||
line="$(printf '%s\n' "$rendered" | grep -E '^ExecStart=/usr/bin/podman run .*--name frank-tts ')"
|
||||
[ -n "$line" ] || { printf '%s\n' "$rendered"; false; }
|
||||
|
||||
# Two attachments, and the fleet-wide bridge FIRST: that is the network
|
||||
# consumers address (open-notebook dials `http://frank-tts:7772/v1` by
|
||||
# container name), so it owns the default route. The engine's private bridge
|
||||
# is second and exists so the shim can resolve the engine at all.
|
||||
[ "$(printf '%s' "$line" | grep -oE -- '--network [^ ]+' | head -1)" = "--network dankclaw_default" ]
|
||||
printf '%s' "$line" | grep -qE -- '--network systemd-frank-tts-engine( |$)'
|
||||
# Published exactly once, on the fleet port — and this is the only publish in
|
||||
# the voice stack.
|
||||
[ "$(printf '%s' "$line" | grep -oE -- '--publish [^ ]+' | wc -l)" = "1" ]
|
||||
printf '%s' "$line" | grep -qE -- '--publish (\[::\]:)?7772:7772( |$)'
|
||||
|
||||
unit="$(unit_block frank-tts.service)"
|
||||
printf '%s' "$unit" | grep -qE '^Wants=frank-tts-engine\.service$'
|
||||
printf '%s' "$unit" | grep -qE '^After=.*frank-tts-engine\.service'
|
||||
# Wants, never Requires: the CLI rollback backend needs no upstream, and an
|
||||
# engine that will not start must not take this endpoint's unit down.
|
||||
! printf '%s' "$unit" | grep -qE '^Requires=frank-tts-engine\.service$'
|
||||
# And it is tied to the bridge as well, or the container would be started
|
||||
# before netavark had created the network it dials over.
|
||||
printf '%s' "$unit" | grep -qE '^Requires=frank-tts-engine-network\.service$'
|
||||
}
|
||||
|
||||
@test "the upstream the env file names is only resolvable on a network the unit joins" {
|
||||
# The pair's one real wiring contract, checked as a join rather than as a
|
||||
# string: take the hostname out of TTS_UPSTREAM the way the container will
|
||||
# get it, and require the façade unit to attach to the Quadlet network file
|
||||
# named after that hostname. A revert to host.containers.internal, or a unit
|
||||
# that drops the second Network= line, breaks here — both mistakes leave the
|
||||
# endpoint answering and every synthesis failing.
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$FACADE_ENV"
|
||||
set +a
|
||||
host="${TTS_UPSTREAM#*://}"
|
||||
host="${host%%:*}"
|
||||
host="${host%%/*}"
|
||||
[ -n "$host" ] || { echo "TTS_UPSTREAM names no host: $TTS_UPSTREAM"; false; }
|
||||
[ "$host" != localhost ] || { echo "localhost is not reachable across netns"; false; }
|
||||
if ! grep -qE "^Network=${host}\.network$" "$FACADE"; then
|
||||
echo "frank-tts.container does not attach to ${host}.network"
|
||||
false
|
||||
fi
|
||||
if ! [ -f "$REPO_ROOT/dankclaw/voiceservice/quadlet/${host}.network" ]; then
|
||||
echo "no Quadlet network file for $host — the generator would drop the unit"
|
||||
false
|
||||
fi
|
||||
}
|
||||
|
||||
@test "the planner keeps the two voice deployables separate and ordered" {
|
||||
# `--only` on the façade must not drag the engine's rebuild along, and a
|
||||
# bridge change must restart both: the network file is named after the engine
|
||||
# unit only because install_quadlet copies `$unit.network` beside the unit.
|
||||
run python3 "$PLANNER" --target dankclaw --paths dankclaw/voiceservice/app.py
|
||||
[ "$output" = "frank-tts" ]
|
||||
run python3 "$PLANNER" --target dankclaw --paths dankclaw/voiceservice/Containerfile
|
||||
[ "$output" = "frank-tts" ]
|
||||
run python3 "$PLANNER" --target dankclaw --paths \
|
||||
dankclaw/voiceservice/quadlet/frank-tts.container
|
||||
[ "$output" = "frank-tts" ]
|
||||
run python3 "$PLANNER" --target dankclaw --paths \
|
||||
dankclaw/voiceservice/quadlet/frank-tts-engine.container
|
||||
[ "$output" = "frank-tts-engine" ]
|
||||
run python3 "$PLANNER" --target dankclaw --paths \
|
||||
dankclaw/voiceservice/quadlet/frank-tts-engine.network
|
||||
printf '%s\n' "$output" | grep -qx frank-tts
|
||||
printf '%s\n' "$output" | grep -qx frank-tts-engine
|
||||
|
||||
# Order: dependency before consumer, so a full dankclaw deploy starts a warm
|
||||
# engine and then the endpoint that forwards into it.
|
||||
run python3 "$PLANNER" --target dankclaw --paths \
|
||||
dankclaw/voiceservice/quadlet/frank-tts.container \
|
||||
dankclaw/voiceservice/quadlet/frank-tts-engine.container
|
||||
local e f
|
||||
e="$(printf '%s\n' "$output" | grep -n '^frank-tts-engine$' | cut -d: -f1)"
|
||||
f="$(printf '%s\n' "$output" | grep -n '^frank-tts$' | cut -d: -f1)"
|
||||
[ -n "$e" ] && [ -n "$f" ] && [ "$e" -lt "$f" ]
|
||||
}
|
||||
|
||||
@test "the façade handler builds its image, installs the bridge file, and gates on recorded health" {
|
||||
grep -qF 'dankclaw:frank-tts|' "$REMOTE"
|
||||
grep -qF 'dankclaw:frank-tts|' "$REPO_ROOT/scripts/deploy.sh"
|
||||
grep -qF 'frank-tts) deploy_frank_tts ;;' "$REMOTE"
|
||||
# build_compose_image's default arm returns 0 without building: an unlisted
|
||||
# service means a unit whose Pull=never image was never made.
|
||||
grep -qE '^\s+containerfile=dankclaw/voiceservice/Containerfile$' "$REMOTE"
|
||||
|
||||
handler="$(sed -n '/^deploy_frank_tts()/,/^}/p' "$REMOTE")"
|
||||
[ -n "$handler" ] || { echo "no deploy_frank_tts handler"; false; }
|
||||
printf '%s' "$handler" | grep -qF 'build_compose_image frank-tts'
|
||||
printf '%s' "$handler" | grep -qF 'install_quadlet frank-tts dankclaw/voiceservice/quadlet frank-tts'
|
||||
# The engine's quadlet pair is installed by this handler too: with the
|
||||
# referenced .network file missing the generator produces no façade unit at
|
||||
# all, and `--only frank-tts` has no other way to get it onto the host.
|
||||
printf '%s' "$handler" | grep -qF 'install_quadlet frank-tts-engine dankclaw/voiceservice/quadlet frank-tts-engine'
|
||||
printf '%s' "$handler" | grep -qE 'mkdir -p /home/dank/voices'
|
||||
printf '%s' "$handler" | grep -qE 'systemctl --user restart frank-tts\.service'
|
||||
printf '%s' "$handler" | grep -qE 'wait_container_healthy frank-tts [0-9]+'
|
||||
! printf '%s' "$handler" | grep -qE '^[[:space:]]*systemctl --user enable'
|
||||
}
|
||||
|
||||
@test "the voice stack's homepage state: one card for the pair, and it is hidden while undeployed" {
|
||||
# The engine is not on the homepage and must not be: it publishes no port, so
|
||||
# there is no address the checker could dial, and a card that can never be
|
||||
# green is worse than no card. The façade carries the pair's signal.
|
||||
python3 - "$REPO_ROOT" <<'PY'
|
||||
import json, os, sys
|
||||
root = sys.argv[1]
|
||||
cards = {c["name"]: c for c in json.load(open(f"{root}/shared/homepage/services.json"))}
|
||||
assert "frank-tts-engine" not in cards, \
|
||||
"the engine publishes no port: a card for it could never be probed green"
|
||||
fts = cards["frank-tts"]
|
||||
assert fts["probe"] == "127.0.0.1:7772", fts["probe"]
|
||||
assert fts["wip"] == "down" and fts["showLink"] is False, \
|
||||
"nothing listens on 7772 until the deploy, so a visible card reads as down forever"
|
||||
assert os.path.exists(f"{root}/shared/homepage{fts['art']}"), f"missing art {fts['art']}"
|
||||
PY
|
||||
}
|
||||
|
||||
@test "either deployable's paths select both voice seams" {
|
||||
# A change to the bridge or the env file can break the other half of the
|
||||
# pair, and the seams are cheap; routing them together is what keeps a
|
||||
# façade-only commit from landing without the engine's wiring tests.
|
||||
run "$REPO_ROOT/scripts/test-changed.sh" --list --paths \
|
||||
dankclaw/voiceservice/quadlet/frank-tts.container
|
||||
[ "$status" -eq 0 ]
|
||||
printf '%s\n' "$output" | grep -qx "tests/frank_tts_unit.bats"
|
||||
printf '%s\n' "$output" | grep -qx "tests/frank_tts_engine.bats"
|
||||
|
||||
run "$REPO_ROOT/scripts/test-changed.sh" --list --paths dankclaw/voiceservice/app.py
|
||||
[ "$status" -eq 0 ]
|
||||
printf '%s\n' "$output" | grep -qx "tests/frank_tts_unit.bats"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user