Files
svcforge/services/cli/main.py
T
Nguyen Minh Phuc c53734d2bc
ci / lint (push) Successful in 33s
ci / types (push) Successful in 43s
ci / unit (push) Successful in 32s
ci / security (push) Successful in 57s
ci / dockerfile (push) Successful in 7s
ci / chart (push) Successful in 8s
ci / integration (push) Successful in 55s
ci / image (api) (push) Successful in 3m39s
ci / image (reconciler) (push) Successful in 2m53s
ci / image (worker) (push) Successful in 2m14s
ci / bump (push) Successful in 16s
docs: add USER_GUIDE.md, tighten comments, fix CLI needing a DSN
The comment pass is prose-only: every distinct "why" is kept, the
narration around it is not. Verified by AST-comparing each changed file
against HEAD with docstrings stripped — only the two files below differ
in executable code.

Two real fixes fell out of the read-through:

* The CLI documented itself as never touching the database, then called
  load_settings(), which requires SVCFORGE_PG_DSN. It refused to start
  without a Postgres URL it never opens. It now has its own two-field
  ClientSettings; the orphaned api_url/api_token are dropped from
  Settings, where nothing else read them.
* repo/db.py had the DictRow alias comment and the ERROR_MAX_CHARS
  comment run together above the wrong symbol.

USER_GUIDE.md is the caller-facing guide the README only gestured at:
auth, catalog, every endpoint with curl, the lifecycle, the error table,
rate limiting, the CLI, client generation, an end-to-end poll loop.

It records two facts about the live deployment rather than documenting a
flow nobody can run. SVCFORGE_JWKS_URL points at a realm with no IdP
behind it, so the API logs "JWKS warm-up failed" at startup and every
/v1 request is a 401. And `helm repo list` in the worker returns no
repositories, so the three bitnamilegacy/ catalog entries cannot resolve
at provision time; only the oci:// entries can.

make lint clean, 76 unit + 111 integration tests pass.
2026-07-21 14:51:26 +00:00

190 lines
6.4 KiB
Python

