mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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:
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user