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]>
This commit is contained in:
zooqueen
2026-02-22 15:59:24 -08:00
co-authored by Hanzo Dev
parent 850d53fb75
commit b2c00fb723
3 changed files with 342 additions and 2 deletions
+120 -2
View File
@@ -8,8 +8,10 @@ Usage:
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>
@@ -324,6 +326,13 @@ def deploy_list(org: str | None, project: str | None, env_id: str | None) -> Non
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
@@ -336,19 +345,46 @@ def deploy_create(
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 or git repo.
"""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 or --repo.[/red]")
console.print("[red]Specify --image, --repo, or --config.[/red]")
raise SystemExit(1)
org_id, project_id, env_id = resolve(org, project, env_id)
@@ -676,6 +712,88 @@ def deploy_env(
# ── 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.")
+221
View File
@@ -0,0 +1,221 @@
"""Parse hanzo.toml deployment manifests into PaaS container payloads.
The hanzo.toml file is the project-level deployment manifest for Hanzo PaaS.
It maps cleanly to the PaaS container API:
[app]
name = "my-app"
type = "deployment"
dockerfile = "Dockerfile"
[env]
NODE_ENV = "production"
[networking]
port = 3000
ingress = true
force_https = true
[resources]
cpu = "2000m" # millicores or "2" for cores
memory = "2048Mi" # mebibytes or "2Gi" for gibibytes
[storage]
enabled = true
mount = "/data"
size = "1Gi"
[deploy]
replicas = 1
strategy = "RollingUpdate"
command = "node index.js"
[probes.readiness]
path = "/health"
port = 3000
period = 30
timeout = 10
[probes.liveness]
path = "/health"
port = 3000
period = 30
timeout = 10
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
# Python 3.11+ has tomllib in stdlib; older versions need tomli.
if sys.version_info >= (3, 11):
import tomllib
else:
try:
import tomli as tomllib # type: ignore[no-redef]
except ModuleNotFoundError as exc:
raise ImportError(
"hanzo.toml support requires the 'tomli' package on Python <3.11. "
"Install it with: pip install tomli"
) from exc
DEFAULT_CONFIG_FILENAME = "hanzo.toml"
def find_config(path: str | Path | None = None) -> Path | None:
"""Locate hanzo.toml — explicit path or CWD search."""
if path:
p = Path(path)
if p.is_file():
return p
return None
cwd = Path.cwd()
candidate = cwd / DEFAULT_CONFIG_FILENAME
return candidate if candidate.is_file() else None
def load_config(path: str | Path) -> dict[str, Any]:
"""Read and parse a hanzo.toml file."""
with open(path, "rb") as f:
return tomllib.load(f)
def _parse_cpu(raw: str | int) -> tuple[int, str]:
"""Parse CPU value like '2000m' or '2' into (value, type)."""
s = str(raw).strip()
if s.endswith("m"):
return int(s[:-1]), "millicores"
return int(float(s) * 1000), "millicores"
def _parse_memory(raw: str | int) -> tuple[int, str]:
"""Parse memory value like '2048Mi' or '2Gi' into (value, type)."""
s = str(raw).strip()
if s.endswith("Gi"):
return int(s[:-2]), "gibibyte"
if s.endswith("Mi"):
return int(s[:-2]), "mebibyte"
# Plain number = mebibytes
return int(s), "mebibyte"
def _parse_storage_size(raw: str | int) -> tuple[int, str]:
"""Parse storage size like '1Gi' or '500Mi'."""
s = str(raw).strip()
if s.endswith("Gi"):
return int(s[:-2]), "gibibyte"
if s.endswith("Mi"):
return int(s[:-2]), "mebibyte"
return int(s), "gibibyte"
def _build_probe(cfg: dict[str, Any], port_default: int) -> dict[str, Any]:
"""Convert a [probes.*] section into PaaS probe config."""
return {
"enabled": True,
"checkMechanism": "httpGet",
"httpPath": cfg.get("path", "/health"),
"httpPort": cfg.get("port", port_default),
"initialDelaySeconds": cfg.get("initial_delay", 30),
"periodSeconds": cfg.get("period", 30),
"timeoutSeconds": cfg.get("timeout", 10),
"failureThreshold": cfg.get("failure_threshold", 3),
}
def config_to_payload(cfg: dict[str, Any]) -> dict[str, Any]:
"""Convert a parsed hanzo.toml dict into a PaaS container creation payload."""
app = cfg.get("app", {})
env = cfg.get("env", {})
networking = cfg.get("networking", {})
resources = cfg.get("resources", {})
storage = cfg.get("storage", {})
deploy = cfg.get("deploy", {})
probes_cfg = cfg.get("probes", {})
name = app.get("name", "app")
deploy_type = app.get("type", "deployment")
port = networking.get("port", 3000)
# Resources
cpu_val, cpu_type = _parse_cpu(resources.get("cpu", "200m"))
mem_val, mem_type = _parse_memory(resources.get("memory", "256Mi"))
payload: dict[str, Any] = {
"name": name,
"type": deploy_type,
"networking": {
"containerPort": port,
"ingress": {"enabled": networking.get("ingress", False)},
"customDomain": {"enabled": False},
"tcpProxy": {"enabled": False},
},
"podConfig": {
"restartPolicy": "Always",
"cpuRequest": cpu_val // 2,
"cpuRequestType": cpu_type,
"cpuLimit": cpu_val,
"cpuLimitType": cpu_type,
"memoryRequest": mem_val // 2,
"memoryRequestType": mem_type,
"memoryLimit": mem_val,
"memoryLimitType": mem_type,
},
"storageConfig": {
"enabled": storage.get("enabled", False),
},
"probes": {
"startup": {"enabled": False},
"readiness": {"enabled": False},
"liveness": {"enabled": False},
},
}
# Storage
if storage.get("enabled", False):
size_val, size_type = _parse_storage_size(storage.get("size", "1Gi"))
payload["storageConfig"] = {
"enabled": True,
"mountPath": storage.get("mount", "/data"),
"size": size_val,
"sizeType": size_type,
"accessModes": ["ReadWriteOnce"],
}
# Probes
for probe_name in ("readiness", "liveness", "startup"):
if probe_name in probes_cfg:
payload["probes"][probe_name] = _build_probe(probes_cfg[probe_name], port)
# Deployment config
if deploy_type == "deployment":
payload["deploymentConfig"] = {
"desiredReplicas": deploy.get("replicas", 1),
"strategy": deploy.get("strategy", "RollingUpdate"),
"rollingUpdate": {
"maxSurge": 30,
"maxSurgeType": "percentage",
"maxUnavailable": 0,
"maxUnavailableType": "number",
},
"revisionHistoryLimit": 10,
"cpuMetric": {"enabled": False},
"memoryMetric": {"enabled": False},
}
# Environment variables
if env:
payload["variables"] = [
{"name": k, "value": str(v)} for k, v in env.items()
]
# Source — always repo (Dockerfile-based) for hanzo.toml
dockerfile = app.get("dockerfile", "Dockerfile")
payload["repoOrRegistry"] = "repo"
payload["repo"] = {
"dockerfile": dockerfile,
}
return payload
+1
View File
@@ -26,6 +26,7 @@ dependencies = [
"pyjwt>=2.8.0",
"hanzo-iam>=1.0.0",
"hanzo-kms>=1.0.0",
"tomli>=2.0.0; python_version < '3.11'",
]
[project.optional-dependencies]