feat: add 'hanzo infra' CLI for cloud infrastructure provisioning

Commands:
- hanzo infra list      - Show available services
- hanzo infra provision - Provision service on Hanzo Cloud
- hanzo infra connect   - Get connection details
- hanzo infra env       - Export environment variables
- hanzo infra status    - Check service health
- hanzo infra destroy   - Delete provisioned service
- hanzo infra init      - Initialize from hanzo.yaml

Services: vector, kv, documentdb, storage, search, pubsub, tasks, queues, cron, functions
This commit is contained in:
2026-01-19 23:28:25 -08:00
parent 1028ee9c92
commit a7cbc63552
5 changed files with 502 additions and 3 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "hanzo"
version = "0.3.33"
version = "0.3.34"
description = "Hanzo AI - Complete AI Infrastructure Platform with CLI, Router, MCP, and Agent Runtime"
authors = [
{name = "Hanzo AI", email = "[email protected]"},
+1 -1
View File
@@ -1,6 +1,6 @@
"""Hanzo - Complete AI Infrastructure Platform with CLI, Router, MCP, and Agent Runtime."""
__version__ = "0.3.33"
__version__ = "0.3.34"
__all__ = ["main", "cli", "__version__"]
from .cli import cli, main
+3 -1
View File
@@ -22,6 +22,7 @@ from .commands import (
config,
router,
network,
infra,
)
from .ui.startup import show_startup
from .utils.output import console
@@ -29,7 +30,7 @@ from .interactive.repl import HanzoREPL
from .interactive.enhanced_repl import EnhancedHanzoREPL
# Version
__version__ = "0.3.31"
__version__ = "0.3.34"
@click.group(invoke_without_command=True)
@@ -90,6 +91,7 @@ cli.add_command(tools.tools_group)
cli.add_command(network.network_group)
cli.add_command(config.config_group)
cli.add_command(router.router_group)
cli.add_command(infra.infra_group)
# Quick aliases
+3
View File
@@ -6,9 +6,12 @@ __all__ = [
"chat",
"cluster",
"config",
"infra",
"mcp",
"miner",
"network",
"node",
"repl",
"router",
"tools",
]
+494
View File
@@ -0,0 +1,494 @@
"""Infrastructure management commands for Hanzo CLI.
Provision and manage infrastructure services on Hanzo Cloud:
- Vector (Qdrant)
- KV (Redis/Valkey)
- DocumentDB (MongoDB)
- Storage (S3/MinIO)
- Search (Meilisearch)
- PubSub (NATS)
- Tasks (Temporal)
- Queues
- Cron
- Functions (Nuclio)
"""
import os
import json
from typing import Optional
from pathlib import Path
import click
import httpx
from rich import box
from rich.panel import Panel
from rich.table import Table
from rich.prompt import Confirm
from ..utils.output import console
HANZO_API_URL = os.getenv("HANZO_API_URL", "https://api.hanzo.ai")
# Service names - defined before SERVICES dict for click.Choice
SERVICE_NAMES = (
"vector", "kv", "documentdb", "storage", "search",
"pubsub", "tasks", "queues", "cron", "functions"
)
SERVICES = {
"vector": {
"name": "Vector Database",
"description": "Qdrant vector similarity search",
"env_prefix": "QDRANT",
"default_port": 6333,
},
"kv": {
"name": "Key-Value Store",
"description": "Redis/Valkey for caching and state",
"env_prefix": "REDIS",
"default_port": 6379,
},
"documentdb": {
"name": "Document Database",
"description": "MongoDB for document storage",
"env_prefix": "MONGODB",
"default_port": 27017,
},
"storage": {
"name": "Object Storage",
"description": "S3-compatible storage (MinIO)",
"env_prefix": "S3",
"default_port": 9000,
},
"search": {
"name": "Full-Text Search",
"description": "Meilisearch for fast search",
"env_prefix": "MEILI",
"default_port": 7700,
},
"pubsub": {
"name": "Pub/Sub Messaging",
"description": "NATS for event streaming",
"env_prefix": "NATS",
"default_port": 4222,
},
"tasks": {
"name": "Workflow Engine",
"description": "Temporal for durable workflows",
"env_prefix": "TEMPORAL",
"default_port": 7233,
},
"queues": {
"name": "Job Queues",
"description": "Distributed work queues",
"env_prefix": "QUEUE",
"default_port": 6379,
},
"cron": {
"name": "Scheduled Jobs",
"description": "Cron-based job scheduling",
"env_prefix": "CRON",
"default_port": 6379,
},
"functions": {
"name": "Serverless Functions",
"description": "Nuclio function runtime",
"env_prefix": "NUCLIO",
"default_port": 8070,
},
}
def get_api_key() -> Optional[str]:
"""Get Hanzo API key from env or auth file."""
if os.getenv("HANZO_API_KEY"):
return os.getenv("HANZO_API_KEY")
auth_file = Path.home() / ".hanzo" / "auth.json"
if auth_file.exists():
try:
auth = json.loads(auth_file.read_text())
return auth.get("api_key")
except Exception:
pass
return None
def get_infra_config() -> dict:
"""Load infrastructure configuration."""
config_file = Path.home() / ".hanzo" / "infra.json"
if config_file.exists():
try:
return json.loads(config_file.read_text())
except Exception:
pass
return {"services": {}}
def save_infra_config(config: dict):
"""Save infrastructure configuration."""
config_dir = Path.home() / ".hanzo"
config_dir.mkdir(exist_ok=True)
config_file = config_dir / "infra.json"
config_file.write_text(json.dumps(config, indent=2))
@click.group(name="infra")
def infra_group():
"""Manage Hanzo infrastructure services.
Provision and connect to managed infrastructure:
\b
Services:
vector - Qdrant vector database
kv - Redis/Valkey key-value store
documentdb - MongoDB document database
storage - S3/MinIO object storage
search - Meilisearch full-text search
pubsub - NATS messaging
tasks - Temporal workflows
queues - Distributed job queues
cron - Scheduled jobs
functions - Nuclio serverless
\b
Examples:
hanzo infra list # Show available services
hanzo infra provision vector # Provision Qdrant
hanzo infra connect # Get connection URLs
hanzo infra env # Export as env vars
hanzo infra status # Check health
"""
pass
@infra_group.command()
def list():
"""List available infrastructure services."""
config = get_infra_config()
provisioned = config.get("services", {})
table = Table(title="Hanzo Infrastructure Services", box=box.ROUNDED)
table.add_column("Service", style="cyan")
table.add_column("Name", style="white")
table.add_column("Status", style="green")
table.add_column("Description", style="dim")
for key, info in SERVICES.items():
status = "✓ Provisioned" if key in provisioned else "○ Available"
style = "green" if key in provisioned else "dim"
table.add_row(key, info["name"], f"[{style}]{status}[/{style}]", info["description"])
console.print(table)
console.print()
console.print("[dim]Use 'hanzo infra provision <service>' to provision a service[/dim]")
@infra_group.command()
@click.argument("service", type=click.Choice(SERVICE_NAMES))
@click.option("--tier", type=click.Choice(["free", "pro", "enterprise"]), default="free", help="Service tier")
@click.option("--region", default="us-west-2", help="Deployment region")
def provision(service: str, tier: str, region: str):
"""Provision an infrastructure service on Hanzo Cloud."""
api_key = get_api_key()
if not api_key:
console.print("[red]Not authenticated. Run 'hanzo auth login' first.[/red]")
return
info = SERVICES[service]
console.print(f"[cyan]Provisioning {info['name']}...[/cyan]")
try:
with httpx.Client(timeout=60) as client:
resp = client.post(
f"{HANZO_API_URL}/v1/infra/provision",
headers={"Authorization": f"Bearer {api_key}"},
json={
"service": service,
"tier": tier,
"region": region,
},
)
if resp.status_code == 401:
console.print("[red]Authentication failed. Run 'hanzo auth login'.[/red]")
return
if resp.status_code == 402:
console.print("[yellow]Upgrade required for this tier.[/yellow]")
console.print("Visit https://hanzo.ai/pricing to upgrade.")
return
if resp.status_code >= 400:
console.print(f"[red]Error: {resp.text}[/red]")
return
data = resp.json()
# Save to config
config = get_infra_config()
config["services"][service] = {
"id": data.get("id"),
"url": data.get("url"),
"host": data.get("host"),
"port": data.get("port"),
"credentials": data.get("credentials", {}),
"tier": tier,
"region": region,
}
save_infra_config(config)
console.print(f"[green]✓ {info['name']} provisioned successfully![/green]")
console.print()
console.print(f"[cyan]Connection URL:[/cyan] {data.get('url')}")
console.print()
console.print("[dim]Run 'hanzo infra env' to export environment variables[/dim]")
except httpx.ConnectError:
console.print("[red]Could not connect to Hanzo API.[/red]")
console.print("[dim]Check your network connection or try again later.[/dim]")
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
@infra_group.command()
@click.argument("service", type=click.Choice(SERVICE_NAMES), required=False)
def connect(service: Optional[str]):
"""Show connection details for provisioned services."""
config = get_infra_config()
services = config.get("services", {})
if not services:
console.print("[yellow]No services provisioned yet.[/yellow]")
console.print("Run 'hanzo infra provision <service>' to get started.")
return
if service:
if service not in services:
console.print(f"[yellow]{service} not provisioned.[/yellow]")
return
services = {service: services[service]}
for svc_name, svc_config in services.items():
info = SERVICES[svc_name]
panel = Panel(
f"[cyan]URL:[/cyan] {svc_config.get('url', 'N/A')}\n"
f"[cyan]Host:[/cyan] {svc_config.get('host', 'N/A')}\n"
f"[cyan]Port:[/cyan] {svc_config.get('port', 'N/A')}\n"
f"[cyan]Tier:[/cyan] {svc_config.get('tier', 'free')}\n"
f"[cyan]Region:[/cyan] {svc_config.get('region', 'N/A')}",
title=f"[bold]{info['name']}[/bold]",
border_style="cyan",
)
console.print(panel)
@infra_group.command()
@click.option("--shell", type=click.Choice(["bash", "zsh", "fish", "powershell"]), default="bash")
@click.option("--export", "do_export", is_flag=True, help="Print export statements")
def env(shell: str, do_export: bool):
"""Show environment variables for provisioned services."""
config = get_infra_config()
services = config.get("services", {})
if not services:
console.print("[yellow]No services provisioned.[/yellow]")
return
env_vars = []
for svc_name, svc_config in services.items():
info = SERVICES[svc_name]
prefix = info["env_prefix"]
if svc_config.get("url"):
env_vars.append((f"{prefix}_URL", svc_config["url"]))
if svc_config.get("host"):
env_vars.append((f"{prefix}_HOST", svc_config["host"]))
if svc_config.get("port"):
env_vars.append((f"{prefix}_PORT", str(svc_config["port"])))
creds = svc_config.get("credentials", {})
if creds.get("api_key"):
env_vars.append((f"{prefix}_API_KEY", creds["api_key"]))
if creds.get("password"):
env_vars.append((f"{prefix}_PASSWORD", creds["password"]))
if creds.get("username"):
env_vars.append((f"{prefix}_USERNAME", creds["username"]))
if do_export:
# Print export statements
if shell in ("bash", "zsh"):
for key, value in env_vars:
console.print(f'export {key}="{value}"')
elif shell == "fish":
for key, value in env_vars:
console.print(f'set -gx {key} "{value}"')
elif shell == "powershell":
for key, value in env_vars:
console.print(f'$env:{key} = "{value}"')
else:
# Show as table
table = Table(title="Environment Variables", box=box.ROUNDED)
table.add_column("Variable", style="cyan")
table.add_column("Value", style="green")
for key, value in env_vars:
# Mask sensitive values
if "KEY" in key or "PASSWORD" in key or "SECRET" in key:
display = value[:8] + "..." if len(value) > 8 else "***"
else:
display = value
table.add_row(key, display)
console.print(table)
console.print()
console.print(f"[dim]Run 'hanzo infra env --export' to get export statements[/dim]")
@infra_group.command()
@click.argument("service", type=click.Choice(SERVICE_NAMES), required=False)
def status(service: Optional[str]):
"""Check health status of provisioned services."""
config = get_infra_config()
services = config.get("services", {})
if not services:
console.print("[yellow]No services provisioned.[/yellow]")
return
if service:
if service not in services:
console.print(f"[yellow]{service} not provisioned.[/yellow]")
return
services = {service: services[service]}
api_key = get_api_key()
table = Table(title="Service Health", box=box.ROUNDED)
table.add_column("Service", style="cyan")
table.add_column("Status", style="white")
table.add_column("Latency", style="dim")
with httpx.Client(timeout=10) as client:
for svc_name, svc_config in services.items():
info = SERVICES[svc_name]
try:
resp = client.get(
f"{HANZO_API_URL}/v1/infra/{svc_config.get('id')}/health",
headers={"Authorization": f"Bearer {api_key}"} if api_key else {},
)
if resp.status_code == 200:
data = resp.json()
status_str = "[green]● Healthy[/green]"
latency = f"{data.get('latency_ms', '?')}ms"
else:
status_str = "[yellow]○ Unknown[/yellow]"
latency = "-"
except Exception:
status_str = "[red]✗ Unreachable[/red]"
latency = "-"
table.add_row(info["name"], status_str, latency)
console.print(table)
@infra_group.command()
@click.argument("service", type=click.Choice(SERVICE_NAMES))
@click.option("--force", is_flag=True, help="Skip confirmation")
def destroy(service: str, force: bool):
"""Destroy a provisioned service."""
config = get_infra_config()
services = config.get("services", {})
if service not in services:
console.print(f"[yellow]{service} not provisioned.[/yellow]")
return
info = SERVICES[service]
if not force:
if not Confirm.ask(f"[red]Destroy {info['name']}? This cannot be undone.[/red]"):
console.print("Cancelled.")
return
api_key = get_api_key()
if not api_key:
console.print("[red]Not authenticated.[/red]")
return
try:
with httpx.Client(timeout=30) as client:
svc_id = services[service].get("id")
resp = client.delete(
f"{HANZO_API_URL}/v1/infra/{svc_id}",
headers={"Authorization": f"Bearer {api_key}"},
)
if resp.status_code >= 400:
console.print(f"[red]Error: {resp.text}[/red]")
return
# Remove from config
del config["services"][service]
save_infra_config(config)
console.print(f"[green]✓ {info['name']} destroyed.[/green]")
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
@infra_group.command()
def init():
"""Initialize infrastructure from hanzo.yaml config file."""
config_paths = [
Path.cwd() / "hanzo.yaml",
Path.cwd() / "hanzo.yml",
Path.cwd() / ".hanzo.yaml",
]
config_file = None
for p in config_paths:
if p.exists():
config_file = p
break
if not config_file:
console.print("[yellow]No hanzo.yaml found in current directory.[/yellow]")
console.print()
console.print("Create one with:")
console.print()
console.print("[cyan]# hanzo.yaml[/cyan]")
console.print("infra:")
console.print(" vector: true")
console.print(" kv: true")
console.print(" search: true")
return
import yaml
try:
config = yaml.safe_load(config_file.read_text())
except Exception as e:
console.print(f"[red]Error parsing {config_file}: {e}[/red]")
return
infra_config = config.get("infra", {})
if not infra_config:
console.print("[yellow]No 'infra' section in config file.[/yellow]")
return
console.print(f"[cyan]Initializing infrastructure from {config_file}...[/cyan]")
for service, enabled in infra_config.items():
if service in SERVICES and enabled:
console.print(f" Provisioning {service}...")
# Would call provision logic here
console.print("[green]✓ Infrastructure initialized![/green]")