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.
537 lines
22 KiB
Python
Executable File
537 lines
22 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Plan runtime services affected by repository changes.
|
|
|
|
The planner is intentionally side-effect free. It accepts either an explicit
|
|
path list or two Git revisions. The latter lets compose-file changes resolve
|
|
to only the service definitions that actually changed instead of restarting an
|
|
entire compose project.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import subprocess
|
|
import sys
|
|
from pathlib import PurePosixPath
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
# Dependency-sensitive services come before their consumers. Independent
|
|
# compose services follow. LLM services are deliberately last and are never
|
|
# implied by a generic host, stack, documentation, or config change.
|
|
SERVICE_ORDER = (
|
|
"gluetun",
|
|
"searxng-valkey",
|
|
"searxng",
|
|
"marreta",
|
|
"pihole",
|
|
"open-webui",
|
|
"open-notebook",
|
|
"gitea-postgres",
|
|
"gitea-server",
|
|
"comfyui",
|
|
"seerr",
|
|
"hermes",
|
|
"hermes-dashboard",
|
|
"hermes-webui",
|
|
"prometheus",
|
|
"grafana",
|
|
"loki",
|
|
"alloy",
|
|
"node-exporter",
|
|
"alertmanager",
|
|
"authentik-postgres",
|
|
"authentik-server",
|
|
"authentik-worker",
|
|
"authentik-outpost",
|
|
"authentik-outpost-ldap",
|
|
"caddy",
|
|
"homepage",
|
|
"romm-mariadb",
|
|
"romm-redis",
|
|
"romm",
|
|
"copyparty",
|
|
"jellyfin",
|
|
"calibre-web",
|
|
"calibre-server",
|
|
"plex",
|
|
"tautulli",
|
|
"prowlarr",
|
|
"qbittorrent",
|
|
"radarr",
|
|
"sonarr",
|
|
"lidarr",
|
|
"readarr",
|
|
# The voice pair keeps the inference family's ordering rule: the engine is
|
|
# a dependency of the 7772 facade, so it is planned (and therefore
|
|
# restarted) first.
|
|
"frank-tts-engine",
|
|
"frank-tts",
|
|
"frank-dragoon",
|
|
"frank-zealot",
|
|
"frank-archon",
|
|
)
|
|
|
|
DANKCLAW_SERVICES = {
|
|
"hermes-webui",
|
|
"gluetun",
|
|
"searxng-valkey",
|
|
"searxng",
|
|
"marreta",
|
|
"pihole",
|
|
"open-webui",
|
|
"open-notebook",
|
|
"gitea-postgres",
|
|
"gitea-server",
|
|
"comfyui",
|
|
"seerr",
|
|
"hermes",
|
|
"hermes-dashboard",
|
|
"prometheus",
|
|
"grafana",
|
|
"loki",
|
|
"alloy",
|
|
"node-exporter",
|
|
"alertmanager",
|
|
"authentik-postgres",
|
|
"authentik-server",
|
|
"authentik-worker",
|
|
"authentik-outpost",
|
|
"authentik-outpost-ldap",
|
|
"caddy",
|
|
"homepage",
|
|
"frank-dragoon",
|
|
"frank-zealot",
|
|
"frank-tts-engine",
|
|
"frank-tts",
|
|
}
|
|
|
|
DANKSERVER_SERVICES = {
|
|
"romm-mariadb",
|
|
"romm-redis",
|
|
"romm",
|
|
"copyparty",
|
|
"jellyfin",
|
|
"calibre-web",
|
|
"calibre-server",
|
|
"plex",
|
|
"tautulli",
|
|
"prowlarr",
|
|
"qbittorrent",
|
|
"radarr",
|
|
"sonarr",
|
|
"lidarr",
|
|
"readarr",
|
|
"frank-archon",
|
|
"node-exporter",
|
|
}
|
|
|
|
# FrankBook-Pro runs the *arr stack + qBittorrent inside a podman machine on
|
|
# macOS. Service names overlap the Linux hosts (gluetun, qbittorrent, ...);
|
|
# the per-target sets and the frankbook-pro intercept in deploy-remote.sh
|
|
# keep dispatch unambiguous.
|
|
FRANKBOOK_SERVICES = {
|
|
"gluetun",
|
|
"qbittorrent",
|
|
"prowlarr",
|
|
"radarr",
|
|
"sonarr",
|
|
"lidarr",
|
|
"readarr",
|
|
}
|
|
|
|
DANKCLAW_COMPOSE_SERVICES = set()
|
|
# Dankserver's compose file is still the source of truth for its media stack;
|
|
# Dankclaw's is gone (deleted 2026-09-16). Every service on Dankclaw is a
|
|
# Quadlet unit now — the last compose services were the hermes trio, whose units
|
|
# have owned their containers since 2026-09-12 and whose compose blocks were
|
|
# dead weight that could only create a duplicate container.
|
|
|
|
DANKSERVER_COMPOSE_SERVICES = {
|
|
"mariadb",
|
|
"redis",
|
|
"romm",
|
|
"copyparty",
|
|
"jellyfin",
|
|
"calibre-web",
|
|
}
|
|
COMPOSE_NAME_MAP = {"mariadb": "romm-mariadb", "redis": "romm-redis"}
|
|
|
|
|
|
|
|
def _add(services: set[str], *names: str) -> None:
|
|
services.update(names)
|
|
|
|
|
|
def target_services(target: str) -> set[str]:
|
|
if target == "dankclaw":
|
|
return DANKCLAW_SERVICES
|
|
if target == "dankserver":
|
|
return DANKSERVER_SERVICES
|
|
if target == "frankbook-pro":
|
|
return FRANKBOOK_SERVICES
|
|
raise ValueError(f"unknown target: {target}")
|
|
|
|
|
|
def _compose_services(target: str) -> set[str]:
|
|
return DANKCLAW_COMPOSE_SERVICES if target == "dankclaw" else DANKSERVER_COMPOSE_SERVICES
|
|
|
|
|
|
def _git_show(repo: str, revision: str, path: str) -> str | None:
|
|
result = subprocess.run(
|
|
["git", "-C", repo, "show", f"{revision}:{path}"],
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
return result.stdout if result.returncode == 0 else None
|
|
|
|
|
|
def _yaml_document(contents: str | None) -> dict[str, Any]:
|
|
if contents is None:
|
|
return {}
|
|
try:
|
|
data = yaml.safe_load(contents) or {}
|
|
except yaml.YAMLError:
|
|
return {}
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
|
|
def _yaml_services(contents: str | None) -> dict[str, Any]:
|
|
services = _yaml_document(contents).get("services", {})
|
|
return services if isinstance(services, dict) else {}
|
|
|
|
|
|
def compose_changes(repo: str, target: str, base: str, head: str) -> set[str]:
|
|
"""Return only compose services whose YAML definitions or dependencies changed.
|
|
|
|
A target with no compose file (Dankclaw since 2026-09-16) plans nothing:
|
|
its services are Quadlet units, selected by their own paths.
|
|
"""
|
|
path = f"{target}/compose.yaml"
|
|
if not _git_show(repo, head, path) and not _git_show(repo, base, path):
|
|
return set()
|
|
old_doc = _yaml_document(_git_show(repo, base, path))
|
|
new_doc = _yaml_document(_git_show(repo, head, path))
|
|
old = old_doc.get("services", {}) if isinstance(old_doc.get("services", {}), dict) else {}
|
|
new = new_doc.get("services", {}) if isinstance(new_doc.get("services", {}), dict) else {}
|
|
names = set(old) | set(new)
|
|
allowed = _compose_services(target)
|
|
|
|
# An unavailable or malformed revision is safer as a broad compose plan
|
|
# than as a silent no-op. It still cannot include LLM units.
|
|
if (old_doc and not new_doc) or (new_doc and not old_doc and names):
|
|
return {COMPOSE_NAME_MAP.get(n, n) for n in allowed}
|
|
|
|
# Top-level networks/configuration can affect every compose service even
|
|
# when no individual service mapping changed. Ignore the services key while
|
|
# comparing the rest of the Compose document.
|
|
old_globals = {key: value for key, value in old_doc.items() if key != "services"}
|
|
new_globals = {key: value for key, value in new_doc.items() if key != "services"}
|
|
if old_globals != new_globals:
|
|
return {COMPOSE_NAME_MAP.get(n, n) for n in allowed}
|
|
|
|
changed = {name for name in names if name in allowed and old.get(name) != new.get(name)}
|
|
return {COMPOSE_NAME_MAP.get(n, n) for n in changed}
|
|
|
|
|
|
def services_for_path(path: str, target: str) -> set[str]:
|
|
"""Return services affected by one repository-relative path."""
|
|
path = path.replace("\\", "/")
|
|
p = PurePosixPath(path)
|
|
parts = p.parts
|
|
services: set[str] = set()
|
|
if not parts:
|
|
return services
|
|
|
|
# Non-runtime material is deliberately a no-op.
|
|
if parts[0] in {"docs", "tests", "old", ".githooks"}:
|
|
return services
|
|
if path in {"README.md", "AGENTS.md"} or parts[0] == ".obsidian":
|
|
return services
|
|
|
|
if parts[0] == "shared":
|
|
if len(parts) < 2:
|
|
return services
|
|
group = parts[1]
|
|
if group == "searxng":
|
|
_add(services, "searxng")
|
|
elif group == "prometheus":
|
|
_add(services, "prometheus")
|
|
if len(parts) >= 3 and parts[2] == "alertmanager.yml":
|
|
# The Alertmanager config ships from this directory: a change
|
|
# to it must redeploy the service that installs it.
|
|
_add(services, "alertmanager")
|
|
elif group == "loki":
|
|
_add(services, "loki")
|
|
elif group == "alloy":
|
|
_add(services, "alloy")
|
|
elif group == "grafana":
|
|
# Provisioned datasources and dashboards: git is the source of
|
|
# truth, so a change here must restart the service that reads them.
|
|
_add(services, "grafana")
|
|
elif group == "homepage":
|
|
# Homepage is a Dankclaw-owned deployment. Shared homepage files
|
|
# are intentionally not copied during a Dankserver-only run.
|
|
if target == "dankclaw":
|
|
_add(services, "homepage")
|
|
return services
|
|
|
|
if parts[0] == "dankclaw":
|
|
if len(parts) >= 2 and parts[1] == "compose.yaml":
|
|
# Dankclaw has no compose file since 2026-09-16: every service is a
|
|
# Quadlet unit, each selected by its own path. A change (or a
|
|
# deletion) of this path therefore restarts nothing.
|
|
return services
|
|
if len(parts) >= 2 and parts[1] == "Caddyfile":
|
|
_add(services, "caddy")
|
|
return services
|
|
if len(parts) >= 2 and parts[1] == "Containerfile.comfyui":
|
|
_add(services, "comfyui")
|
|
return services
|
|
if len(parts) >= 2 and parts[1] in {"caddy", "comfyui", "loki", "alloy", "hermes", "anubis", "seerr"}:
|
|
_add(services, parts[1])
|
|
return services
|
|
if len(parts) >= 4 and parts[1] == "authentik" and parts[2] == "quadlet":
|
|
name = parts[3].removesuffix(".container")
|
|
if name in {"authentik-postgres", "authentik-server", "authentik-worker", "authentik-outpost", "authentik-outpost-ldap"}:
|
|
_add(services, name)
|
|
return services
|
|
if len(parts) >= 2 and parts[1] == "searxng":
|
|
if len(parts) >= 4 and parts[2] == "quadlet":
|
|
name = parts[3]
|
|
if name == "gluetun.container":
|
|
# SearXNG and Marreta (+ its browser companion) share
|
|
# Gluetun's network namespace. A Gluetun recreation
|
|
# tears the netns down, so every sharer must restart.
|
|
_add(services, "gluetun", "searxng", "marreta")
|
|
elif name == "searxng.container":
|
|
_add(services, "searxng")
|
|
elif name == "searxng-valkey.container":
|
|
_add(services, "searxng-valkey", "searxng")
|
|
elif name == "searxng.network":
|
|
_add(services, "gluetun", "searxng-valkey", "searxng", "marreta")
|
|
return services
|
|
if len(parts) >= 2 and parts[1] == "marreta":
|
|
# One deployable owns both the app and its browser companion;
|
|
# both live in gluetun's netns. A change to either unit selects
|
|
# the marreta service, which restarts browser-then-app.
|
|
_add(services, "marreta")
|
|
return services
|
|
if len(parts) >= 2 and parts[1] == "pihole":
|
|
_add(services, "pihole")
|
|
return services
|
|
if len(parts) >= 2 and parts[1] == "open-webui":
|
|
_add(services, "open-webui")
|
|
return services
|
|
if len(parts) >= 2 and parts[1] == "open-notebook":
|
|
# One deployable owns the app AND its SurrealDB, like the marreta
|
|
# app/companion pair: the app alone cannot write a note and the
|
|
# database alone is a volume nothing reads. Any change under the
|
|
# directory (unit, network, or the interim episode mount) therefore
|
|
# plans the pair, which deploy_open_notebook starts database-first.
|
|
_add(services, "open-notebook")
|
|
return services
|
|
if len(parts) >= 2 and parts[1] == "voiceservice":
|
|
# The voice stack's containers share one directory but are TWO
|
|
# deployables — the arm A engine and the 7772 façade in front of it
|
|
# — so this maps per file, the way the authentik trio does, and not
|
|
# per directory. Anything that is not named after one of the two
|
|
# services plans nothing, exactly as it did before the pair was
|
|
# registered.
|
|
if parts[-1] in {"frank-tts-engine.container", "frank-tts-engine.network",
|
|
"Containerfile.engine", "tts-engine-start.sh",
|
|
"tts-engine-warm.sh"}:
|
|
_add(services, "frank-tts-engine")
|
|
# The 7772 façade is the other deployable in this directory. The
|
|
# bridge file is named after the ENGINE unit (install_quadlet copies
|
|
# `$unit.network` beside `$unit.container`), yet both units attach
|
|
# to it, so a subnet change is a change to both. Anything else here
|
|
# belongs to neither deployable and plans nothing.
|
|
if parts[-1] in {"frank-tts.container", "app.py", "Containerfile"}:
|
|
_add(services, "frank-tts")
|
|
if parts[-1] == "frank-tts-engine.network":
|
|
_add(services, "frank-tts")
|
|
return services
|
|
# frank-dragoon left the quadlet shape on 2026-09-19 (CIRU v4.4.1 host
|
|
# build, root unit): its files live in scripts/ and map there.
|
|
if len(parts) >= 2 and parts[1] == "observability":
|
|
# The five observability containers became Quadlet units on
|
|
# 2026-09-11. A unit change selects exactly its own service;
|
|
# SERVICE_ORDER already puts each dependency ahead of its
|
|
# consumer (prometheus before grafana, loki before alloy).
|
|
if len(parts) >= 4 and parts[2] == "quadlet":
|
|
name = parts[3].removesuffix(".container")
|
|
if name in {"prometheus", "grafana", "loki", "alloy", "node-exporter", "alertmanager"}:
|
|
_add(services, name)
|
|
return services
|
|
if len(parts) >= 2 and parts[1] == "gitea":
|
|
if len(parts) >= 4 and parts[2] == "quadlet":
|
|
name = parts[3]
|
|
if name == "gitea-postgres.container":
|
|
_add(services, "gitea-postgres")
|
|
elif name == "gitea-server.container":
|
|
_add(services, "gitea-server")
|
|
elif name == "gitea-caddy.container":
|
|
# Removing the old split proxy requires the canonical
|
|
# Caddy handler to retire its installed unit/container
|
|
# before it claims ports 80/443.
|
|
_add(services, "caddy")
|
|
elif name == "gitea.network":
|
|
_add(services, "gitea-postgres", "gitea-server")
|
|
return services
|
|
return services
|
|
|
|
if parts[0] == "dankserver":
|
|
if len(parts) >= 2 and parts[1] == "compose.yaml":
|
|
# Compose service keys map onto the deployed unit names.
|
|
_add(services, *(COMPOSE_NAME_MAP.get(n, n) for n in DANKSERVER_COMPOSE_SERVICES))
|
|
return services
|
|
if len(parts) >= 3 and parts[2] == "quadlet":
|
|
quadlet_services = {
|
|
"romm": {"romm", "romm-mariadb", "romm-redis"},
|
|
"jellyfin": {"jellyfin"},
|
|
"calibre-web": {"calibre-web"},
|
|
"calibre-server": {"calibre-server"},
|
|
"copyparty": {"copyparty"},
|
|
"plex": {"plex"},
|
|
"tautulli": {"tautulli"},
|
|
}
|
|
_add(services, *quadlet_services.get(parts[1], set()))
|
|
if len(parts) >= 2 and parts[1] == "observability":
|
|
# Dankserver's node-exporter became a Quadlet unit on 2026-09-16
|
|
# (serverAppConfigs-docs/Infrastructure/Observability.md): its
|
|
# compose service died at a reboot and stayed dead, so Prometheus'
|
|
# node-dankserver job had nothing to scrape.
|
|
if len(parts) >= 4 and parts[2] == "quadlet":
|
|
name = parts[3].removesuffix(".container")
|
|
if name == "node-exporter":
|
|
_add(services, "node-exporter")
|
|
return services
|
|
# frank-archon's quadlet-era directory left the tree on 2026-09-26
|
|
# (the Lucebox DS4.1 unit was retired for the llama.cpp root unit —
|
|
# the trio in scripts/ now maps it). Nothing lives under
|
|
# dankserver/frank-archon/ except dated trash, and trash plans
|
|
# nothing (the retire rule above the service blocks).
|
|
return services
|
|
|
|
if parts[0] == "frankbook-pro":
|
|
if len(parts) >= 3 and parts[2] == "quadlet":
|
|
name = parts[3].removesuffix(".container")
|
|
if name == "gluetun":
|
|
# Every *arr + qBittorrent container shares gluetun's
|
|
# network namespace; a gluetun recreation kills all of
|
|
# them, so the plan restarts the whole netns family.
|
|
_add(services, "gluetun", "qbittorrent",
|
|
"prowlarr", "sonarr", "radarr", "lidarr", "readarr")
|
|
elif name in FRANKBOOK_SERVICES:
|
|
_add(services, name)
|
|
return services
|
|
|
|
if parts[0] == "scripts":
|
|
name = p.name
|
|
if name in {"homepage-status.py", "homepage-status.service", "homepage-status.timer"}:
|
|
_add(services, "homepage")
|
|
if name in {"signal-relay.py", "signal-relay.service"}:
|
|
# The Signal relay is installed by the alertmanager deploy, so a
|
|
# change to it redeploying alertmanager is the only path that
|
|
# reaches it on the host.
|
|
_add(services, "alertmanager")
|
|
if name in {"frank-zealot.env", "frank-zealot.service", "serve-frank-zealot.sh"}:
|
|
# llama.cpp frank-zealot (2026-09-16) keeps its unit, env and serve
|
|
# files here; each one selects frank-zealot only. The ik_llama.cpp
|
|
# trio that lived here for two days is in scripts/.AGENTTRASH/,
|
|
# which is archive material and plans nothing.
|
|
_add(services, "frank-zealot")
|
|
if name in {"frank-dragoon.env", "frank-dragoon.service", "serve-frank-dragoon.sh"}:
|
|
# CIRU v4.4.1 frank-dragoon (2026-09-19) keeps its unit, env and
|
|
# serve files here the same way: a root unit on a host-built
|
|
# runtime. The download script fetches the payload, which is
|
|
# host-side state, so it selects nothing.
|
|
_add(services, "frank-dragoon")
|
|
if name == "qwen-sharp-v22.4.0.jinja":
|
|
# The Qwen-Sharp chat template frank-dragoon loads via
|
|
# --chat-template-file. It lived under dankserver/frank-medium/ until
|
|
# the 2026-09-26 cutover retired that directory; editing it changes
|
|
# what the live frank-dragoon endpoint renders, so it plans
|
|
# frank-dragoon.
|
|
_add(services, "frank-dragoon")
|
|
if name == "frank-tts-engine.env":
|
|
# The engine's knobs are command-line flags, and this file is the
|
|
# repo's copy of them (the image entrypoint translates one line to
|
|
# one flag), so editing it is a restart of the engine. It also
|
|
# carries the boot warmer's three knobs, because the warmer is
|
|
# exec-ed into this container and inherits the same EnvironmentFile.
|
|
# A restart is what re-registers and re-warms every voice: the
|
|
# engine's voice registry is memory-only. (The warmer itself lives
|
|
# with the image, mapped above with the files it is built from, like
|
|
# the serve scripts of frank-zealot and frank-archon are mapped to
|
|
# the units that run them.)
|
|
# scripts/download-frank-tts-engine-qwentts.sh deliberately plans
|
|
# nothing: it fetches host-side state, like every other download
|
|
# script in this directory.
|
|
_add(services, "frank-tts-engine")
|
|
if name in {"frank-archon.env", "frank-archon.service", "serve-frank-archon.sh"}:
|
|
# llama.cpp frank-archon (2026-09-26, replacing the Lucebox
|
|
# DS4.1 quadlet) keeps its unit, env and serve files here the
|
|
# same way: a root unit on the unsloth b11030-mix prebuilt
|
|
# serving Qwen3.8-Flash-Next Q8_0. The download script fetches
|
|
# the payload, which is host-side state, so it selects nothing.
|
|
_add(services, "frank-archon")
|
|
if name == "open-notebook.env":
|
|
# open-notebook's tunables live here — the only repo-tracked part of
|
|
# either container's environment (the credentials are seeded on the
|
|
# host, so they are never a deploy trigger). Changing it is a
|
|
# restart of the pair, which the single deployable performs.
|
|
_add(services, "open-notebook")
|
|
# Deployment/test/benchmark scripts and other repo tooling do not
|
|
# restart a runtime service.
|
|
return services
|
|
|
|
# Examples and env templates are not deployed runtime state.
|
|
if p.name in {".env.example", "env.example"}:
|
|
return services
|
|
return services
|
|
|
|
def changed_paths(repo: str, base: str, head: str) -> list[str]:
|
|
result = subprocess.run(
|
|
["git", "-C", repo, "diff", "--name-only", "--diff-filter=ACDMRTUXB", base, head],
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
check=True,
|
|
)
|
|
return [line for line in result.stdout.splitlines() if line]
|
|
|
|
|
|
def plan(paths: list[str], target: str, repo: str | None = None,
|
|
base: str | None = None, head: str | None = None) -> list[str]:
|
|
services: set[str] = set()
|
|
for path in paths:
|
|
if path == f"{target}/compose.yaml" and repo and base and head:
|
|
services.update(compose_changes(repo, target, base, head))
|
|
else:
|
|
services.update(services_for_path(path, target))
|
|
allowed = target_services(target)
|
|
return [name for name in SERVICE_ORDER if name in services and name in allowed]
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--target", choices=("dankclaw", "dankserver", "frankbook-pro"), required=True)
|
|
parser.add_argument("--paths", nargs="+")
|
|
parser.add_argument("--repo", help="repository path for revision-aware planning")
|
|
parser.add_argument("--base", help="base Git revision")
|
|
parser.add_argument("--head", help="head Git revision")
|
|
args = parser.parse_args()
|
|
|
|
if not args.paths and not (args.repo and args.base and args.head):
|
|
parser.error("provide --paths or all of --repo, --base, and --head")
|
|
paths = args.paths or changed_paths(args.repo, args.base, args.head)
|
|
print("\n".join(plan(paths, args.target, args.repo, args.base, args.head)))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|