Files
zooqueenandHanzo Dev b2c00fb723 feat(hanzo-cli): add native hanzo.toml support for PaaS deployments
Add TOML config parser that converts hanzo.toml manifests into PaaS
container API payloads. New `deploy up` command reads hanzo.toml from
CWD and creates or updates containers. Also adds --config flag to
`deploy create` for explicit manifest path. Adds tomli dependency for
Python 3.9/3.10 compat.

Co-authored-by: Hanzo Dev <[email protected]>
2026-02-22 15:59:24 -08:00

828 lines
27 KiB
Python

"""Hanzo CLI — PaaS subcommands for platform management.
Context is sticky: run ``hanzo paas use --org X --project Y --env Z``
once, then subsequent commands remember. CLI flags always override.
Usage:
hanzo paas use --org O --project P --env E
hanzo paas orgs
hanzo paas projects
hanzo paas envs
hanzo paas deploy up [--config hanzo.toml]
hanzo paas deploy list
hanzo paas deploy create <name> --repo <url> [--branch main]
hanzo paas deploy create <name> --config hanzo.toml
hanzo paas deploy status <name>
hanzo paas deploy logs <name>
hanzo paas deploy redeploy <name>
hanzo paas deploy env <name> [KEY=VAL ...]
hanzo paas deploy delete <name>
"""
from __future__ import annotations
import json
from typing import Any
import click
from rich.console import Console
from rich.table import Table
from hanzo_cli.paas.client import PaaSClient
from hanzo_cli.paas.context import clear_context, load_context, resolve, save_context
console = Console()
# ── shared option decorators ────────────────────────────────────────────
def _org_option(fn):
return click.option(
"--org", default=None, help="Organization ID (or from context)."
)(fn)
def _project_option(fn):
return click.option(
"--project", default=None, help="Project ID (or from context)."
)(fn)
def _env_option(fn):
return click.option(
"--env", "env_id", default=None, help="Environment ID (or from context)."
)(fn)
def _require(label: str, value: str | None) -> str:
"""Ensure a value is set; exit with a helpful message if not."""
if not value:
console.print(
f"[red]{label} is required.[/red] Pass it via flag or run 'hanzo paas use'."
)
raise SystemExit(1)
return value
def _find_container(
client: PaaSClient,
org_id: str,
project_id: str,
env_id: str,
name: str,
) -> dict[str, Any]:
"""Look up a container by name within the current context."""
containers = client.list_containers(org_id, project_id, env_id)
# list may be wrapped in {"data": [...]} or raw [...]
items = (
containers
if isinstance(containers, list)
else containers.get("data", containers)
)
for c in items:
if c.get("iid") == name or c.get("name") == name or c.get("slug") == name:
return c
console.print(f"[red]Container '{name}' not found.[/red]")
raise SystemExit(1)
# ═══════════════════════════════════════════════════════════════════════
# Root group
# ═══════════════════════════════════════════════════════════════════════
@click.group()
def paas() -> None:
"""Manage the Hanzo PaaS — orgs, projects, deployments."""
# ── context ─────────────────────────────────────────────────────────────
@paas.command("use")
@click.option("--org", default=None, help="Organization ID to remember.")
@click.option("--project", default=None, help="Project ID to remember.")
@click.option("--env", "env_id", default=None, help="Environment ID to remember.")
@click.option("--clear", is_flag=True, help="Clear stored context.")
def use_context(
org: str | None, project: str | None, env_id: str | None, clear: bool
) -> None:
"""Set the default org / project / environment context."""
if clear:
clear_context()
console.print("Context cleared.")
return
ctx = load_context()
if org:
ctx["org_id"] = org
if project:
ctx["project_id"] = project
if env_id:
ctx["env_id"] = env_id
save_context(ctx)
table = Table(title="PaaS Context")
table.add_column("Key", style="cyan")
table.add_column("Value", style="white")
table.add_row("org", ctx.get("org_id", "—"))
table.add_row("project", ctx.get("project_id", "—"))
table.add_row("env", ctx.get("env_id", "—"))
console.print(table)
@paas.command("context")
def show_context() -> None:
"""Show current PaaS context."""
ctx = load_context()
if not ctx:
console.print("[yellow]No context set.[/yellow] Run 'hanzo paas use'.")
return
table = Table(title="PaaS Context")
table.add_column("Key", style="cyan")
table.add_column("Value", style="white")
table.add_row("org", ctx.get("org_id", "—"))
table.add_row("project", ctx.get("project_id", "—"))
table.add_row("env", ctx.get("env_id", "—"))
console.print(table)
# ═══════════════════════════════════════════════════════════════════════
# Orgs / Projects / Environments
# ═══════════════════════════════════════════════════════════════════════
@paas.command("orgs")
def list_orgs() -> None:
"""List organizations."""
client = PaaSClient.from_auth()
try:
data = client.list_orgs()
finally:
client.close()
items = data if isinstance(data, list) else data.get("data", data)
if not items:
console.print("[yellow]No organizations.[/yellow]")
return
table = Table(title=f"Organizations ({len(items)})")
table.add_column("ID", style="dim")
table.add_column("Name", style="cyan")
table.add_column("Slug", style="white")
for o in items:
table.add_row(
str(o.get("_id", o.get("id", "—"))),
o.get("name", "—"),
o.get("iid", "—"),
)
console.print(table)
@paas.command("projects")
@_org_option
def list_projects(org: str | None) -> None:
"""List projects in an organization."""
org_id, _, _ = resolve(org, None, None)
org_id = _require("--org", org_id)
client = PaaSClient.from_auth()
try:
data = client.list_projects(org_id)
finally:
client.close()
items = data if isinstance(data, list) else data.get("data", data)
if not items:
console.print("[yellow]No projects.[/yellow]")
return
table = Table(title=f"Projects ({len(items)})")
table.add_column("ID", style="dim")
table.add_column("Name", style="cyan")
table.add_column("Slug", style="white")
for p in items:
table.add_row(
str(p.get("_id", p.get("id", "—"))),
p.get("name", "—"),
p.get("iid", "—"),
)
console.print(table)
@paas.command("envs")
@_org_option
@_project_option
def list_envs(org: str | None, project: str | None) -> None:
"""List environments in a project."""
org_id, project_id, _ = resolve(org, project, None)
org_id = _require("--org", org_id)
project_id = _require("--project", project_id)
client = PaaSClient.from_auth()
try:
data = client.list_envs(org_id, project_id)
finally:
client.close()
items = data if isinstance(data, list) else data.get("data", data)
if not items:
console.print("[yellow]No environments.[/yellow]")
return
table = Table(title=f"Environments ({len(items)})")
table.add_column("ID", style="dim")
table.add_column("Name", style="cyan")
table.add_column("Slug", style="white")
for e in items:
table.add_row(
str(e.get("_id", e.get("id", "—"))),
e.get("name", "—"),
e.get("iid", "—"),
)
console.print(table)
# ═══════════════════════════════════════════════════════════════════════
# Deploy sub-group
# ═══════════════════════════════════════════════════════════════════════
@paas.group("deploy")
def deploy() -> None:
"""Manage container deployments."""
# ── deploy list ─────────────────────────────────────────────────────────
@deploy.command("list")
@_org_option
@_project_option
@_env_option
def deploy_list(org: str | None, project: str | None, env_id: str | None) -> None:
"""List deployed containers."""
org_id, project_id, env_id = resolve(org, project, env_id)
org_id = _require("--org", org_id)
project_id = _require("--project", project_id)
env_id = _require("--env", env_id)
client = PaaSClient.from_auth()
try:
data = client.list_containers(org_id, project_id, env_id)
finally:
client.close()
items = data if isinstance(data, list) else data.get("data", data)
if not items:
console.print("[yellow]No containers.[/yellow]")
return
table = Table(title=f"Containers ({len(items)})")
table.add_column("Name", style="cyan")
table.add_column("Type", style="white")
table.add_column("Repo", style="dim")
table.add_column("Status", style="green")
for c in items:
name = c.get("iid", c.get("name", "—"))
ctype = c.get("type", "—")
repo = c.get("repo", {})
repo_str = repo.get("name", "—") if isinstance(repo, dict) else "—"
status = c.get("pipelineStatus", c.get("status", "—"))
if isinstance(status, dict):
status = status.get("status", "—")
table.add_row(name, ctype, repo_str, str(status))
console.print(table)
# ── deploy create ───────────────────────────────────────────────────────
@deploy.command("create")
@click.argument("name")
@click.option(
"--image",
default=None,
help="Docker image URL (e.g. nginx:latest, ghcr.io/org/app:v1).",
)
@click.option("--repo", default=None, help="Git repository (owner/repo).")
@click.option("--branch", default="main", help="Branch to deploy (with --repo).")
@click.option(
"--dockerfile", default="Dockerfile", help="Dockerfile path (with --repo)."
)
@click.option("--port", type=int, default=3000, help="Container port.")
@click.option("--replicas", type=int, default=1, help="Desired replica count.")
@click.option(
"--type",
"deploy_type",
default="deployment",
type=click.Choice(["deployment", "statefulset", "cronjob"]),
)
@click.option(
"--config",
"config_path",
default=None,
type=click.Path(exists=True),
help="Path to hanzo.toml deployment manifest.",
)
@_org_option
@_project_option
@_env_option
def deploy_create(
name: str,
image: str | None,
repo: str | None,
branch: str,
dockerfile: str,
port: int,
replicas: int,
deploy_type: str,
config_path: str | None,
org: str | None,
project: str | None,
env_id: str | None,
) -> None:
"""Create a new container deployment from a Docker image, git repo, or hanzo.toml.
Examples:
hanzo paas deploy create myapp --image nginx:latest --port 80
hanzo paas deploy create myapp --image ghcr.io/org/app:v1 --port 8080
hanzo paas deploy create myapp --repo hanzoai/app --branch main
hanzo paas deploy create myapp --config hanzo.toml
"""
if config_path:
from hanzo_cli.paas.config import config_to_payload, load_config
cfg = load_config(config_path)
payload = config_to_payload(cfg)
# CLI name argument overrides config name
payload["name"] = name
org_id, project_id, env_id = resolve(org, project, env_id)
org_id = _require("--org", org_id)
project_id = _require("--project", project_id)
env_id = _require("--env", env_id)
client = PaaSClient.from_auth()
try:
data = client.create_container(org_id, project_id, env_id, payload)
finally:
client.close()
console.print(f"[green]Created container '{name}' from {config_path}[/green]")
cid = data.get("_id", data.get("id", ""))
if cid:
console.print(f"ID: {cid}")
return
if not image and not repo:
console.print("[red]Specify --image, --repo, or --config.[/red]")
raise SystemExit(1)
org_id, project_id, env_id = resolve(org, project, env_id)
org_id = _require("--org", org_id)
project_id = _require("--project", project_id)
env_id = _require("--env", env_id)
# Resolve public registry ID for image deployments
registry_id = None
if image:
client_pre = PaaSClient.from_auth()
try:
regs = client_pre.http.get("/v1/registry")
regs.raise_for_status()
for reg in regs.json():
if reg.get("type") == "Public":
registry_id = str(reg["_id"])
break
except Exception:
pass
finally:
client_pre.close()
payload: dict[str, Any] = {
"name": name,
"type": deploy_type,
"networking": {
"containerPort": port,
"ingress": {"enabled": False},
"customDomain": {"enabled": False},
"tcpProxy": {"enabled": False},
},
"podConfig": {
"restartPolicy": "Always",
"cpuRequest": 100,
"cpuRequestType": "millicores",
"cpuLimit": 200,
"cpuLimitType": "millicores",
"memoryRequest": 128,
"memoryRequestType": "mebibyte",
"memoryLimit": 256,
"memoryLimitType": "mebibyte",
},
"storageConfig": {"enabled": False},
"probes": {
"startup": {"enabled": False},
"readiness": {"enabled": False},
"liveness": {"enabled": False},
},
}
if deploy_type == "deployment":
payload["deploymentConfig"] = {
"desiredReplicas": replicas,
"strategy": "RollingUpdate",
"rollingUpdate": {
"maxSurge": 30,
"maxSurgeType": "percentage",
"maxUnavailable": 0,
"maxUnavailableType": "number",
},
"revisionHistoryLimit": 10,
"cpuMetric": {"enabled": False},
"memoryMetric": {"enabled": False},
}
if image:
payload["repoOrRegistry"] = "registry"
reg_obj: dict[str, Any] = {"imageUrl": image}
if registry_id:
reg_obj["registryId"] = registry_id
payload["registry"] = reg_obj
else:
payload["repoOrRegistry"] = "repo"
payload["repo"] = {
"name": repo,
"branch": branch,
"dockerfile": dockerfile,
}
client = PaaSClient.from_auth()
try:
data = client.create_container(org_id, project_id, env_id, payload)
finally:
client.close()
console.print(f"[green]Created container '{name}'[/green]")
cid = data.get("_id", data.get("id", ""))
if cid:
console.print(f"ID: {cid}")
# ── deploy status ───────────────────────────────────────────────────────
@deploy.command("status")
@click.argument("name")
@_org_option
@_project_option
@_env_option
def deploy_status(
name: str,
org: str | None,
project: str | None,
env_id: str | None,
) -> None:
"""Show container status and pods."""
org_id, project_id, env_id = resolve(org, project, env_id)
org_id = _require("--org", org_id)
project_id = _require("--project", project_id)
env_id = _require("--env", env_id)
client = PaaSClient.from_auth()
try:
container = _find_container(client, org_id, project_id, env_id, name)
cid = str(container.get("_id", container.get("id")))
# Container info
table = Table(title=f"Container: {name}")
table.add_column("Field", style="cyan")
table.add_column("Value", style="white")
table.add_row("ID", cid)
table.add_row("Type", container.get("type", "—"))
table.add_row("Pipeline", str(container.get("pipelineStatus", "—")))
status = container.get("status", {})
if isinstance(status, dict):
table.add_row("Desired Replicas", str(status.get("desiredReplicas", "—")))
table.add_row("Ready Replicas", str(status.get("readyReplicas", "—")))
table.add_row(
"Available Replicas", str(status.get("availableReplicas", "—"))
)
console.print(table)
# Pods
try:
pods_data = client.get_container_pods(org_id, project_id, env_id, cid)
pods = (
pods_data if isinstance(pods_data, list) else pods_data.get("data", [])
)
if pods:
pod_table = Table(title="Pods")
pod_table.add_column("Name", style="cyan")
pod_table.add_column("Status", style="green")
pod_table.add_column("Restarts", style="yellow", justify="right")
for p in pods:
pod_table.add_row(
p.get("name", "—"),
p.get("status", "—"),
str(p.get("restartCount", 0)),
)
console.print(pod_table)
except Exception:
pass # pods endpoint may not be available
finally:
client.close()
# ── deploy logs ─────────────────────────────────────────────────────────
@deploy.command("logs")
@click.argument("name")
@_org_option
@_project_option
@_env_option
def deploy_logs(
name: str,
org: str | None,
project: str | None,
env_id: str | None,
) -> None:
"""Show container logs."""
org_id, project_id, env_id = resolve(org, project, env_id)
org_id = _require("--org", org_id)
project_id = _require("--project", project_id)
env_id = _require("--env", env_id)
client = PaaSClient.from_auth()
try:
container = _find_container(client, org_id, project_id, env_id, name)
cid = str(container.get("_id", container.get("id")))
logs_data = client.get_container_logs(org_id, project_id, env_id, cid)
# PaaS returns {pods: [...], logs: [{podName, logs: [line, ...]}, ...]}
if isinstance(logs_data, dict) and "logs" in logs_data:
pod_logs = logs_data["logs"]
if isinstance(pod_logs, list):
for pod in pod_logs:
if isinstance(pod, dict):
pod_name = pod.get("podName", "?")
lines = pod.get("logs", [])
if len(pod_logs) > 1:
click.echo(click.style(f"─── {pod_name} ───", fg="cyan"))
for line in lines:
if line:
click.echo(line)
else:
click.echo(str(pod))
else:
click.echo(str(pod_logs))
elif isinstance(logs_data, str):
click.echo(logs_data)
elif isinstance(logs_data, list):
for line in logs_data:
click.echo(line)
else:
click.echo(json.dumps(logs_data, indent=2))
finally:
client.close()
# ── deploy redeploy ─────────────────────────────────────────────────────
@deploy.command("redeploy")
@click.argument("name")
@_org_option
@_project_option
@_env_option
def deploy_redeploy(
name: str,
org: str | None,
project: str | None,
env_id: str | None,
) -> None:
"""Trigger a rebuild and redeploy of a container."""
org_id, project_id, env_id = resolve(org, project, env_id)
org_id = _require("--org", org_id)
project_id = _require("--project", project_id)
env_id = _require("--env", env_id)
client = PaaSClient.from_auth()
try:
container = _find_container(client, org_id, project_id, env_id, name)
cid = str(container.get("_id", container.get("id")))
client.redeploy_container(org_id, project_id, env_id, cid)
finally:
client.close()
console.print(f"[green]Redeployment triggered for '{name}'.[/green]")
# ── deploy env ──────────────────────────────────────────────────────────
@deploy.command("env")
@click.argument("name")
@click.argument("vars", nargs=-1)
@_org_option
@_project_option
@_env_option
def deploy_env(
name: str,
vars: tuple[str, ...],
org: str | None,
project: str | None,
env_id: str | None,
) -> None:
"""Show or set environment variables on a container.
Without arguments, shows current vars. With KEY=VAL pairs, sets them.
Examples:
hanzo paas deploy env myapp
hanzo paas deploy env myapp DATABASE_URL=postgres://... NODE_ENV=production
"""
org_id, project_id, env_id = resolve(org, project, env_id)
org_id = _require("--org", org_id)
project_id = _require("--project", project_id)
env_id = _require("--env", env_id)
client = PaaSClient.from_auth()
try:
container = _find_container(client, org_id, project_id, env_id, name)
cid = str(container.get("_id", container.get("id")))
if not vars:
# Show current vars
variables = container.get("variables", [])
if not variables:
console.print("[yellow]No environment variables set.[/yellow]")
return
table = Table(title=f"Env vars: {name}")
table.add_column("Key", style="cyan")
table.add_column("Value", style="dim")
for v in variables:
val = v.get("value", "")
masked = f"{val[:4]}***" if len(val) > 4 else val
table.add_row(v.get("name", "—"), masked)
console.print(table)
else:
# Set vars: parse KEY=VAL pairs
new_vars = []
for kv in vars:
if "=" not in kv:
console.print(f"[red]Invalid format: '{kv}'. Use KEY=VALUE.[/red]")
raise SystemExit(1)
k, v = kv.split("=", 1)
new_vars.append({"name": k, "value": v})
# Merge with existing
existing = {v["name"]: v["value"] for v in container.get("variables", [])}
for nv in new_vars:
existing[nv["name"]] = nv["value"]
merged = [{"name": k, "value": v} for k, v in existing.items()]
# PaaS requires the full container payload on PUT — merge vars into it
container["variables"] = merged
client.update_container(
org_id,
project_id,
env_id,
cid,
container,
)
console.print(
f"[green]Set {len(new_vars)} variable(s) on '{name}'.[/green]"
)
finally:
client.close()
# ── deploy delete ───────────────────────────────────────────────────────
@deploy.command("up")
@click.option(
"--config",
"config_path",
default=None,
type=click.Path(),
help="Path to hanzo.toml (default: ./hanzo.toml).",
)
@_org_option
@_project_option
@_env_option
def deploy_up(
config_path: str | None,
org: str | None,
project: str | None,
env_id: str | None,
) -> None:
"""Deploy from hanzo.toml — create or update.
Reads hanzo.toml from the current directory (or --config path),
creates the container if it doesn't exist, or updates it if it does.
Examples:
hanzo deploy up
hanzo deploy up --config path/to/hanzo.toml
"""
from hanzo_cli.paas.config import config_to_payload, find_config, load_config
resolved = find_config(config_path)
if not resolved:
console.print(
"[red]No hanzo.toml found.[/red] "
"Create one or pass --config <path>."
)
raise SystemExit(1)
cfg = load_config(resolved)
payload = config_to_payload(cfg)
name = payload.get("name", "app")
org_id, project_id, env_id = resolve(org, project, env_id)
org_id = _require("--org", org_id)
project_id = _require("--project", project_id)
env_id = _require("--env", env_id)
client = PaaSClient.from_auth()
try:
# Check if container already exists
containers = client.list_containers(org_id, project_id, env_id)
items = (
containers
if isinstance(containers, list)
else containers.get("data", containers)
)
existing = None
for c in items:
if c.get("iid") == name or c.get("name") == name:
existing = c
break
if existing:
# Update existing container
cid = str(existing.get("_id", existing.get("id")))
# Merge payload into existing config
existing.update(payload)
client.update_container(org_id, project_id, env_id, cid, existing)
console.print(
f"[green]Updated container '{name}' from {resolved}[/green]"
)
else:
# Create new container
data = client.create_container(org_id, project_id, env_id, payload)
cid = data.get("_id", data.get("id", ""))
console.print(
f"[green]Created container '{name}' from {resolved}[/green]"
)
if cid:
console.print(f"ID: {cid}")
finally:
client.close()
@deploy.command("delete")
@click.argument("name")
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
@_org_option
@_project_option
@_env_option
def deploy_delete(
name: str,
yes: bool,
org: str | None,
project: str | None,
env_id: str | None,
) -> None:
"""Delete a container deployment."""
if not yes:
click.confirm(f"Delete container '{name}'?", abort=True)
org_id, project_id, env_id = resolve(org, project, env_id)
org_id = _require("--org", org_id)
project_id = _require("--project", project_id)
env_id = _require("--env", env_id)
client = PaaSClient.from_auth()
try:
container = _find_container(client, org_id, project_id, env_id, name)
cid = str(container.get("_id", container.get("id")))
client.delete_container(org_id, project_id, env_id, cid)
finally:
client.close()
console.print(f"[green]Deleted '{name}'.[/green]")