mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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:
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Hanzo CLI — unified command-line interface for the Hanzo platform."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Hanzo CLI — IAM subcommands."""
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Hanzo CLI — KMS subcommands."""
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
"""Hanzo CLI — PaaS subcommands."""
|
||||
@@ -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"))
|
||||
@@ -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]")
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -12,7 +12,7 @@ from hanzo_iam.models import (
|
||||
UserInfo,
|
||||
)
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.1.0"
|
||||
|
||||
__all__ = [
|
||||
# Clients
|
||||
|
||||
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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,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" }
|
||||
|
||||
Reference in New Issue
Block a user