"""svcforge — the control plane client.
Talks to the API over HTTP and never touches the database. If the CLI could write to
Postgres, every invariant the API enforces — the state machine, the one-transaction create,
AuthZ in the WHERE clause — would have a back door, and the first 3am incident would go
through it. `ClientSettings` below is what keeps that true in practice.
"""
from __future__ import annotations
import sys
import time
import uuid
from enum import StrEnum
from typing import Annotated, Any
import httpx
import typer
from pydantic_settings import BaseSettings, SettingsConfigDict
from svcforge_core.domain.states import InstanceState
app = typer.Typer(help="svcforge control plane client", no_args_is_help=True)
_TERMINAL = {InstanceState.READY, InstanceState.FAILED, InstanceState.DELETED}
class ServiceType(StrEnum):
"""What the catalog offers. Kept as an enum so typer can complete and validate it."""
ELASTICSEARCH = "elasticsearch"
REDIS = "redis"
POSTGRES = "postgres"
# Small enough to provision on a cluster with no spare memory; see catalog.yaml.
PODINFO = "podinfo"
NGINX = "nginx"
class Size(StrEnum):
SMALL = "small"
MEDIUM = "medium"
class ClientSettings(BaseSettings):
"""The two values the CLI needs, and nothing else.
Its own model rather than `svcforge_core.settings.Settings`, which requires
`SVCFORGE_PG_DSN`: loading that here would refuse to run the CLI without a database URL
it then never opens, on a laptop that has no reason to hold one.
"""
model_config = SettingsConfigDict(
env_prefix="SVCFORGE_", env_file=".env", env_file_encoding="utf-8", extra="ignore", frozen=True
)
api_url: str = "http://localhost:8000"
api_token: str | None = None
def _client() -> httpx.Client:
settings = ClientSettings()
headers = {"authorization": f"Bearer {settings.api_token}"} if settings.api_token else {}
return httpx.Client(base_url=settings.api_url, headers=headers, timeout=10.0)
def _die(msg: str) -> None:
typer.secho(msg, fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)
def _check(resp: httpx.Response) -> Any: # noqa: ANN401 - a decoded JSON body genuinely is Any
if resp.status_code == 401:
_die("401 unauthorized: check SVCFORGE_API_TOKEN")
if resp.status_code == 404:
_die("404 not found")
if resp.status_code == 429:
_die("429 rate limited: slow down")
if resp.status_code >= 400:
_die(f"{resp.status_code}: {resp.text[:400]}")
return resp.json()
def _parse_ttl(ttl: str | None) -> int | None:
"""'7d' -> 7. Only days, because the API only takes days."""
if ttl is None:
return None
if not ttl.endswith("d") or not ttl[:-1].isdigit():
_die(f"bad --ttl {ttl!r}: expected something like '7d'")
return int(ttl[:-1])
def _print_table(rows: list[dict[str, Any]]) -> None:
if not rows:
typer.echo("(none)")
return
cols = ["id", "service_type", "size", "state", "chart_version", "endpoint"]
widths = {c: max(len(c), *(len(str(r.get(c) or "-")) for r in rows)) for c in cols}
typer.echo(" ".join(c.ljust(widths[c]) for c in cols))
typer.echo(" ".join("-" * widths[c] for c in cols))
for r in rows:
typer.echo(" ".join(str(r.get(c) or "-").ljust(widths[c]) for c in cols))
@app.command()
def create(
service_type: Annotated[ServiceType, typer.Argument(help="what to provision")],
size: Annotated[Size, typer.Option()] = Size.SMALL,
ttl: Annotated[str | None, typer.Option(help="e.g. 7d")] = None,
wait: Annotated[bool, typer.Option(help="poll until ready or failed")] = False,
) -> None:
"""Request an instance. Returns as soon as the API accepts it (202)."""
body: dict[str, Any] = {"service_type": service_type.value, "size": size.value}
ttl_days = _parse_ttl(ttl)
if ttl_days is not None:
body["ttl_days"] = ttl_days
with _client() as c:
data = _check(c.post("/v1/instances", json=body))
typer.echo(f"{data['id']} {data['state']}")
if not wait:
return
# 202 means "accepted", not "done". Polling is the client's job precisely because
# the API refused to block on a helm install that takes four minutes.
instance_id = data["id"]
deadline = time.monotonic() + 600
state = data["state"]
while time.monotonic() < deadline:
time.sleep(2)
data = _check(c.get(f"/v1/instances/{instance_id}"))
if data["state"] != state:
state = data["state"]
typer.echo(f" -> {state}")
if state in _TERMINAL:
break
else:
_die("timed out waiting; the task may still be running — check `svcforge status`")
if state == InstanceState.FAILED:
_die(f"failed: {data.get('error') or 'no error recorded'}")
typer.echo(f"endpoint: {data.get('endpoint') or '-'}")
@app.command("list")
def list_instances(
state: Annotated[InstanceState | None, typer.Option(help="filter by state")] = None,
) -> None:
"""List your team's instances."""
with _client() as c:
rows = _check(c.get("/v1/instances"))
if state is not None:
rows = [r for r in rows if r["state"] == state.value]
_print_table(rows)
@app.command()
def status(instance_id: Annotated[uuid.UUID, typer.Argument()]) -> None:
"""Show one instance. Exits non-zero if it is failed, so scripts can branch on it."""
with _client() as c:
data = _check(c.get(f"/v1/instances/{instance_id}"))
_print_table([data])
if data["state"] == InstanceState.FAILED:
typer.secho(f"error: {data.get('error')}", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)
@app.command()
def delete(
instance_id: Annotated[uuid.UUID, typer.Argument()],
yes: Annotated[bool, typer.Option("--yes", "-y", help="skip the confirmation")] = False,
) -> None:
"""Deprovision an instance."""
if not yes and not typer.confirm(f"delete {instance_id}?"):
raise typer.Abort
with _client() as c:
data = _check(c.delete(f"/v1/instances/{instance_id}"))
typer.echo(f"{data['id']} {data['state']}")
def main() -> None: # pragma: no cover - console-script entrypoint
try:
app()
except httpx.ConnectError:
typer.secho("cannot reach the API: check SVCFORGE_API_URL", fg=typer.colors.RED, err=True)
sys.exit(1)
if __name__ == "__main__": # pragma: no cover
main()