feat: wire CLI to PaaS API with IAM session exchange

- Add PaaS API client with IAM→session token exchange, auto-refresh,
  and session caching (~/.hanzo/paas_session.json)
- Add `hanzo auth context` for org/project/env selection against live API
- Wire `hanzo run` commands to PaaS container CRUD endpoints
- Wire `hanzo k8s` commands to PaaS cluster and workload endpoints
- Add `hanzo git` commands for provider/repo/branch management
- Fix auth.json token format handling (nested tokens.access_token)
- Extract shared find_container/extract_list helpers to api_client
This commit is contained in:
2026-02-15 18:31:44 -08:00
parent 6505ae6571
commit 19add7aee2
6 changed files with 1982 additions and 212 deletions
+2
View File
@@ -22,6 +22,7 @@ from .commands import (
events,
flow,
fn,
git_provider,
growth,
install,
k8s,
@@ -118,6 +119,7 @@ cli.add_command(env.env_group)
cli.add_command(events.events_group)
cli.add_command(flow.flow_group)
cli.add_command(fn.fn_group)
cli.add_command(git_provider.git_group)
cli.add_command(growth.growth_group)
cli.add_command(iam.iam_group)
cli.add_command(install.install_group)
+259 -13
View File
@@ -52,14 +52,23 @@ class AuthManager:
if os.getenv("HANZO_API_KEY"):
return True
auth = self.load_auth()
return bool(auth.get("api_key") or auth.get("logged_in"))
return bool(
auth.get("api_key")
or auth.get("logged_in")
or auth.get("token")
or auth.get("tokens", {}).get("access_token")
)
def get_api_key(self) -> Optional[str]:
"""Get API key."""
"""Get API key or access token."""
if os.getenv("HANZO_API_KEY"):
return os.getenv("HANZO_API_KEY")
auth = self.load_auth()
return auth.get("api_key")
return (
auth.get("api_key")
or auth.get("token")
or auth.get("tokens", {}).get("access_token")
)
@click.group(name="auth")
@@ -451,6 +460,14 @@ def status(ctx):
elif auth.get("api_key"):
table.add_row("Method", "API Key")
table.add_row("API Key", f"{auth['api_key'][:8]}...")
elif auth.get("token") or auth.get("tokens", {}).get("access_token"):
table.add_row("Method", "IAM OAuth")
token = auth.get("token") or auth.get("tokens", {}).get("access_token", "")
if token:
claims = _decode_jwt_claims(token)
email = claims.get("email", "")
if email:
table.add_row("User", email)
elif auth.get("email"):
table.add_row("Method", "Email/Password")
table.add_row("Email", auth["email"])
@@ -480,26 +497,33 @@ def whoami():
return
auth = auth_mgr.load_auth()
# Create user info panel
lines = []
if auth.get("email"):
lines.append(f"[cyan]Email:[/cyan] {auth['email']}")
# Try to get email from stored data or JWT claims
email = auth.get("email", "")
if not email:
token = auth.get("token") or auth.get("tokens", {}).get("access_token", "")
if token:
claims = _decode_jwt_claims(token)
email = claims.get("email", "")
name = claims.get("displayName") or claims.get("name", "")
if name:
lines.append(f"[cyan]Name:[/cyan] {name}")
if email:
lines.append(f"[cyan]Email:[/cyan] {email}")
if os.getenv("HANZO_API_KEY"):
lines.append("[cyan]API Key:[/cyan] Set via environment")
lines.append("[cyan]Auth:[/cyan] API key (env)")
elif auth.get("api_key"):
lines.append(f"[cyan]API Key:[/cyan] {auth['api_key'][:8]}...")
if auth.get("current_org"):
lines.append(f"[cyan]Organization:[/cyan] {auth['current_org']}")
lines.append(f"[cyan]Auth:[/cyan] API key ({auth['api_key'][:8]}...)")
elif auth.get("token") or auth.get("tokens", {}).get("access_token"):
lines.append("[cyan]Auth:[/cyan] IAM OAuth")
if auth.get("last_login"):
lines.append(f"[cyan]Last Login:[/cyan] {auth['last_login']}")
content = "\n".join(lines) if lines else "[dim]No user information available[/dim]"
console.print(
Panel(content, title="[bold cyan]User Information[/bold cyan]", box=box.ROUNDED)
)
@@ -520,3 +544,225 @@ def set_key(api_key: str):
console.print("[green]✓[/green] API key saved successfully")
console.print("[dim]You can now use Hanzo Cloud services[/dim]")
# ============================================================================
# Context management (org / project / env selection)
# ============================================================================
@auth_group.group(name="context", invoke_without_command=True)
@click.pass_context
def context_group(ctx):
"""Manage active org/project/env context.
\b
Examples:
hanzo auth context # Show current context
hanzo auth context set --org hanzo # Set active org/project/env
hanzo auth context list # List available orgs & projects
"""
if ctx.invoked_subcommand is None:
_print_context()
@context_group.command(name="show")
def context_show():
"""Show current context."""
_print_context()
def _print_context():
from ..utils.api_client import load_context
ctx = load_context()
if not ctx:
console.print("[yellow]No context set.[/yellow]")
console.print("[dim]Run 'hanzo auth context set --org ORG --project PROJECT --env ENV'[/dim]")
return
table = Table(title="Active Context", box=box.ROUNDED)
table.add_column("Property", style="cyan")
table.add_column("Value", style="white")
for key in ("org_id", "org_name", "project_id", "project_name", "env_id", "env_name"):
if ctx.get(key):
label = key.replace("_", " ").title()
table.add_row(label, ctx[key])
console.print(table)
@context_group.command(name="set")
@click.option("--org", "-o", required=True, help="Organization ID or name")
@click.option("--project", "-p", help="Project ID or name")
@click.option("--env", "-e", help="Environment ID or name")
def context_set(org: str, project: str, env: str):
"""Set active org/project/env context.
\b
Examples:
hanzo auth context set --org hanzo
hanzo auth context set --org hanzo --project myapp --env development
"""
from ..utils.api_client import PaaSClient, save_context, org_url, project_url, env_url
try:
client = PaaSClient()
except SystemExit:
return
ctx = {}
# Resolve org
with console.status("Resolving organization..."):
data = client.get(org_url())
if data is None:
return
orgs = data if isinstance(data, list) else data.get("orgs", data.get("data", []))
matched_org = None
for o in orgs:
oid = o.get("_id") or o.get("iid") or o.get("id", "")
oname = o.get("name", "")
if org in (oid, oname):
matched_org = o
break
if not matched_org:
console.print(f"[red]Organization '{org}' not found.[/red]")
return
ctx["org_id"] = matched_org.get("_id") or matched_org.get("iid") or matched_org.get("id")
ctx["org_name"] = matched_org.get("name", org)
console.print(f"[green]✓[/green] Organization: {ctx['org_name']}")
# Resolve project (if given)
if project:
with console.status("Resolving project..."):
data = client.get(project_url(ctx["org_id"]))
if data is None:
save_context(ctx)
return
projects = data if isinstance(data, list) else data.get("projects", data.get("data", []))
matched_proj = None
for p in projects:
pid = p.get("_id") or p.get("iid") or p.get("id", "")
pname = p.get("name", "")
if project in (pid, pname):
matched_proj = p
break
if not matched_proj:
console.print(f"[yellow]Project '{project}' not found. Saving org-only context.[/yellow]")
save_context(ctx)
return
ctx["project_id"] = matched_proj.get("_id") or matched_proj.get("iid") or matched_proj.get("id")
ctx["project_name"] = matched_proj.get("name", project)
console.print(f"[green]✓[/green] Project: {ctx['project_name']}")
# Resolve env (if given and project was resolved)
if env and ctx.get("project_id"):
with console.status("Resolving environment..."):
data = client.get(env_url(ctx["org_id"], ctx["project_id"]))
if data is None:
save_context(ctx)
return
envs = data if isinstance(data, list) else data.get("environments", data.get("data", []))
matched_env = None
for e in envs:
eid = e.get("_id") or e.get("iid") or e.get("id", "")
ename = e.get("name", "")
if env in (eid, ename):
matched_env = e
break
if not matched_env:
console.print(f"[yellow]Environment '{env}' not found. Saving org+project context.[/yellow]")
save_context(ctx)
return
ctx["env_id"] = matched_env.get("_id") or matched_env.get("iid") or matched_env.get("id")
ctx["env_name"] = matched_env.get("name", env)
console.print(f"[green]✓[/green] Environment: {ctx['env_name']}")
save_context(ctx)
console.print("[green]✓[/green] Context saved to ~/.hanzo/context.json")
@context_group.command(name="list")
def context_list():
"""List available orgs, projects, and environments."""
from ..utils.api_client import PaaSClient, load_context, org_url, project_url, env_url
try:
client = PaaSClient()
except SystemExit:
return
current = load_context()
# List orgs
with console.status("Fetching organizations..."):
data = client.get(org_url())
if data is None:
return
orgs = data if isinstance(data, list) else data.get("orgs", data.get("data", []))
table = Table(title="Organizations", box=box.ROUNDED)
table.add_column("", style="green", width=2)
table.add_column("ID", style="cyan")
table.add_column("Name", style="white")
for o in orgs:
oid = o.get("_id") or o.get("iid") or o.get("id", "")
oname = o.get("name", "")
active = "●" if oid == current.get("org_id") else ""
table.add_row(active, oid, oname)
console.print(table)
# List projects for active org
if current.get("org_id"):
with console.status("Fetching projects..."):
data = client.get(project_url(current["org_id"]))
if data:
projects = data if isinstance(data, list) else data.get("projects", data.get("data", []))
if projects:
ptable = Table(title=f"Projects in {current.get('org_name', current['org_id'])}", box=box.ROUNDED)
ptable.add_column("", style="green", width=2)
ptable.add_column("ID", style="cyan")
ptable.add_column("Name", style="white")
for p in projects:
pid = p.get("_id") or p.get("iid") or p.get("id", "")
pname = p.get("name", "")
active = "●" if pid == current.get("project_id") else ""
ptable.add_row(active, pid, pname)
console.print(ptable)
# List envs for active project
if current.get("org_id") and current.get("project_id"):
with console.status("Fetching environments..."):
data = client.get(env_url(current["org_id"], current["project_id"]))
if data:
envs = data if isinstance(data, list) else data.get("environments", data.get("data", []))
if envs:
etable = Table(title=f"Environments in {current.get('project_name', current['project_id'])}", box=box.ROUNDED)
etable.add_column("", style="green", width=2)
etable.add_column("ID", style="cyan")
etable.add_column("Name", style="white")
for e in envs:
eid = e.get("_id") or e.get("iid") or e.get("id", "")
ename = e.get("name", "")
active = "●" if eid == current.get("env_id") else ""
etable.add_row(active, eid, ename)
console.print(etable)
@@ -0,0 +1,316 @@
"""Git provider integration for Hanzo CLI.
Connect git providers (GitHub, GitLab, Bitbucket) and link repos to containers.
"""
import click
from rich import box
from rich.table import Table
from ..utils.output import console
@click.group(name="git")
def git_group():
"""Manage git provider connections and repo links.
\b
Providers:
hanzo git connect github # Connect GitHub account
hanzo git providers # List connected providers
hanzo git disconnect ID # Remove a provider
\b
Repos:
hanzo git repos # List repos from connected provider
hanzo git branches REPO # List branches
hanzo git link REPO # Link repo to container
"""
pass
def _get_client():
from ..utils.api_client import PaaSClient
try:
return PaaSClient(timeout=30)
except SystemExit:
return None
# ============================================================================
# Provider management
# ============================================================================
@git_group.command(name="connect")
@click.argument("provider", type=click.Choice(["github", "gitlab", "bitbucket"]))
@click.option("--token", "-t", help="Personal access token (alternative to OAuth)")
def git_connect(provider, token):
"""Connect a git provider.
\b
Examples:
hanzo git connect github # OAuth flow
hanzo git connect github --token ghp_xxx # PAT
hanzo git connect gitlab --token glpat_xxx
"""
from ..utils.api_client import git_url
client = _get_client()
if not client:
return
if token:
payload = {
"provider": provider,
"accessToken": token,
}
console.print(f"[cyan]Connecting {provider} with token...[/cyan]")
result = client.post(git_url(), payload)
if result is None:
return
pid = result.get("_id") or result.get("id", "")
console.print(f"[green]✓[/green] {provider} connected")
if pid:
console.print(f" Provider ID: {pid}")
else:
# OAuth flow - redirect to PaaS OAuth endpoint
from ..utils.api_client import PLATFORM_API_URL
oauth_url = f"{PLATFORM_API_URL}/v1/user/git/connect/{provider}"
console.print(f"[cyan]Opening browser for {provider} OAuth...[/cyan]")
console.print(f"\n {oauth_url}\n")
try:
import webbrowser
webbrowser.open(oauth_url)
console.print("[dim]Complete the OAuth flow in your browser.[/dim]")
console.print("[dim]Then run 'hanzo git providers' to verify.[/dim]")
except Exception:
console.print("[yellow]Could not open browser. Visit the URL above manually.[/yellow]")
@git_group.command(name="providers")
def git_providers():
"""List connected git providers."""
from ..utils.api_client import git_url
client = _get_client()
if not client:
return
data = client.get(git_url())
if data is None:
return
providers = data if isinstance(data, list) else data.get("providers", data.get("data", []))
if not providers:
console.print("[dim]No git providers connected.[/dim]")
console.print("[dim]Run 'hanzo git connect github' to get started.[/dim]")
return
table = Table(title="Git Providers", box=box.ROUNDED)
table.add_column("ID", style="cyan")
table.add_column("Provider", style="white")
table.add_column("Username", style="green")
table.add_column("Status", style="dim")
for p in providers:
pid = p.get("_id") or p.get("id", "")
prov = p.get("provider", p.get("gitProviderId", ""))
user = p.get("username", p.get("providerUserId", ""))
status = p.get("status", "connected")
table.add_row(str(pid)[:12], prov, user, status)
console.print(table)
@git_group.command(name="disconnect")
@click.argument("provider_id")
@click.option("--force", "-f", is_flag=True, help="Skip confirmation")
def git_disconnect(provider_id, force):
"""Disconnect a git provider."""
from ..utils.api_client import git_url
if not force:
from rich.prompt import Confirm
if not Confirm.ask(f"[red]Disconnect git provider '{provider_id}'?[/red]"):
return
client = _get_client()
if not client:
return
result = client.delete(git_url(provider_id))
if result is None:
return
console.print(f"[green]✓[/green] Git provider disconnected")
# ============================================================================
# Repos & Branches
# ============================================================================
@git_group.command(name="repos")
@click.option("--provider", "-p", help="Provider ID (uses first connected if omitted)")
def git_repos(provider):
"""List repos from connected git provider."""
from ..utils.api_client import git_url
client = _get_client()
if not client:
return
# If no provider specified, find the first one
if not provider:
data = client.get(git_url())
if data is None:
return
providers = data if isinstance(data, list) else data.get("providers", data.get("data", []))
if not providers:
console.print("[yellow]No git providers connected. Run 'hanzo git connect github' first.[/yellow]")
return
provider = str(providers[0].get("_id") or providers[0].get("id", ""))
data = client.get(f"{git_url(provider)}/repo")
if data is None:
return
repos = data if isinstance(data, list) else data.get("repos", data.get("data", []))
if not repos:
console.print("[dim]No repos found.[/dim]")
return
table = Table(title="Repositories", box=box.ROUNDED)
table.add_column("Name", style="cyan")
table.add_column("Full Name", style="white")
table.add_column("Default Branch", style="green")
table.add_column("Private", style="dim")
for r in repos:
rname = r.get("name", "")
rfull = r.get("fullName", r.get("full_name", ""))
rbranch = r.get("defaultBranch", r.get("default_branch", "main"))
rprivate = "Yes" if r.get("private", False) else "No"
table.add_row(rname, rfull, rbranch, rprivate)
console.print(table)
@git_group.command(name="branches")
@click.argument("repo")
@click.option("--provider", "-p", help="Provider ID")
def git_branches(repo, provider):
"""List branches for a repo."""
from ..utils.api_client import git_url
client = _get_client()
if not client:
return
if not provider:
data = client.get(git_url())
if data is None:
return
providers = data if isinstance(data, list) else data.get("providers", data.get("data", []))
if not providers:
console.print("[yellow]No git providers connected.[/yellow]")
return
provider = str(providers[0].get("_id") or providers[0].get("id", ""))
data = client.get(f"{git_url(provider)}/repo/branch", params={"repo": repo})
if data is None:
return
branches = data if isinstance(data, list) else data.get("branches", data.get("data", []))
if not branches:
console.print(f"[dim]No branches found for '{repo}'.[/dim]")
return
table = Table(title=f"Branches for {repo}", box=box.ROUNDED)
table.add_column("Branch", style="cyan")
table.add_column("Default", style="green")
for b in branches:
bname = b.get("name", b) if isinstance(b, dict) else str(b)
is_default = b.get("default", False) if isinstance(b, dict) else False
table.add_row(bname, "Yes" if is_default else "")
console.print(table)
@git_group.command(name="link")
@click.argument("repo")
@click.option("--container", "-c", required=True, help="Container name to link to")
@click.option("--branch", "-b", default="main", help="Branch to deploy from")
@click.option("--provider", "-p", help="Provider ID")
def git_link(repo, container, branch, provider):
"""Link a git repo to a container (sets up webhook for auto-deploy).
\b
Examples:
hanzo git link org/repo --container my-app
hanzo git link org/repo --container api --branch develop
"""
from ..utils.api_client import container_url, require_context, git_url
client = _get_client()
if not client:
return
try:
ctx = require_context()
except SystemExit:
return
# Resolve provider if not given
if not provider:
data = client.get(git_url())
if data is None:
return
providers = data if isinstance(data, list) else data.get("providers", data.get("data", []))
if not providers:
console.print("[yellow]No git providers connected.[/yellow]")
return
provider = str(providers[0].get("_id") or providers[0].get("id", ""))
# Find the container
base_url = container_url(ctx["org_id"], ctx["project_id"], ctx["env_id"])
data = client.get(base_url)
if data is None:
return
containers = data if isinstance(data, list) else data.get("containers", data.get("data", []))
cid = None
for c in containers:
cname = c.get("name", "")
if cname == container:
cid = c.get("_id") or c.get("iid") or c.get("id", "")
break
if not cid:
console.print(f"[yellow]Container '{container}' not found.[/yellow]")
return
# Update container to use repo source
payload = {
"repoOrRegistry": "repo",
"repo": {
"url": repo,
"branch": branch,
"gitProviderId": provider,
"connected": True,
},
}
result = client.put(f"{base_url}/{cid}", payload)
if result is None:
return
console.print(f"[green]✓[/green] Linked '{repo}' -> '{container}'")
console.print(f" Branch: {branch}")
console.print(f" Auto-deploy: enabled")
console.print(f"[dim]Push to '{branch}' to trigger a build.[/dim]")
File diff suppressed because it is too large Load Diff
+430 -70
View File
@@ -1,14 +1,14 @@
"""Hanzo Run - Service lifecycle management CLI.
Modern deployment and lifecycle commands.
Deploy and manage containers on the PaaS platform.
"""
import os
import click
from rich import box
from rich.table import Table
from rich.panel import Panel
from rich.live import Live
from rich.spinner import Spinner
from ..utils.output import console
@@ -40,6 +40,43 @@ def run_group():
pass
# ============================================================================
# Helpers
# ============================================================================
def _get_client():
from ..utils.api_client import PaaSClient
try:
return PaaSClient(timeout=60)
except SystemExit:
return None
def _get_ctx():
from ..utils.api_client import require_context
try:
return require_context()
except SystemExit:
return None
def _container_base():
from ..utils.api_client import container_url
client = _get_client()
if not client:
return None
ctx = _get_ctx()
if not ctx:
return None
url = container_url(ctx["org_id"], ctx["project_id"], ctx["env_id"])
return client, ctx, url
def _find_container(client, base_url: str, name: str):
from ..utils.api_client import find_container
return find_container(client, base_url, name)
# ============================================================================
# Service Deployment
# ============================================================================
@@ -47,79 +84,178 @@ def run_group():
@run_group.command(name="service")
@click.argument("name", required=False)
@click.option("--image", "-i", help="Container image")
@click.option("--source", "-s", help="Source directory (auto-build)")
@click.option("--env", "-e", default="development", help="Target environment")
@click.option("--source", "-s", help="Git repo URL (source build)")
@click.option("--env", "-e", "env_name", help="Override environment (uses context by default)")
@click.option("--replicas", "-r", default=1, help="Number of replicas")
@click.option("--port", "-p", default=8080, help="Service port")
@click.option("--cpu", default="0.5", help="CPU cores")
@click.option("--memory", default="512Mi", help="Memory")
@click.option("--wait", "-w", is_flag=True, help="Wait for deployment")
def run_service(name: str, image: str, source: str, env: str, replicas: int,
port: int, cpu: str, memory: str, wait: bool):
@click.option("--var", "-V", multiple=True, help="Env vars (KEY=value)")
def run_service(name, image, source, env_name, replicas, port, cpu, memory, wait, var):
"""Deploy or update a service.
\b
Examples:
hanzo run service my-api --image my-api:v1.2
hanzo run service --source ./app --env production
hanzo run service --source https://github.com/org/repo
hanzo run service my-api --replicas 3 --cpu 1 --memory 1Gi
hanzo run service my-api -V DB_HOST=db.local -V SECRET=xxx
"""
from ..utils.api_client import container_url
if not name:
# Auto-detect from current directory
import os
name = os.path.basename(os.getcwd())
console.print(f"[cyan]Deploying service '{name}'...[/cyan]")
info = _container_base()
if not info:
return
client, ctx, base_url = info
if source:
console.print(f" Building from: {source}")
elif image:
# Check if container already exists
existing, existing_id = _find_container(client, base_url, name)
# Build env vars list
variables = []
for v in var:
if "=" in v:
k, val = v.split("=", 1)
variables.append({"name": k, "value": val})
if existing:
# Update existing container
console.print(f"[cyan]Updating service '{name}'...[/cyan]")
payload = {}
if image:
payload["repoOrRegistry"] = "registry"
payload["registry"] = {"image": image}
if replicas:
payload["deploymentConfig"] = {"desiredReplicas": replicas}
if port:
payload["networking"] = {"containerPort": port}
if variables:
payload["variables"] = variables
result = client.put(f"{base_url}/{existing_id}", payload)
if result is None:
return
console.print(f"[green]✓[/green] Service '{name}' updated")
else:
# Create new container
console.print(f"[cyan]Deploying service '{name}'...[/cyan]")
payload = {
"name": name,
"type": "deployment",
"networking": {"containerPort": port},
"deploymentConfig": {"desiredReplicas": replicas},
}
if image:
payload["repoOrRegistry"] = "registry"
payload["registry"] = {"image": image}
elif source:
payload["repoOrRegistry"] = "repo"
payload["repo"] = {"url": source, "branch": "main"}
if variables:
payload["variables"] = variables
result = client.post(base_url, payload)
if result is None:
return
console.print(f"[green]✓[/green] Service '{name}' deployed")
cid = result.get("_id") or result.get("iid") or result.get("id", "")
if cid:
console.print(f" Container ID: {cid}")
if image:
console.print(f" Image: {image}")
console.print(f" Environment: {env}")
elif source:
console.print(f" Source: {source}")
console.print(f" Replicas: {replicas}")
console.print(f" Resources: {cpu} CPU, {memory} RAM")
console.print()
console.print(f"[green]✓[/green] Service '{name}' deployed")
console.print(f" URL: https://{name}.{env}.hanzo.ai")
console.print(f" Deployment ID: dep_abc123")
# Trigger build if source
if source and not existing:
cid = (result or {}).get("_id") or (result or {}).get("iid") or (result or {}).get("id", "")
if cid:
console.print("[cyan]Triggering build...[/cyan]")
client.post(f"{base_url}/{cid}/trigger")
if wait:
console.print("[dim]Waiting for deployment to be ready...[/dim]")
import time
for _ in range(60):
cid = (result or {}).get("_id") or existing_id
if cid:
data = client.get(f"{base_url}/{cid}")
if data and data.get("status", {}).get("availableReplicas", 0) >= replicas:
console.print("[green]✓[/green] Deployment ready")
return
time.sleep(2)
console.print("[yellow]Timed out waiting for deployment[/yellow]")
@run_group.command(name="job")
@click.argument("name")
@click.option("--image", "-i", help="Container image")
@click.option("--command", "-c", help="Command to run")
@click.option("--env", "-e", default="development", help="Target environment")
@click.option("--command", "-c", "cmd", help="Command to run")
@click.option("--wait", "-w", is_flag=True, help="Wait for completion")
@click.option("--timeout", "-t", default="1h", help="Job timeout")
def run_job(name: str, image: str, command: str, env: str, wait: bool, timeout: str):
def run_job(name, image, cmd, wait, timeout):
"""Run a one-off job."""
console.print(f"[cyan]Starting job '{name}'...[/cyan]")
console.print(f" Environment: {env}")
console.print(f" Timeout: {timeout}")
console.print()
info = _container_base()
if not info:
return
client, ctx, base_url = info
job_id = "job_xyz789"
payload = {
"name": name,
"type": "cronjob",
"repoOrRegistry": "registry",
}
if image:
payload["registry"] = {"image": image}
if cmd:
payload["deploymentConfig"] = {"command": cmd}
console.print(f"[cyan]Starting job '{name}'...[/cyan]")
result = client.post(base_url, payload)
if result is None:
return
cid = result.get("_id") or result.get("iid") or result.get("id", "")
console.print(f"[green]✓[/green] Job started")
console.print(f" Job ID: {job_id}")
console.print(f" Logs: hanzo run logs {job_id}")
console.print(f" Container ID: {cid}")
console.print(f" Logs: hanzo run logs {name}")
@run_group.command(name="function")
@click.argument("name")
@click.option("--payload", "-p", help="JSON payload")
@click.option("--async", "async_", is_flag=True, help="Invoke asynchronously")
def run_function(name: str, payload: str, async_: bool):
def run_function(name, payload, async_):
"""Invoke a function."""
info = _container_base()
if not info:
return
client, ctx, base_url = info
_, cid = _find_container(client, base_url, name)
if not cid:
console.print(f"[yellow]Function '{name}' not found.[/yellow]")
return
console.print(f"[cyan]Invoking function '{name}'...[/cyan]")
result = client.post(f"{base_url}/{cid}/trigger")
if result is None:
return
if async_:
console.print("[green]✓[/green] Function invoked asynchronously")
console.print(" Request ID: req_abc123")
else:
console.print("[green]✓[/green] Function completed")
console.print(" Duration: 123ms")
console.print(" Result: {}")
console.print("[green]✓[/green] Function invoked")
# ============================================================================
@@ -128,30 +264,82 @@ def run_function(name: str, payload: str, async_: bool):
@run_group.command(name="status")
@click.argument("name", required=False)
@click.option("--env", "-e", default="development", help="Environment")
def run_status(name: str, env: str):
def run_status(name):
"""Check deployment status."""
info = _container_base()
if not info:
return
client, ctx, base_url = info
if name:
# Show single container
container, cid = _find_container(client, base_url, name)
if not container:
console.print(f"[yellow]Service '{name}' not found.[/yellow]")
return
status_info = container.get("status", {})
replicas = container.get("deploymentConfig", {}).get("desiredReplicas", "?")
avail = status_info.get("availableReplicas", "?")
state = status_info.get("conditions", [{}])
state_str = "[green]● Running[/green]" if avail else "[yellow]○ Pending[/yellow]"
img = ""
if container.get("repoOrRegistry") == "registry":
img = (container.get("registry") or {}).get("image", "")
elif container.get("repoOrRegistry") == "repo":
img = (container.get("repo") or {}).get("url", "")
port = (container.get("networking") or {}).get("containerPort", "")
console.print(Panel(
f"[cyan]Service:[/cyan] {name}\n"
f"[cyan]Status:[/cyan] [green]● Running[/green]\n"
f"[cyan]Replicas:[/cyan] 2/2 ready\n"
f"[cyan]Version:[/cyan] v1.2.3\n"
f"[cyan]Uptime:[/cyan] 3d 12h\n"
f"[cyan]URL:[/cyan] https://{name}.{env}.hanzo.ai",
f"[cyan]Service:[/cyan] {container.get('name', name)}\n"
f"[cyan]Status:[/cyan] {state_str}\n"
f"[cyan]Replicas:[/cyan] {avail}/{replicas}\n"
f"[cyan]Image:[/cyan] {img}\n"
f"[cyan]Port:[/cyan] {port}\n"
f"[cyan]ID:[/cyan] {cid}",
title="Service Status",
border_style="cyan"
))
else:
table = Table(title=f"Services ({env})", box=box.ROUNDED)
# List all containers
data = client.get(base_url)
if data is None:
return
from ..utils.api_client import extract_list
containers = extract_list(data, "containers")
if not containers:
console.print("[dim]No services deployed in current context.[/dim]")
console.print("[dim]Deploy with: hanzo run service NAME --image IMAGE[/dim]")
return
table = Table(title="Services", box=box.ROUNDED)
table.add_column("Name", style="cyan")
table.add_column("Type", style="white")
table.add_column("Status", style="green")
table.add_column("Replicas", style="white")
table.add_column("Version", style="dim")
table.add_column("URL", style="dim")
table.add_column("Image/Repo", style="dim")
for c in containers:
cname = c.get("name", "")
ctype = c.get("type", "deployment")
status_info = c.get("status", {})
desired = c.get("deploymentConfig", {}).get("desiredReplicas", "?")
avail = status_info.get("availableReplicas", 0)
state = "Running" if avail else "Pending"
img = ""
if c.get("repoOrRegistry") == "registry":
img = (c.get("registry") or {}).get("image", "")
elif c.get("repoOrRegistry") == "repo":
img = (c.get("repo") or {}).get("url", "")
table.add_row(cname, ctype, state, f"{avail}/{desired}", img)
console.print(table)
console.print("[dim]No services deployed[/dim]")
@run_group.command(name="logs")
@@ -159,11 +347,41 @@ def run_status(name: str, env: str):
@click.option("--follow", "-f", is_flag=True, help="Follow logs")
@click.option("--tail", "-n", default=100, help="Number of lines")
@click.option("--since", "-s", help="Since time (e.g., 1h, 30m)")
def run_logs(name: str, follow: bool, tail: int, since: str):
def run_logs(name, follow, tail, since):
"""View service logs."""
info = _container_base()
if not info:
return
client, ctx, base_url = info
_, cid = _find_container(client, base_url, name)
if not cid:
console.print(f"[yellow]Service '{name}' not found.[/yellow]")
return
params = {"tail": tail}
if since:
params["since"] = since
console.print(f"[cyan]Logs for {name}:[/cyan]")
data = client.get(f"{base_url}/{cid}/logs")
if data is None:
console.print("[dim]No logs available.[/dim]")
return
logs = data.get("logs", data.get("data", ""))
if isinstance(logs, list):
for line in logs[-tail:]:
console.print(line)
elif isinstance(logs, str):
for line in logs.split("\n")[-tail:]:
console.print(line)
else:
console.print("[dim]No logs available.[/dim]")
if follow:
console.print("[dim]Following logs... (Ctrl+C to stop)[/dim]")
console.print("[dim]Follow mode is not yet supported via API. Use 'hanzo k8s logs' for streaming.[/dim]")
# ============================================================================
@@ -175,11 +393,39 @@ def run_logs(name: str, follow: bool, tail: int, since: str):
@click.option("--replicas", "-r", type=int, help="Number of replicas")
@click.option("--cpu", help="CPU cores")
@click.option("--memory", help="Memory")
@click.option("--env", "-e", default="development", help="Environment")
def run_scale(name: str, replicas: int, cpu: str, memory: str, env: str):
def run_scale(name, replicas, cpu, memory):
"""Scale a service."""
info = _container_base()
if not info:
return
client, ctx, base_url = info
_, cid = _find_container(client, base_url, name)
if not cid:
console.print(f"[yellow]Service '{name}' not found.[/yellow]")
return
payload = {}
if replicas is not None:
payload["deploymentConfig"] = {"desiredReplicas": replicas}
if cpu or memory:
resources = {}
if cpu:
resources["cpu"] = cpu
if memory:
resources["memory"] = memory
payload["resources"] = resources
if not payload:
console.print("[yellow]No scaling changes specified.[/yellow]")
return
result = client.put(f"{base_url}/{cid}", payload)
if result is None:
return
changes = []
if replicas:
if replicas is not None:
changes.append(f"replicas={replicas}")
if cpu:
changes.append(f"cpu={cpu}")
@@ -193,28 +439,109 @@ def run_scale(name: str, replicas: int, cpu: str, memory: str, env: str):
@click.argument("name")
@click.option("--from", "from_env", required=True, help="Source environment")
@click.option("--to", "to_env", required=True, help="Target environment")
def run_promote(name: str, from_env: str, to_env: str):
"""Promote service to next environment."""
def run_promote(name, from_env, to_env):
"""Promote service to next environment.
Copies container config from source environment to target.
"""
from ..utils.api_client import PaaSClient, require_context, container_url
try:
client = PaaSClient(timeout=60)
ctx = require_context(("org_id", "project_id"))
except SystemExit:
return
# Find container in source env
src_url = container_url(ctx["org_id"], ctx["project_id"], from_env)
container, _ = _find_container(client, src_url, name)
if not container:
console.print(f"[yellow]Service '{name}' not found in '{from_env}'.[/yellow]")
return
# Create/update in target env
dst_url = container_url(ctx["org_id"], ctx["project_id"], to_env)
existing, existing_id = _find_container(client, dst_url, name)
payload = {
"name": container.get("name", name),
"type": container.get("type", "deployment"),
"repoOrRegistry": container.get("repoOrRegistry", "registry"),
}
if container.get("registry"):
payload["registry"] = container["registry"]
if container.get("repo"):
payload["repo"] = container["repo"]
if container.get("deploymentConfig"):
payload["deploymentConfig"] = container["deploymentConfig"]
if container.get("networking"):
payload["networking"] = container["networking"]
if container.get("variables"):
payload["variables"] = container["variables"]
if existing:
result = client.put(f"{dst_url}/{existing_id}", payload)
else:
result = client.post(dst_url, payload)
if result is None:
return
console.print(f"[green]✓[/green] Promoted '{name}' from {from_env} to {to_env}")
@run_group.command(name="rollback")
@click.argument("name")
@click.option("--version", "-v", help="Version to rollback to")
@click.option("--env", "-e", default="development", help="Environment")
def run_rollback(name: str, version: str, env: str):
@click.option("--version", "-v", help="Version to rollback to (pipeline run)")
def run_rollback(name, version):
"""Rollback to previous deployment."""
info = _container_base()
if not info:
return
client, ctx, base_url = info
_, cid = _find_container(client, base_url, name)
if not cid:
console.print(f"[yellow]Service '{name}' not found.[/yellow]")
return
# Get pipelines to find the previous run
pipelines = client.get(f"{base_url}/{cid}/pipelines")
if pipelines is None:
console.print("[yellow]No pipeline history found.[/yellow]")
return
runs = pipelines if isinstance(pipelines, list) else pipelines.get("runs", pipelines.get("data", []))
if version:
console.print(f"[green]✓[/green] Rolled back '{name}' to version {version}")
# Find specific run
target = None
for r in runs:
if str(r.get("runNumber", "")) == version or r.get("_id", "") == version:
target = r
break
if not target:
console.print(f"[yellow]Pipeline run '{version}' not found.[/yellow]")
return
elif len(runs) >= 2:
target = runs[1] # Previous run
else:
console.print(f"[green]✓[/green] Rolled back '{name}' to previous version")
console.print("[yellow]No previous deployment to rollback to.[/yellow]")
return
# Trigger re-run of that pipeline
console.print(f"[cyan]Rolling back '{name}'...[/cyan]")
result = client.post(f"{base_url}/{cid}/trigger")
if result is None:
return
console.print(f"[green]✓[/green] Rolled back '{name}'")
@run_group.command(name="traffic")
@click.argument("name")
@click.option("--version", "-v", multiple=True, help="Version:weight pairs")
@click.option("--env", "-e", default="development", help="Environment")
def run_traffic(name: str, version: tuple, env: str):
def run_traffic(name, version):
"""Adjust traffic split between versions.
\b
@@ -222,11 +549,44 @@ def run_traffic(name: str, version: tuple, env: str):
hanzo run traffic my-api -v v1:90 -v v2:10
hanzo run traffic my-api -v v2:100 # Full cutover
"""
if version:
console.print(f"[green]✓[/green] Traffic split updated for '{name}':")
for v in version:
ver, weight = v.split(":")
console.print(f" {ver}: {weight}%")
else:
console.print(f"[cyan]Current traffic split for '{name}':[/cyan]")
console.print(" v1.2.3: 100%")
if not version:
console.print("[yellow]Traffic splitting requires PaaS ingress configuration.[/yellow]")
console.print("[dim]Specify version weights: -v v1:90 -v v2:10[/dim]")
return
# Traffic splitting is typically handled at the ingress/service mesh level
console.print(f"[cyan]Updating traffic split for '{name}':[/cyan]")
for v in version:
parts = v.split(":")
if len(parts) == 2:
console.print(f" {parts[0]}: {parts[1]}%")
console.print("[yellow]Traffic splitting requires ingress controller support.[/yellow]")
console.print("[dim]Configure via 'hanzo k8s ingress' or your service mesh.[/dim]")
@run_group.command(name="delete")
@click.argument("name")
@click.option("--force", "-f", is_flag=True, help="Skip confirmation")
def run_delete(name, force):
"""Delete a deployed service."""
info = _container_base()
if not info:
return
client, ctx, base_url = info
_, cid = _find_container(client, base_url, name)
if not cid:
console.print(f"[yellow]Service '{name}' not found.[/yellow]")
return
if not force:
from rich.prompt import Confirm
if not Confirm.ask(f"[red]Delete service '{name}'? This cannot be undone.[/red]"):
return
result = client.delete(f"{base_url}/{cid}")
if result is None:
return
console.print(f"[green]✓[/green] Service '{name}' deleted")
+360
View File
@@ -0,0 +1,360 @@
"""PaaS API client for Hanzo CLI.
Shared HTTP client used by k8s, run, and git commands to talk to
the PaaS platform API (platform.hanzo.ai).
"""
import os
import json
import time
from pathlib import Path
from typing import Optional
import httpx
from .output import console
PLATFORM_API_URL = os.getenv(
"PLATFORM_API_URL",
os.getenv("HANZO_PLATFORM_URL", "https://platform.hanzo.ai"),
)
CONTEXT_FILE = Path.home() / ".hanzo" / "context.json"
AUTH_FILE = Path.home() / ".hanzo" / "auth.json"
SESSION_FILE = Path.home() / ".hanzo" / "paas_session.json"
# ---------------------------------------------------------------------------
# Auth helpers
# ---------------------------------------------------------------------------
def get_iam_token() -> Optional[str]:
"""Get IAM bearer token from env or ~/.hanzo/auth.json."""
token = os.getenv("HANZO_TOKEN") or os.getenv("HANZO_API_KEY")
if token:
return token
if AUTH_FILE.exists():
try:
auth = json.loads(AUTH_FILE.read_text())
# CLI login stores flat "token", worker/IAM stores nested "tokens.access_token"
return (
auth.get("token")
or auth.get("api_key")
or auth.get("tokens", {}).get("access_token")
)
except Exception:
pass
return None
# Keep old name as alias
get_token = get_iam_token
def _load_session() -> dict:
"""Load cached PaaS session tokens."""
if SESSION_FILE.exists():
try:
return json.loads(SESSION_FILE.read_text())
except Exception:
pass
return {}
def _save_session(session: dict):
"""Cache PaaS session tokens."""
SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
SESSION_FILE.write_text(json.dumps(session, indent=2))
def _exchange_iam_for_session(iam_token: str, timeout: int = 15) -> Optional[dict]:
"""Exchange IAM token for PaaS session via POST /v1/auth/login."""
url = f"{PLATFORM_API_URL}/v1/auth/login"
payload = {"provider": "hanzo", "accessToken": iam_token}
try:
with httpx.Client(timeout=timeout) as client:
resp = client.post(url, json=payload)
except Exception as e:
console.print(f"[red]Failed to reach PaaS login: {e}[/red]")
return None
if resp.status_code >= 400:
try:
body = resp.json()
msg = body.get("message") or body.get("error") or resp.text
except Exception:
msg = resp.text
console.print(f"[red]PaaS login failed ({resp.status_code}): {msg}[/red]")
return None
data = resp.json()
at = data.get("at", "")
rt = data.get("rt", "")
if not at:
console.print("[red]PaaS login returned no session token.[/red]")
return None
session = {
"access_token": at,
"refresh_token": rt,
"created_at": time.time(),
"platform_url": PLATFORM_API_URL,
}
_save_session(session)
return session
def _get_session_token(timeout: int = 15) -> Optional[str]:
"""Get a valid PaaS session token, exchanging IAM token if needed."""
# Check env for direct PaaS token override
paas_token = os.getenv("HANZO_PAAS_TOKEN")
if paas_token:
return paas_token
# Check cached session (session tokens are short-lived: 5 min access, 4 hr refresh)
session = _load_session()
if session.get("access_token") and session.get("platform_url") == PLATFORM_API_URL:
age = time.time() - session.get("created_at", 0)
if age < 240: # Use cached if < 4 min old (access token lasts 5 min)
return session["access_token"]
# Need fresh session — get IAM token first
iam_token = get_iam_token()
if not iam_token:
return None
session = _exchange_iam_for_session(iam_token, timeout=timeout)
if session:
return session["access_token"]
return None
def _require_token() -> str:
"""Return PaaS session token or print error and raise SystemExit."""
token = _get_session_token()
if not token:
console.print("[red]Not authenticated. Run 'hanzo auth login' first.[/red]")
raise SystemExit(1)
return token
# ---------------------------------------------------------------------------
# Context helpers (org / project / env selection)
# ---------------------------------------------------------------------------
def load_context() -> dict:
"""Load active org/project/env context from ~/.hanzo/context.json."""
if CONTEXT_FILE.exists():
try:
return json.loads(CONTEXT_FILE.read_text())
except Exception:
pass
return {}
def save_context(ctx: dict):
"""Persist active context."""
CONTEXT_FILE.parent.mkdir(parents=True, exist_ok=True)
CONTEXT_FILE.write_text(json.dumps(ctx, indent=2))
def require_context(fields: tuple = ("org_id", "project_id", "env_id")) -> dict:
"""Return context dict or error if required fields are missing."""
ctx = load_context()
missing = [f for f in fields if not ctx.get(f)]
if missing:
console.print(
f"[red]Missing context: {', '.join(missing)}[/red]\n"
"Run 'hanzo auth context set --org ORG --project PROJECT --env ENV' first."
)
raise SystemExit(1)
return ctx
# ---------------------------------------------------------------------------
# URL builders
# ---------------------------------------------------------------------------
def container_url(
org_id: str,
project_id: str,
env_id: str,
container_id: Optional[str] = None,
) -> str:
base = f"{PLATFORM_API_URL}/v1/org/{org_id}/project/{project_id}/env/{env_id}/container"
if container_id:
return f"{base}/{container_id}"
return base
def org_url(org_id: Optional[str] = None) -> str:
if org_id:
return f"{PLATFORM_API_URL}/v1/org/{org_id}"
return f"{PLATFORM_API_URL}/v1/org"
def project_url(org_id: str, project_id: Optional[str] = None) -> str:
base = f"{PLATFORM_API_URL}/v1/org/{org_id}/project"
if project_id:
return f"{base}/{project_id}"
return base
def env_url(org_id: str, project_id: str, env_id: Optional[str] = None) -> str:
base = f"{PLATFORM_API_URL}/v1/org/{org_id}/project/{project_id}/env"
if env_id:
return f"{base}/{env_id}"
return base
def cluster_url(cluster_name: Optional[str] = None) -> str:
base = f"{PLATFORM_API_URL}/v1/cluster"
if cluster_name:
return f"{base}/{cluster_name}"
return base
def git_url(provider_id: Optional[str] = None) -> str:
base = f"{PLATFORM_API_URL}/v1/user/git"
if provider_id:
return f"{base}/{provider_id}"
return base
# ---------------------------------------------------------------------------
# Response helpers
# ---------------------------------------------------------------------------
def extract_list(data, key: str) -> list:
"""Extract list from API response (handles both direct lists and wrapped objects)."""
if isinstance(data, list):
return data
if isinstance(data, dict):
return data.get(key, data.get("data", []))
return []
def find_container(client, base_url: str, name: str):
"""Find a container by name in the current environment. Returns (data, id)."""
data = client.get(base_url)
if data is None:
return None, None
for c in extract_list(data, "containers"):
cname = c.get("name", c.get("iid", ""))
cid = c.get("_id") or c.get("iid") or c.get("id", "")
if cname == name or cid == name:
return c, cid
return None, None
# ---------------------------------------------------------------------------
# PaaS HTTP client
# ---------------------------------------------------------------------------
class PaaSClient:
"""HTTP client for PaaS API with auto session management."""
def __init__(self, timeout: int = 30):
self.token = _require_token()
self.timeout = timeout
self._refresh_token = _load_session().get("refresh_token", "")
@property
def headers(self) -> dict:
h = {"Authorization": f"Bearer {self.token}"}
if self._refresh_token:
h["Refresh-Token"] = self._refresh_token
return h
def get(self, url: str, **kwargs) -> Optional[dict]:
return self._request("GET", url, **kwargs)
def post(self, url: str, payload: Optional[dict] = None, **kwargs) -> Optional[dict]:
return self._request("POST", url, json=payload, **kwargs)
def put(self, url: str, payload: Optional[dict] = None, **kwargs) -> Optional[dict]:
return self._request("PUT", url, json=payload, **kwargs)
def delete(self, url: str, **kwargs) -> Optional[dict]:
return self._request("DELETE", url, **kwargs)
def _request(self, method: str, url: str, **kwargs) -> Optional[dict]:
try:
with httpx.Client(timeout=self.timeout) as client:
resp = client.request(method, url, headers=self.headers, **kwargs)
# If server refreshed our tokens, save them
new_at = resp.headers.get("Access-Token")
new_rt = resp.headers.get("Refresh-Token")
if new_at:
self.token = new_at
if new_rt:
self._refresh_token = new_rt
_save_session({
"access_token": new_at,
"refresh_token": self._refresh_token,
"created_at": time.time(),
"platform_url": PLATFORM_API_URL,
})
# On 401, try re-auth once
if resp.status_code == 401:
new_token = self._reauth()
if new_token:
resp = client.request(method, url, headers=self.headers, **kwargs)
else:
return self._handle_response(resp)
return self._handle_response(resp)
except httpx.ConnectError:
console.print(f"[red]Could not connect to {PLATFORM_API_URL}[/red]")
return None
except httpx.TimeoutException:
console.print("[red]Request timed out.[/red]")
return None
except Exception as e:
console.print(f"[red]Request error: {e}[/red]")
return None
def _reauth(self) -> Optional[str]:
"""Re-exchange IAM token for a fresh PaaS session."""
iam_token = get_iam_token()
if not iam_token:
return None
session = _exchange_iam_for_session(iam_token, timeout=self.timeout)
if session:
self.token = session["access_token"]
self._refresh_token = session.get("refresh_token", "")
return self.token
return None
@staticmethod
def _handle_response(resp: httpx.Response) -> Optional[dict]:
if resp.status_code == 401:
console.print("[red]Authentication failed. Run 'hanzo auth login'.[/red]")
return None
if resp.status_code == 403:
console.print("[red]Permission denied.[/red]")
return None
if resp.status_code == 404:
console.print("[yellow]Resource not found.[/yellow]")
return None
if resp.status_code >= 400:
try:
body = resp.json()
msg = body.get("message") or body.get("error") or resp.text
except Exception:
msg = resp.text
console.print(f"[red]API error ({resp.status_code}): {msg}[/red]")
return None
if resp.status_code == 204:
return {}
try:
return resp.json()
except Exception:
return {}