feat: add hanzo-cli unified CLI with IAM, KMS, PaaS management

- New hanzo-cli package (0.1.0) providing the `hanzo` command
- Auth: browser OAuth + ROPC password login against hanzo.id (Casdoor)
- IAM subcommands: users, user, set-password, orgs, apps, sync-app
- KMS subcommands: list, get, set, delete, inject
- PaaS subcommands: orgs, projects, envs, deploy (create/status/logs/redeploy/env/delete)
- PaaS deploy supports both Docker images (--image) and git repos (--repo)
- Sticky org/project/env context for PaaS commands
- PaaS client with IAM→PaaS session exchange and auto-refresh
- hanzo-iam SDK v1.1.0: bearer_token auth, admin helpers, set_password/get_applications/update_application

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-02-18 20:58:43 -08:00
co-authored by Hanzo Dev
parent 60d4a987da
commit 5276a58d19
17 changed files with 2393 additions and 74 deletions
+19
View File
@@ -0,0 +1,19 @@
# hanzo-cli
Unified CLI for the Hanzo platform — IAM, KMS, and PaaS management.
## Install
```bash
pip install hanzo-cli
```
## Usage
```bash
hanzo login
hanzo whoami
hanzo iam users
hanzo kms list
hanzo paas deploy list
```
+3
View File
@@ -0,0 +1,3 @@
"""Hanzo CLI — unified command-line interface for the Hanzo platform."""
__version__ = "0.1.0"
+339
View File
@@ -0,0 +1,339 @@
"""Authentication and token management for Hanzo CLI.
Credential chain:
1. HANZO_IAM_CLIENT_ID + HANZO_IAM_CLIENT_SECRET env vars
2. ~/.hanzo/auth/token.json from `hanzo login`
3. Exit with help message
"""
from __future__ import annotations
import http.server
import json
import os
import secrets
import sys
import threading
import time
import webbrowser
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
import click
from hanzo_iam import IAMClient, IAMConfig, Organization
TOKEN_DIR = Path.home() / ".hanzo" / "auth"
TOKEN_FILE = TOKEN_DIR / "token.json"
DEFAULT_IAM_URL = "https://hanzo.id"
DEFAULT_ORG = "hanzo"
DEFAULT_APP = "app-hanzo"
DEFAULT_CLIENT_ID = "hanzo-app-client-id"
CALLBACK_PORT = 8399
CALLBACK_PATH = "/callback"
def _save_token(data: dict[str, Any]) -> None:
"""Save token data to disk."""
TOKEN_DIR.mkdir(parents=True, exist_ok=True)
TOKEN_FILE.write_text(json.dumps(data, indent=2))
TOKEN_FILE.chmod(0o600)
def _load_token() -> dict[str, Any] | None:
"""Load stored token from disk."""
if not TOKEN_FILE.exists():
return None
try:
return json.loads(TOKEN_FILE.read_text())
except (json.JSONDecodeError, OSError):
return None
def _clear_token() -> None:
"""Remove stored token."""
if TOKEN_FILE.exists():
TOKEN_FILE.unlink()
def _iam_url() -> str:
return os.getenv("HANZO_IAM_URL") or os.getenv("IAM_URL") or DEFAULT_IAM_URL
def _iam_org() -> str:
return os.getenv("HANZO_IAM_ORG") or os.getenv("IAM_ORG") or DEFAULT_ORG
def _iam_app() -> str:
return os.getenv("HANZO_IAM_APP") or os.getenv("IAM_APP") or DEFAULT_APP
def _iam_client_id() -> str:
return (
os.getenv("IAM_CLIENT_ID")
or os.getenv("HANZO_IAM_CLIENT_ID")
or DEFAULT_CLIENT_ID
)
def get_client(ctx: click.Context | None = None) -> IAMClient:
"""Build an IAMClient using the credential chain.
1. Env vars HANZO_IAM_CLIENT_ID + HANZO_IAM_CLIENT_SECRET
2. Stored bearer token from `hanzo login`
3. Exit with instructions
"""
client_id = (
os.getenv("IAM_CLIENT_ID")
or os.getenv("HANZO_IAM_CLIENT_ID")
or ""
)
client_secret = (
os.getenv("IAM_CLIENT_SECRET")
or os.getenv("HANZO_IAM_CLIENT_SECRET")
or ""
)
if client_id and client_secret:
config = IAMConfig(
server_url=_iam_url(),
client_id=client_id,
client_secret=client_secret,
organization=_iam_org(),
application=_iam_app(),
)
return IAMClient(config=config)
# Try stored token
token_data = _load_token()
if token_data and token_data.get("access_token"):
config = IAMConfig(
server_url=token_data.get("server_url", _iam_url()),
client_id=token_data.get("client_id", ""),
client_secret="",
organization=token_data.get("organization", _iam_org()),
application=token_data.get("application", _iam_app()),
)
return IAMClient(
config=config,
bearer_token=token_data["access_token"],
)
click.echo(
"Not authenticated. Run 'hanzo login' or set HANZO_IAM_CLIENT_ID"
" + HANZO_IAM_CLIENT_SECRET environment variables.",
err=True,
)
sys.exit(1)
def get_token_info() -> dict[str, Any] | None:
"""Return stored token info for whoami."""
return _load_token()
# =========================================================================
# Browser OAuth Login Flow
# =========================================================================
class _OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
"""HTTP handler that captures the OAuth callback code."""
code: str | None = None
state: str | None = None
error: str | None = None
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if parsed.path != CALLBACK_PATH:
self.send_response(404)
self.end_headers()
return
qs = parse_qs(parsed.query)
if "error" in qs:
_OAuthCallbackHandler.error = qs["error"][0]
self._respond("Login failed. You can close this window.")
return
_OAuthCallbackHandler.code = qs.get("code", [None])[0]
_OAuthCallbackHandler.state = qs.get("state", [None])[0]
self._respond("Login successful! You can close this window.")
def _respond(self, message: str) -> None:
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
body = (
f"<html><body><h2>{message}</h2>"
"<p>Return to your terminal.</p></body></html>"
)
self.wfile.write(body.encode())
def log_message(self, format: str, *args: Any) -> None:
"""Suppress default request logging."""
def browser_login(port: int = CALLBACK_PORT) -> dict[str, Any]:
"""Run the browser OAuth login flow.
Opens the user's browser to the IAM login page, starts a local HTTP
server to receive the callback, exchanges the code for tokens.
Returns:
Token data dict with access_token, etc.
"""
server_url = _iam_url()
org = _iam_org()
app = _iam_app()
client_id = _iam_client_id()
config = IAMConfig(
server_url=server_url,
client_id=client_id,
client_secret="",
organization=org,
application=app,
)
client = IAMClient(config=config)
redirect_uri = f"http://localhost:{port}{CALLBACK_PATH}"
state = secrets.token_urlsafe(32)
auth_url = client.get_authorization_url(
redirect_uri=redirect_uri,
state=state,
scope="openid profile email",
)
# Reset handler state
_OAuthCallbackHandler.code = None
_OAuthCallbackHandler.state = None
_OAuthCallbackHandler.error = None
server = http.server.HTTPServer(("127.0.0.1", port), _OAuthCallbackHandler)
server.timeout = 120 # 2 minute timeout
click.echo(f"Opening browser to login at {server_url}...")
webbrowser.open(auth_url)
click.echo(f"Waiting for callback on http://localhost:{port}{CALLBACK_PATH}")
# Handle one request (the callback)
while _OAuthCallbackHandler.code is None and _OAuthCallbackHandler.error is None:
server.handle_request()
server.server_close()
if _OAuthCallbackHandler.error:
raise click.ClickException(
f"Login failed: {_OAuthCallbackHandler.error}"
)
if _OAuthCallbackHandler.state != state:
raise click.ClickException("State mismatch — possible CSRF attack.")
code = _OAuthCallbackHandler.code
if not code:
raise click.ClickException("No authorization code received.")
# Exchange code for tokens
tokens = client.exchange_code(code=code, redirect_uri=redirect_uri)
client.close()
token_data = {
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"id_token": tokens.id_token,
"token_type": tokens.token_type,
"expires_in": tokens.expires_in,
"scope": tokens.scope,
"server_url": server_url,
"client_id": client_id,
"organization": org,
"application": app,
"login_time": int(time.time()),
}
_save_token(token_data)
return token_data
# =========================================================================
# Password Login Flow (--no-browser)
# =========================================================================
def password_login(username: str | None = None, password: str | None = None) -> dict[str, Any]:
"""Login with username/password (no browser).
Uses the OAuth2 Resource Owner Password Credentials (ROPC) grant
to get a JWT directly from the token endpoint.
Returns:
Token data dict with access_token, etc.
"""
import httpx
server_url = _iam_url()
org = _iam_org()
app = _iam_app()
client_id = _iam_client_id()
client_secret = (
os.getenv("IAM_CLIENT_SECRET")
or os.getenv("HANZO_IAM_CLIENT_SECRET")
or ""
)
if not username:
username = click.prompt("Username or email")
if not password:
password = click.prompt("Password", hide_input=True)
# Use ROPC grant to get tokens directly
resp = httpx.post(
f"{server_url}/api/login/oauth/access_token",
data={
"grant_type": "password",
"client_id": client_id,
"client_secret": client_secret,
"username": username,
"password": password,
"scope": "openid profile email",
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=30.0,
)
data = resp.json()
if "error" in data:
raise click.ClickException(data.get("error_description", data["error"]))
access_token = data.get("access_token", "")
if not access_token:
raise click.ClickException("No access token in response")
token_data = {
"access_token": access_token,
"refresh_token": data.get("refresh_token", ""),
"id_token": data.get("id_token", ""),
"token_type": data.get("token_type", "Bearer"),
"expires_in": data.get("expires_in", 0),
"scope": data.get("scope", ""),
"server_url": server_url,
"client_id": client_id,
"organization": org,
"application": app,
"login_time": int(time.time()),
}
_save_token(token_data)
return token_data
def logout() -> None:
"""Clear stored credentials."""
_clear_token()
+125
View File
@@ -0,0 +1,125 @@
"""Hanzo CLI — unified command-line interface for the Hanzo platform.
Usage:
hanzo --version
hanzo login [--no-browser] [--port N]
hanzo logout
hanzo whoami
hanzo iam <subcommand>
hanzo kms <subcommand>
hanzo paas <subcommand>
"""
from __future__ import annotations
import click
from rich.console import Console
from rich.table import Table
from hanzo_cli import __version__
from hanzo_cli.auth import (
browser_login,
get_token_info,
logout as do_logout,
password_login,
)
console = Console()
@click.group()
@click.version_option(version=__version__, prog_name="hanzo")
@click.pass_context
def main(ctx: click.Context) -> None:
"""Hanzo CLI — manage IAM, secrets, and deployments."""
ctx.ensure_object(dict)
# =========================================================================
# Auth commands
# =========================================================================
@main.command()
@click.option("--no-browser", is_flag=True, help="Use password login instead of browser OAuth.")
@click.option("--port", default=8399, help="Local callback port for browser login.")
@click.pass_context
def login(ctx: click.Context, no_browser: bool, port: int) -> None:
"""Authenticate with Hanzo IAM."""
try:
if no_browser:
token_data = password_login()
else:
token_data = browser_login(port=port)
console.print(f"[green]Logged in successfully.[/green]")
if token_data.get("access_token"):
# Decode the token subject for display
at = token_data["access_token"]
console.print(f"Token stored at ~/.hanzo/auth/token.json")
except Exception as e:
console.print(f"[red]Login failed:[/red] {e}")
raise SystemExit(1)
@main.command()
def logout() -> None:
"""Clear stored credentials."""
do_logout()
console.print("Logged out. Token removed.")
@main.command()
def whoami() -> None:
"""Show current authentication status."""
token_data = get_token_info()
if not token_data:
console.print("[yellow]Not logged in.[/yellow] Run 'hanzo login'.")
raise SystemExit(1)
table = Table(title="Current Session")
table.add_column("Field", style="cyan")
table.add_column("Value", style="white")
table.add_row("Server", token_data.get("server_url", "—"))
table.add_row("Organization", token_data.get("organization", "—"))
table.add_row("Application", token_data.get("application", "—"))
table.add_row("Client ID", token_data.get("client_id", "—"))
# Try to decode the token for user info
access_token = token_data.get("access_token", "")
if access_token:
try:
import jwt
# Decode without verification just to show user info
claims = jwt.decode(access_token, options={"verify_signature": False})
table.add_row("User", claims.get("name", claims.get("sub", "—")))
table.add_row("Email", claims.get("email", "—"))
table.add_row("Owner", claims.get("owner", "—"))
except Exception:
table.add_row("Token", f"{access_token[:20]}...")
login_time = token_data.get("login_time")
if login_time:
from datetime import datetime, timezone
dt = datetime.fromtimestamp(login_time, tz=timezone.utc)
table.add_row("Login Time", dt.isoformat())
console.print(table)
# =========================================================================
# Register subgroups
# =========================================================================
from hanzo_cli.iam.commands import iam # noqa: E402
from hanzo_cli.kms.commands import kms # noqa: E402
from hanzo_cli.paas.commands import paas # noqa: E402
main.add_command(iam)
main.add_command(kms)
main.add_command(paas)
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
"""Hanzo CLI — IAM subcommands."""
+261
View File
@@ -0,0 +1,261 @@
"""Hanzo CLI — IAM subcommands.
Usage:
hanzo iam users — List users
hanzo iam user <name> — Get user details
hanzo iam set-password <user> [pw] — Set password (prompt if no pw)
hanzo iam orgs — List organizations
hanzo iam apps — List applications
hanzo iam sync-app <name> [--init-data] — Sync redirect URIs
"""
from __future__ import annotations
import json
import click
from rich.console import Console
from rich.table import Table
from hanzo_cli.auth import get_client
console = Console()
@click.group()
def iam() -> None:
"""Manage Hanzo IAM — users, orgs, apps, passwords."""
# =========================================================================
# Users
# =========================================================================
@iam.command("users")
def list_users() -> None:
"""List all users in the organization."""
client = get_client()
try:
users = client.get_users()
finally:
client.close()
if not users:
console.print("[yellow]No users found.[/yellow]")
return
table = Table(title=f"Users ({len(users)})")
table.add_column("Name", style="cyan")
table.add_column("Display Name", style="white")
table.add_column("Email", style="white")
table.add_column("Admin", style="yellow")
table.add_column("Online", style="green")
for u in users:
table.add_row(
u.name,
u.display_name or "—",
u.email or "—",
"yes" if u.is_admin else "",
"yes" if u.is_online else "",
)
console.print(table)
@iam.command("user")
@click.argument("name")
def get_user(name: str) -> None:
"""Get details for a specific user."""
client = get_client()
try:
user = client.get_user(name)
finally:
client.close()
table = Table(title=f"User: {user.owner}/{user.name}")
table.add_column("Field", style="cyan")
table.add_column("Value", style="white")
table.add_row("ID", user.id or "—")
table.add_row("Owner", user.owner)
table.add_row("Name", user.name)
table.add_row("Display Name", user.display_name or "—")
table.add_row("Email", user.email or "—")
table.add_row("Phone", user.phone or "—")
table.add_row("Type", user.type or "—")
table.add_row("Admin", "yes" if user.is_admin else "no")
table.add_row("Deleted", "yes" if user.is_deleted else "no")
table.add_row("Forbidden", "yes" if user.is_forbidden else "no")
table.add_row("Online", "yes" if user.is_online else "no")
table.add_row("Email Verified", "yes" if user.email_verified else "no")
table.add_row("Balance", str(user.balance))
table.add_row("Created", user.created_time or "—")
table.add_row("Updated", user.updated_time or "—")
if user.roles:
table.add_row("Roles", ", ".join(user.roles))
if user.groups:
table.add_row("Groups", ", ".join(user.groups))
console.print(table)
# =========================================================================
# Password Management
# =========================================================================
@iam.command("set-password")
@click.argument("user")
@click.argument("password", required=False)
def set_password(user: str, password: str | None) -> None:
"""Set a user's password. Prompts if password not given."""
if not password:
password = click.prompt("New password", hide_input=True, confirmation_prompt=True)
client = get_client()
org = client.config.organization
try:
result = client.set_password(
user_owner=org,
user_name=user,
new_password=password,
)
finally:
client.close()
status = result.get("status", "unknown")
if status == "ok":
console.print(f"[green]Password set for {org}/{user}.[/green]")
else:
msg = result.get("msg", "Unknown error")
console.print(f"[red]Failed:[/red] {msg}")
raise SystemExit(1)
# =========================================================================
# Organizations
# =========================================================================
@iam.command("orgs")
def list_orgs() -> None:
"""List all organizations."""
client = get_client()
try:
orgs = client.get_organizations()
finally:
client.close()
if not orgs:
console.print("[yellow]No organizations found.[/yellow]")
return
table = Table(title=f"Organizations ({len(orgs)})")
table.add_column("Name", style="cyan")
table.add_column("Display Name", style="white")
table.add_column("Website", style="white")
for o in orgs:
table.add_row(
o.get("name", "—"),
o.get("displayName", "—"),
o.get("websiteUrl", "—"),
)
console.print(table)
# =========================================================================
# Applications
# =========================================================================
@iam.command("apps")
def list_apps() -> None:
"""List all applications."""
client = get_client()
try:
apps = client.get_applications()
finally:
client.close()
if not apps:
console.print("[yellow]No applications found.[/yellow]")
return
table = Table(title=f"Applications ({len(apps)})")
table.add_column("Owner", style="white")
table.add_column("Name", style="cyan")
table.add_column("Display Name", style="white")
table.add_column("Client ID", style="dim")
table.add_column("Redirect URIs", style="white")
for app in apps:
uris = ", ".join(app.redirect_uris) if app.redirect_uris else "—"
table.add_row(
app.owner,
app.name,
app.display_name or "—",
app.client_id[:16] + "..." if len(app.client_id) > 16 else app.client_id,
uris,
)
console.print(table)
@iam.command("sync-app")
@click.argument("name")
@click.option("--init-data", is_flag=True, help="Initialize default redirect URIs.")
def sync_app(name: str, init_data: bool) -> None:
"""Sync an application's redirect URIs.
Reads the application, optionally initializes default URIs,
and updates it back.
"""
client = get_client()
try:
# Get all apps and find the one we want
apps = client.get_applications()
target = None
for app in apps:
if app.name == name:
target = app
break
if target is None:
console.print(f"[red]Application '{name}' not found.[/red]")
raise SystemExit(1)
if init_data:
# Add standard development redirect URIs if not present
default_uris = [
"http://localhost:3000/callback",
"http://localhost:3000/api/auth/callback/hanzo",
"http://localhost:8399/callback",
]
existing = set(target.redirect_uris)
added = []
for uri in default_uris:
if uri not in existing:
target.redirect_uris.append(uri)
added.append(uri)
if added:
result = client.update_application(target)
console.print(f"[green]Added {len(added)} redirect URIs to {name}:[/green]")
for uri in added:
console.print(f" + {uri}")
else:
console.print(f"All default URIs already present in {name}.")
else:
console.print(f"Application: {target.owner}/{target.name}")
console.print(f"Client ID: {target.client_id}")
console.print(f"Redirect URIs:")
for uri in target.redirect_uris:
console.print(f" - {uri}")
finally:
client.close()
+1
View File
@@ -0,0 +1 @@
"""Hanzo CLI — KMS subcommands."""
+232
View File
@@ -0,0 +1,232 @@
"""Hanzo CLI — KMS subcommands for secret management.
Usage:
hanzo kms list <project> <env> — List secrets
hanzo kms get <project> <env> <name> — Get secret value
hanzo kms set <project> <env> <name> <value> — Create or update secret
hanzo kms delete <project> <env> <name> — Delete a secret
hanzo kms inject <project> <env> — Print export statements
"""
from __future__ import annotations
import click
from rich.console import Console
from rich.table import Table
console = Console()
def _get_kms_client():
"""Build a KMS client from environment or stored auth."""
import os
from hanzo_kms import ClientSettings, KMSClient
# KMS has its own auth (Universal Auth with client_id/secret)
# Check for dedicated KMS env vars first
kms_url = os.getenv("HANZO_KMS_URL", "https://kms.hanzo.ai")
client_id = os.getenv("HANZO_KMS_CLIENT_ID", "")
client_secret = os.getenv("HANZO_KMS_CLIENT_SECRET", "")
if client_id and client_secret:
from hanzo_kms import AuthenticationOptions, UniversalAuthMethod
settings = ClientSettings(
site_url=kms_url,
auth=AuthenticationOptions(
universal_auth=UniversalAuthMethod(
client_id=client_id,
client_secret=client_secret,
)
),
)
return KMSClient(settings=settings)
# Fall back to default env-based construction
return KMSClient()
@click.group()
def kms() -> None:
"""Manage secrets via Hanzo KMS."""
@kms.command("list")
@click.argument("project")
@click.argument("env")
@click.option("--path", default="/", help="Secret path prefix.")
def list_secrets(project: str, env: str, path: str) -> None:
"""List all secrets in a project environment."""
client = _get_kms_client()
try:
secrets = client.list_secrets(
project_id=project,
environment=env,
path=path,
)
finally:
client.close()
if not secrets:
console.print("[yellow]No secrets found.[/yellow]")
return
table = Table(title=f"Secrets: {project} / {env} ({path})")
table.add_column("Key", style="cyan")
table.add_column("Value", style="dim")
table.add_column("Version", style="white", justify="right")
table.add_column("Updated", style="white")
for s in secrets:
# Mask the value — show first 4 chars then ***
val = s.secret_value
masked = f"{val[:4]}***" if len(val) > 4 else "***"
updated = str(s.updated_at)[:19] if s.updated_at else "—"
table.add_row(s.secret_key, masked, str(s.version), updated)
console.print(table)
@kms.command("get")
@click.argument("project")
@click.argument("env")
@click.argument("name")
@click.option("--path", default="/", help="Secret path prefix.")
@click.option("--reveal", is_flag=True, help="Show full value (default: masked).")
def get_secret(project: str, env: str, name: str, path: str, reveal: bool) -> None:
"""Get a single secret's value."""
client = _get_kms_client()
try:
secret = client.get_secret(
project_id=project,
environment=env,
secret_name=name,
path=path,
)
finally:
client.close()
if reveal:
console.print(secret.secret_value)
else:
val = secret.secret_value
masked = f"{val[:4]}***" if len(val) > 4 else "***"
table = Table(title=f"{name}")
table.add_column("Field", style="cyan")
table.add_column("Value", style="white")
table.add_row("Key", secret.secret_key)
table.add_row("Value", masked)
table.add_row("Version", str(secret.version))
table.add_row("Type", secret.type)
table.add_row("Environment", secret.environment)
if secret.secret_comment:
table.add_row("Comment", secret.secret_comment)
console.print(table)
@kms.command("set")
@click.argument("project")
@click.argument("env")
@click.argument("name")
@click.argument("value", required=False)
@click.option("--path", default="/", help="Secret path prefix.")
@click.option("--comment", default=None, help="Secret comment.")
def set_secret(
project: str, env: str, name: str, value: str | None, path: str, comment: str | None,
) -> None:
"""Create or update a secret. Prompts for value if not given."""
if not value:
value = click.prompt("Secret value", hide_input=True)
client = _get_kms_client()
try:
# Try update first, create if it doesn't exist
try:
secret = client.update_secret(
project_id=project,
environment=env,
secret_name=name,
secret_value=value,
)
console.print(f"[green]Updated[/green] {name} (v{secret.version})")
except Exception:
kwargs = {}
if comment:
kwargs["secret_comment"] = comment
secret = client.create_secret(
project_id=project,
environment=env,
secret_name=name,
secret_value=value,
**kwargs,
)
console.print(f"[green]Created[/green] {name}")
finally:
client.close()
@kms.command("delete")
@click.argument("project")
@click.argument("env")
@click.argument("name")
@click.option("--path", default="/", help="Secret path prefix.")
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
def delete_secret(project: str, env: str, name: str, path: str, yes: bool) -> None:
"""Delete a secret."""
if not yes:
click.confirm(f"Delete secret '{name}' from {project}/{env}?", abort=True)
client = _get_kms_client()
try:
client.delete_secret(
project_id=project,
environment=env,
secret_name=name,
path=path,
)
finally:
client.close()
console.print(f"[green]Deleted[/green] {name}")
@kms.command("inject")
@click.argument("project")
@click.argument("env")
@click.option("--path", default="/", help="Secret path prefix.")
@click.option("--format", "fmt", type=click.Choice(["export", "dotenv", "json"]), default="export")
def inject_secrets(project: str, env: str, path: str, fmt: str) -> None:
"""Print secrets as export statements, dotenv, or JSON.
Pipe to `eval` or redirect to .env file:
hanzo kms inject myproject production | source /dev/stdin
hanzo kms inject myproject production --format dotenv > .env
"""
import json
client = _get_kms_client()
try:
secrets = client.list_secrets(
project_id=project,
environment=env,
path=path,
)
finally:
client.close()
if not secrets:
return
if fmt == "export":
for s in secrets:
# Shell-safe quoting
val = s.secret_value.replace("'", "'\\''")
click.echo(f"export {s.secret_key}='{val}'")
elif fmt == "dotenv":
for s in secrets:
val = s.secret_value.replace('"', '\\"')
click.echo(f'{s.secret_key}="{val}"')
elif fmt == "json":
data = {s.secret_key: s.secret_value for s in secrets}
click.echo(json.dumps(data, indent=2))
+1
View File
@@ -0,0 +1 @@
"""Hanzo CLI — PaaS subcommands."""
+317
View File
@@ -0,0 +1,317 @@
"""Thin HTTP client for the Hanzo PaaS REST API.
Wraps the /v1/* endpoints exposed by the platform service
(paas/platform/routes/*).
Auth flow:
1. ``hanzo login`` stores an IAM token at ~/.hanzo/auth/token.json
2. On first PaaS call we exchange that IAM token for a PaaS session
via POST /v1/auth/login {provider:"hanzo", accessToken:"<IAM JWT>"}
3. PaaS returns {at, rt} — we cache these in ~/.hanzo/paas/session.json
4. Subsequent calls use the PaaS ``at`` in the Authorization header
Environment variables:
HANZO_PAAS_URL — PaaS API base URL (default: https://platform.hanzo.ai)
"""
from __future__ import annotations
import json
import os
import sys
import time
from pathlib import Path
from typing import Any
import click
import httpx
from hanzo_cli.auth import get_token_info
DEFAULT_PAAS_URL = "https://platform.hanzo.ai"
SESSION_DIR = Path.home() / ".hanzo" / "paas"
SESSION_FILE = SESSION_DIR / "session.json"
def _save_session(data: dict[str, Any]) -> None:
SESSION_DIR.mkdir(parents=True, exist_ok=True)
SESSION_FILE.write_text(json.dumps(data, indent=2))
SESSION_FILE.chmod(0o600)
def _load_session() -> dict[str, Any] | None:
if not SESSION_FILE.exists():
return None
try:
return json.loads(SESSION_FILE.read_text())
except (json.JSONDecodeError, OSError):
return None
class PaaSClient:
"""Lightweight wrapper around the PaaS platform REST API."""
def __init__(
self,
base_url: str | None = None,
access_token: str | None = None,
refresh_token: str | None = None,
):
self._base_url = (base_url or os.getenv("HANZO_PAAS_URL", DEFAULT_PAAS_URL)).rstrip("/")
self._at = access_token
self._rt = refresh_token
self._http: httpx.Client | None = None
# -- bootstrap -----------------------------------------------------------
@classmethod
def from_auth(cls) -> PaaSClient:
"""Build a client, exchanging the IAM token for a PaaS session.
1. Check for a cached PaaS session
2. If none, exchange the IAM token via POST /v1/auth/login
3. Cache the PaaS session for reuse
"""
base_url = os.getenv("HANZO_PAAS_URL", DEFAULT_PAAS_URL).rstrip("/")
# Try cached PaaS session first
session = _load_session()
if session and session.get("at"):
inst = cls(
base_url=base_url,
access_token=session["at"],
refresh_token=session.get("rt"),
)
# Quick health check — if 401, re-login
try:
resp = inst.http.get("/v1/cluster/setup-status")
if resp.status_code != 401:
return inst
except Exception:
pass
inst.close()
# Need the IAM token
token_data = get_token_info()
if not token_data or not token_data.get("access_token"):
click.echo(
"Not authenticated. Run 'hanzo login' first.",
err=True,
)
sys.exit(1)
iam_token = token_data["access_token"]
# Exchange IAM token for PaaS session
with httpx.Client(base_url=base_url, timeout=30.0) as tmp:
resp = tmp.post(
"/v1/auth/login",
json={
"provider": "hanzo",
"accessToken": iam_token,
},
headers={
"Content-Type": "application/json",
"User-Agent": "hanzo-cli/0.1",
},
)
if resp.status_code == 401:
click.echo(
"IAM token rejected by PaaS. Try 'hanzo login' again.",
err=True,
)
sys.exit(1)
resp.raise_for_status()
data = resp.json()
at = data.get("at", "")
rt = data.get("rt", "")
if not at:
click.echo("PaaS login succeeded but no session token returned.", err=True)
sys.exit(1)
_save_session({"at": at, "rt": rt, "login_time": int(time.time())})
return cls(base_url=base_url, access_token=at, refresh_token=rt)
@property
def http(self) -> httpx.Client:
if self._http is None:
headers: dict[str, str] = {
"User-Agent": "hanzo-cli/0.1",
"Content-Type": "application/json",
}
if self._at:
headers["Authorization"] = self._at
if self._rt:
headers["Refresh-Token"] = self._rt
self._http = httpx.Client(
base_url=self._base_url,
timeout=30.0,
headers=headers,
)
return self._http
def close(self) -> None:
if self._http:
self._http.close()
self._http = None
# -- helpers -------------------------------------------------------------
def _ok(self, resp: httpx.Response) -> Any:
# PaaS may return refreshed tokens in headers
new_at = resp.headers.get("Access-Token")
new_rt = resp.headers.get("Refresh-Token")
if new_at:
self._at = new_at
_save_session({
"at": new_at,
"rt": new_rt or self._rt or "",
"login_time": int(time.time()),
})
# Rebuild client with new token
self.close()
if resp.status_code >= 400:
try:
err = resp.json()
msg = err.get("error", "")
details = err.get("details", "")
fields = err.get("fields", [])
parts = [f"PaaS error {resp.status_code}"]
if msg:
parts.append(msg)
if details:
parts.append(details)
for f in fields:
parts.append(f" {f.get('param', '?')}: {f.get('msg', '')}")
raise click.ClickException("\n".join(parts))
except (ValueError, KeyError):
pass
resp.raise_for_status()
if not resp.content or resp.status_code == 204:
return {}
return resp.json()
# ========================================================================
# Organizations
# ========================================================================
def list_orgs(self) -> list[dict[str, Any]]:
return self._ok(self.http.get("/v1/org"))
def get_org(self, org_id: str) -> dict[str, Any]:
return self._ok(self.http.get(f"/v1/org/{org_id}"))
# ========================================================================
# Projects
# ========================================================================
def list_projects(self, org_id: str) -> list[dict[str, Any]]:
return self._ok(self.http.get(f"/v1/org/{org_id}/project"))
def get_project(self, org_id: str, project_id: str) -> dict[str, Any]:
return self._ok(self.http.get(f"/v1/org/{org_id}/project/{project_id}"))
# ========================================================================
# Environments
# ========================================================================
def list_envs(self, org_id: str, project_id: str) -> list[dict[str, Any]]:
return self._ok(
self.http.get(f"/v1/org/{org_id}/project/{project_id}/env")
)
# ========================================================================
# Containers (Deployments)
# ========================================================================
def _container_base(self, org_id: str, project_id: str, env_id: str) -> str:
return f"/v1/org/{org_id}/project/{project_id}/env/{env_id}/container"
def list_containers(
self, org_id: str, project_id: str, env_id: str,
) -> list[dict[str, Any]]:
url = self._container_base(org_id, project_id, env_id)
return self._ok(self.http.get(url))
def get_container(
self, org_id: str, project_id: str, env_id: str, container_id: str,
) -> dict[str, Any]:
url = f"{self._container_base(org_id, project_id, env_id)}/{container_id}"
return self._ok(self.http.get(url))
def create_container(
self,
org_id: str,
project_id: str,
env_id: str,
payload: dict[str, Any],
) -> dict[str, Any]:
url = self._container_base(org_id, project_id, env_id)
return self._ok(self.http.post(url, json=payload))
def update_container(
self,
org_id: str,
project_id: str,
env_id: str,
container_id: str,
payload: dict[str, Any],
) -> dict[str, Any]:
url = f"{self._container_base(org_id, project_id, env_id)}/{container_id}"
return self._ok(self.http.put(url, json=payload))
def delete_container(
self, org_id: str, project_id: str, env_id: str, container_id: str,
) -> dict[str, Any]:
url = f"{self._container_base(org_id, project_id, env_id)}/{container_id}"
return self._ok(self.http.delete(url))
def redeploy_container(
self, org_id: str, project_id: str, env_id: str, container_id: str,
) -> dict[str, Any]:
url = (
f"{self._container_base(org_id, project_id, env_id)}"
f"/{container_id}/redeploy"
)
return self._ok(self.http.post(url))
def get_container_pods(
self, org_id: str, project_id: str, env_id: str, container_id: str,
) -> list[dict[str, Any]]:
url = (
f"{self._container_base(org_id, project_id, env_id)}"
f"/{container_id}/pods"
)
return self._ok(self.http.get(url))
def get_container_logs(
self, org_id: str, project_id: str, env_id: str, container_id: str,
) -> Any:
url = (
f"{self._container_base(org_id, project_id, env_id)}"
f"/{container_id}/logs"
)
return self._ok(self.http.get(url))
def get_container_events(
self, org_id: str, project_id: str, env_id: str, container_id: str,
) -> list[dict[str, Any]]:
url = (
f"{self._container_base(org_id, project_id, env_id)}"
f"/{container_id}/events"
)
return self._ok(self.http.get(url))
# ========================================================================
# Cluster
# ========================================================================
def cluster_info(self) -> dict[str, Any]:
return self._ok(self.http.get("/v1/cluster/info"))
def cluster_templates(self) -> list[dict[str, Any]]:
return self._ok(self.http.get("/v1/cluster/templates"))
+655
View File
@@ -0,0 +1,655 @@
"""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 list
hanzo paas deploy create <name> --repo <url> [--branch main]
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"]))
@_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,
org: str | None,
project: str | None,
env_id: str | None,
) -> None:
"""Create a new container deployment from a Docker image or git repo.
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
"""
if not image and not repo:
console.print("[red]Specify --image or --repo.[/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()]
client.update_container(
org_id, project_id, env_id, cid, {"variables": merged},
)
console.print(f"[green]Set {len(new_vars)} variable(s) on '{name}'.[/green]")
finally:
client.close()
# ── deploy delete ───────────────────────────────────────────────────────
@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]")
+57
View File
@@ -0,0 +1,57 @@
"""PaaS context management — remember current org/project/env.
Stores selection at ~/.hanzo/paas/context.json so you don't have to
pass --org / --project / --env on every command.
Set with: hanzo paas use --org myorg --project myproj --env production
Clear with: hanzo paas use --clear
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
CONTEXT_DIR = Path.home() / ".hanzo" / "paas"
CONTEXT_FILE = CONTEXT_DIR / "context.json"
def load_context() -> dict[str, Any]:
"""Load stored PaaS context."""
if not CONTEXT_FILE.exists():
return {}
try:
return json.loads(CONTEXT_FILE.read_text())
except (json.JSONDecodeError, OSError):
return {}
def save_context(ctx: dict[str, Any]) -> None:
"""Persist PaaS context."""
CONTEXT_DIR.mkdir(parents=True, exist_ok=True)
CONTEXT_FILE.write_text(json.dumps(ctx, indent=2))
CONTEXT_FILE.chmod(0o600)
def clear_context() -> None:
"""Remove stored context."""
if CONTEXT_FILE.exists():
CONTEXT_FILE.unlink()
def resolve(
cli_org: str | None,
cli_project: str | None,
cli_env: str | None,
) -> tuple[str | None, str | None, str | None]:
"""Merge CLI flags over stored context.
Returns (org_id, project_id, env_id) — any may be None.
"""
ctx = load_context()
return (
cli_org or ctx.get("org_id"),
cli_project or ctx.get("project_id"),
cli_env or ctx.get("env_id"),
)
+57
View File
@@ -0,0 +1,57 @@
[project]
name = "hanzo-cli"
version = "0.1.0"
description = "Hanzo unified CLI — IAM, KMS, Deploy"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.9"
authors = [{ name = "Hanzo AI", email = "[email protected]" }]
keywords = ["hanzo", "cli", "iam", "devops"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"click>=8.0",
"rich>=13.0",
"httpx>=0.25.0",
"pyjwt>=2.8.0",
"hanzo-iam>=1.0.0",
"hanzo-kms>=1.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"ruff>=0.5.0",
]
[project.scripts]
hanzo = "hanzo_cli.cli:main"
[project.urls]
Homepage = "https://hanzo.ai"
Repository = "https://github.com/hanzoai/python-sdk"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["hanzo_cli"]
[tool.ruff]
target-version = "py39"
line-length = 100
[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "C4", "UP"]
ignore = ["E501"]
+1 -1
View File
@@ -12,7 +12,7 @@ from hanzo_iam.models import (
UserInfo,
)
__version__ = "1.0.0"
__version__ = "1.1.0"
__all__ = [
# Clients
+175 -45
View File
@@ -55,6 +55,7 @@ class AsyncIAMClient:
client_secret: str | None = None,
org: Organization = Organization.HANZO,
config: IAMConfig | None = None,
bearer_token: str | None = None,
):
"""Initialize async IAM client.
@@ -63,6 +64,7 @@ class AsyncIAMClient:
client_secret: OAuth2 client secret (or from env)
org: Organization enum (determines IAM URL)
config: Full configuration (overrides other args)
bearer_token: Bearer token for admin API auth (alternative to client_id/secret)
"""
# Avoid circular import
from hanzo_iam.client import IAMClient
@@ -81,6 +83,7 @@ class AsyncIAMClient:
certificate=env_config.certificate,
)
self._bearer_token = bearer_token
self._http: httpx.AsyncClient | None = None
self._jwks_client: PyJWKClient | None = None
self._openid_config: dict[str, Any] | None = None
@@ -103,6 +106,25 @@ class AsyncIAMClient:
)
return self._http
# =========================================================================
# Admin Auth Helpers
# =========================================================================
def _admin_params(self) -> dict[str, str]:
"""Return query params for admin API auth (empty if using bearer token)."""
if self._bearer_token:
return {}
return {
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
def _admin_headers(self) -> dict[str, str]:
"""Return extra headers for admin API auth (Authorization if bearer token)."""
if self._bearer_token:
return {"Authorization": f"Bearer {self._bearer_token}"}
return {}
# =========================================================================
# OIDC Discovery
# =========================================================================
@@ -414,12 +436,13 @@ class AsyncIAMClient:
"""
params = {
"id": f"{self._config.organization}/{user_id}",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
http = await self._get_http()
response = await http.get("/api/get-user", params=params)
response = await http.get(
"/api/get-user", params=params, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -436,12 +459,13 @@ class AsyncIAMClient:
"""
params = {
"owner": self._config.organization,
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
http = await self._get_http()
response = await http.get("/api/get-users", params=params)
response = await http.get(
"/api/get-users", params=params, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -468,14 +492,15 @@ class AsyncIAMClient:
"""
params: dict[str, Any] = {
"owner": owner or self._config.organization,
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
if is_online is not None:
params["isOnline"] = str(is_online).lower()
http = await self._get_http()
response = await http.get("/api/get-user-count", params=params)
response = await http.get(
"/api/get-user-count", params=params, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -519,15 +544,11 @@ class AsyncIAMClient:
async def _modify_user(self, action: str, user: User) -> User:
"""Modify user via API."""
params = {
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
http = await self._get_http()
response = await http.post(
f"/api/{action}",
params=params,
params=self._admin_params(),
headers=self._admin_headers(),
json=user.model_dump(by_alias=True, exclude_none=True),
)
response.raise_for_status()
@@ -546,12 +567,13 @@ class AsyncIAMClient:
"""
params = {
"id": f"{self._config.organization}/{self._config.application}",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
http = await self._get_http()
response = await http.get("/api/get-application", params=params)
response = await http.get(
"/api/get-application", params=params, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -572,12 +594,13 @@ class AsyncIAMClient:
"""
params = {
"owner": "admin",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
http = await self._get_http()
response = await http.get("/api/get-organizations", params=params)
response = await http.get(
"/api/get-organizations", params=params, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -597,12 +620,13 @@ class AsyncIAMClient:
"""
params = {
"id": f"admin/{name}",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
http = await self._get_http()
response = await http.get("/api/get-organization", params=params)
response = await http.get(
"/api/get-organization", params=params, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -644,14 +668,15 @@ class AsyncIAMClient:
"resourceId": resource_id,
"enforceId": enforce_id,
"owner": owner or self._config.organization,
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
if request:
payload["casbinRequest"] = request
http = await self._get_http()
response = await http.post("/api/enforce", json=payload)
response = await http.post(
"/api/enforce", json=payload, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -686,14 +711,15 @@ class AsyncIAMClient:
"modelId": model_id,
"enforceId": enforce_id,
"owner": owner or self._config.organization,
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
if requests:
payload["casbinRequest"] = requests
http = await self._get_http()
response = await http.post("/api/batch-enforce", json=payload)
response = await http.post(
"/api/batch-enforce", json=payload, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -718,12 +744,13 @@ class AsyncIAMClient:
"""
params = {
"owner": owner or self._config.organization,
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
http = await self._get_http()
response = await http.get("/api/get-roles", params=params)
response = await http.get(
"/api/get-roles", params=params, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -750,12 +777,13 @@ class AsyncIAMClient:
org = owner or self._config.organization
params = {
"id": f"{org}/{role_name}",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
http = await self._get_http()
response = await http.get("/api/get-role", params=params)
response = await http.get(
"/api/get-role", params=params, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -782,12 +810,13 @@ class AsyncIAMClient:
org = owner or self._config.organization
params = {
"id": f"{org}/{username}",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
http = await self._get_http()
response = await http.get("/api/get-user-roles", params=params)
response = await http.get(
"/api/get-user-roles", params=params, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -817,12 +846,13 @@ class AsyncIAMClient:
payload = {
"user": f"{org}/{username}",
"role": f"{org}/{role_name}",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
http = await self._get_http()
response = await http.post("/api/add-user-role", json=payload)
response = await http.post(
"/api/add-user-role", json=payload, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -852,12 +882,13 @@ class AsyncIAMClient:
payload = {
"user": f"{org}/{username}",
"role": f"{org}/{role_name}",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
http = await self._get_http()
response = await http.post("/api/delete-user-role", json=payload)
response = await http.post(
"/api/delete-user-role", json=payload, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -866,6 +897,105 @@ class AsyncIAMClient:
return True
# =========================================================================
# Password Management
# =========================================================================
async def set_password(
self,
user_owner: str,
user_name: str,
new_password: str,
old_password: str = "",
) -> dict[str, Any]:
"""Set or reset a user's password.
Args:
user_owner: Organization that owns the user.
user_name: Username.
new_password: New password to set.
old_password: Current password (empty for admin reset).
Returns:
API response data.
"""
payload = {
"userOwner": user_owner,
"userName": user_name,
"oldPassword": old_password,
"newPassword": new_password,
}
http = await self._get_http()
response = await http.post(
"/api/set-password",
params=self._admin_params(),
headers=self._admin_headers(),
json=payload,
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to set password"))
return data
# =========================================================================
# Application Management
# =========================================================================
async def get_applications(self, owner: str = "admin") -> list[Application]:
"""Get all applications.
Args:
owner: Owner of applications (default: admin).
Returns:
List of Application objects.
"""
params = {
"owner": owner,
**self._admin_params(),
}
http = await self._get_http()
response = await http.get(
"/api/get-applications", params=params, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get applications"))
apps_data = data.get("data", data) or []
return [Application.model_validate(a) for a in apps_data]
async def update_application(self, application: Application) -> dict[str, Any]:
"""Update an application.
Args:
application: Application object with updated fields.
Returns:
API response data.
"""
http = await self._get_http()
response = await http.post(
"/api/update-application",
params=self._admin_params(),
headers=self._admin_headers(),
json=application.model_dump(by_alias=True, exclude_none=True),
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to update application"))
return data
# =========================================================================
# Lifecycle
# =========================================================================
+148 -27
View File
@@ -65,6 +65,7 @@ class IAMClient:
client_secret: str | None = None,
org: Organization = Organization.HANZO,
config: IAMConfig | None = None,
bearer_token: str | None = None,
):
"""Initialize IAM client.
@@ -73,6 +74,7 @@ class IAMClient:
client_secret: OAuth2 client secret (or from env)
org: Organization enum (determines IAM URL)
config: Full configuration (overrides other args)
bearer_token: Bearer token for admin API auth (alternative to client_id/secret)
"""
if config:
self._config = config
@@ -87,6 +89,7 @@ class IAMClient:
certificate=env_config.certificate,
)
self._bearer_token = bearer_token
self._http: httpx.Client | None = None
self._jwks_client: PyJWKClient | None = None
self._openid_config: dict | None = None
@@ -427,6 +430,25 @@ class IAMClient:
response.raise_for_status()
return UserInfo.model_validate(response.json())
# =========================================================================
# Admin Auth Helpers
# =========================================================================
def _admin_params(self) -> dict:
"""Return query params for admin API auth (empty if using bearer token)."""
if self._bearer_token:
return {}
return {
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
def _admin_headers(self) -> dict:
"""Return extra headers for admin API auth (Authorization if bearer token)."""
if self._bearer_token:
return {"Authorization": f"Bearer {self._bearer_token}"}
return {}
# =========================================================================
# User Management (Casdoor Admin API)
# =========================================================================
@@ -442,11 +464,12 @@ class IAMClient:
"""
params = {
"id": f"{self._config.organization}/{user_id}",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
response = self.http.get("/api/get-user", params=params)
response = self.http.get(
"/api/get-user", params=params, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -463,11 +486,12 @@ class IAMClient:
"""
params = {
"owner": self._config.organization,
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
response = self.http.get("/api/get-users", params=params)
response = self.http.get(
"/api/get-users", params=params, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -485,11 +509,12 @@ class IAMClient:
"""
params = {
"id": f"{self._config.organization}/{self._config.application}",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
response = self.http.get("/api/get-application", params=params)
response = self.http.get(
"/api/get-application", params=params, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -537,14 +562,10 @@ class IAMClient:
def _modify_user(self, action: str, user: User) -> dict:
"""Modify user via Casdoor admin API."""
params = {
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
}
response = self.http.post(
f"/api/{action}",
params=params,
params=self._admin_params(),
headers=self._admin_headers(),
json=user.model_dump(by_alias=True, exclude_none=True),
)
response.raise_for_status()
@@ -567,11 +588,12 @@ class IAMClient:
"""
params = {
"owner": "admin",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
response = self.http.get("/api/get-organizations", params=params)
response = self.http.get(
"/api/get-organizations", params=params, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -591,11 +613,12 @@ class IAMClient:
"""
params = {
"id": f"admin/{name}",
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
response = self.http.get("/api/get-organization", params=params)
response = self.http.get(
"/api/get-organization", params=params, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -619,11 +642,12 @@ class IAMClient:
"""
params = {
"owner": owner,
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
response = self.http.get("/api/get-providers", params=params)
response = self.http.get(
"/api/get-providers", params=params, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -647,11 +671,12 @@ class IAMClient:
"""
params = {
"owner": owner or self._config.organization,
"clientId": self._config.client_id,
"clientSecret": self._config.client_secret,
**self._admin_params(),
}
response = self.http.get("/api/get-roles", params=params)
response = self.http.get(
"/api/get-roles", params=params, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
@@ -660,6 +685,102 @@ class IAMClient:
return data.get("data", data) or []
# =========================================================================
# Password Management
# =========================================================================
def set_password(
self,
user_owner: str,
user_name: str,
new_password: str,
old_password: str = "",
) -> dict:
"""Set or reset a user's password.
Args:
user_owner: Organization that owns the user.
user_name: Username.
new_password: New password to set.
old_password: Current password (empty for admin reset).
Returns:
API response data.
"""
payload = {
"userOwner": user_owner,
"userName": user_name,
"oldPassword": old_password,
"newPassword": new_password,
}
response = self.http.post(
"/api/set-password",
params=self._admin_params(),
headers=self._admin_headers(),
json=payload,
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to set password"))
return data
# =========================================================================
# Application Management
# =========================================================================
def get_applications(self, owner: str = "admin") -> list[Application]:
"""Get all applications.
Args:
owner: Owner of applications (default: admin).
Returns:
List of Application objects.
"""
params = {
"owner": owner,
**self._admin_params(),
}
response = self.http.get(
"/api/get-applications", params=params, headers=self._admin_headers(),
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to get applications"))
apps_data = data.get("data", data) or []
return [Application.model_validate(a) for a in apps_data]
def update_application(self, application: Application) -> dict:
"""Update an application.
Args:
application: Application object with updated fields.
Returns:
API response data.
"""
response = self.http.post(
"/api/update-application",
params=self._admin_params(),
headers=self._admin_headers(),
json=application.model_dump(by_alias=True, exclude_none=True),
)
response.raise_for_status()
data = response.json()
if data.get("status") == "error":
raise ValueError(data.get("msg", "Failed to update application"))
return data
# =========================================================================
# Login (Masquerade)
# =========================================================================
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "hanzo-iam"
version = "1.0.0"
version = "1.1.0"
description = "Hanzo IAM SDK - Identity and Access Management for Python"
readme = "README.md"
license = { text = "MIT" }