mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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]")
|
||||
+615
-129
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
|
||||
@@ -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 {}
|
||||
Reference in New Issue
Block a user