Files
svcforge/services/cli/main.py
T
Nguyen Minh Phuc 6974b3620f
ci / lint (push) Successful in 24s
ci / types (push) Successful in 35s
ci / unit (push) Successful in 26s
ci / security (push) Successful in 41s
ci / dockerfile (push) Successful in 8s
ci / chart (push) Successful in 9s
ci / integration (push) Successful in 46s
ci / image (api) (push) Successful in 2m32s
ci / image (reconciler) (push) Successful in 2m50s
ci / image (worker) (push) Successful in 2m43s
ci / bump (push) Successful in 21s
catalog: two tiny services, so the loop can be exercised on a full cluster
The three existing entries are sized like real products: `elasticsearch` small
asks for 1Gi and medium for 4Gi across three replicas. On this cluster that is
a request that never schedules, so provisioning them proves something about the
node and nothing about svcforge.

  podinfo  16Mi/10m  — one small Go binary, no dependencies, no PVC
  nginx    32Mi/10m  — recognisable, still small

Both are addressed as `oci://`, which is load-bearing rather than cosmetic. An
OCI chart is pulled by reference with no `helm repo add` first. The three
existing entries name `bitnamilegacy/<chart>`, a classic repo alias that
nothing in the worker image configures — so as written they cannot resolve at
provision time. OCI is the form that works from a bare container, and it is why
the e2e test already provisions podinfo.

Chart versions were resolved against the real registries before committing
(podinfo 6.7.1, nginx 25.0.14 / app 1.31.3) rather than guessed, since a wrong
version fails only at provision time.

Also updates the CLI ServiceType enum, the catalog test's expected set, and the
service list in the OpenAPI description.
2026-07-21 05:47:41 +00:00

174 lines
5.8 KiB
Python

"""svcforge — the control plane client.
This talks to the API over HTTP and never touches the database. That restraint is the
whole design: 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.
"""
from __future__ import annotations
import sys
import time
import uuid
from enum import StrEnum
from typing import Annotated, Any
import httpx
import typer
from svcforge_core.domain.states import InstanceState
from svcforge_core.settings import load_settings
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"
def _client() -> httpx.Client:
settings = load_settings()
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()