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
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.
174 lines
5.8 KiB
Python
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()
|