Files
workflows-py/operator/dev.py

370 lines
10 KiB
Python
Executable File

#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["click"]
# ///
"""
Local development environment for cloud_llama_deploy.
up — create/ensure cluster and start tilt
down — tear down tilt resources, retaining data
down --delete — also delete the kind cluster (kind target only)
Targets:
kind — (default) creates a kind cluster
docker-desktop — uses Docker Desktop's built-in Kubernetes
"""
import os
import subprocess
import sys
from pathlib import Path
from textwrap import dedent
import click
PROJECT_ROOT = Path(__file__).parent.parent.absolute()
NAMESPACE = "llama-agents"
APPS_NAMESPACE = "llama-agents-apps"
TARGETS = ("kind", "docker-desktop")
K8S_CONTEXTS = {
"kind": "kind-kind",
"docker-desktop": "docker-desktop",
}
def run(
cmd: list[str], check: bool = True, capture: bool = False
) -> subprocess.CompletedProcess:
result = subprocess.run(
cmd, check=False, capture_output=capture, text=True, cwd=PROJECT_ROOT
)
if check and result.returncode != 0:
if capture and result.stderr:
print(result.stderr, file=sys.stderr)
sys.exit(1)
return result
# ---------------------------------------------------------------------------
# kind helpers
# ---------------------------------------------------------------------------
def kind_cluster_exists() -> bool:
result = run(["kind", "get", "clusters"], check=False, capture=True)
return "kind" in result.stdout
def ensure_kind_cluster() -> None:
if kind_cluster_exists():
result = run(
["kind", "export", "kubeconfig", "--name", "kind"],
check=False,
capture=True,
)
if result.returncode == 0:
return
print("Cluster 'kind' exists but is broken, recreating...")
run(["kind", "delete", "cluster", "--name", "kind"])
print("Creating kind cluster 'kind'...")
kind_config = dedent("""\
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 80
hostPort: 8090
protocol: TCP
- containerPort: 443
hostPort: 8444
protocol: TCP
""")
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(kind_config)
config_path = f.name
try:
run(
[
"kind",
"create",
"cluster",
"--name",
"kind",
"--config",
config_path,
]
)
finally:
os.unlink(config_path)
install_ingress_controller("kind")
# ---------------------------------------------------------------------------
# docker-desktop helpers
# ---------------------------------------------------------------------------
def ensure_docker_desktop_cluster() -> None:
context = K8S_CONTEXTS["docker-desktop"]
result = run(
["kubectl", "--context", context, "cluster-info"],
check=False,
capture=True,
)
if result.returncode != 0:
print(
f"Kubernetes context '{context}' is not reachable.\n"
"Enable Kubernetes in Docker Desktop → Settings → Kubernetes.",
file=sys.stderr,
)
sys.exit(1)
# Switch to the docker-desktop context
run(["kubectl", "config", "use-context", context])
install_ingress_controller("docker-desktop")
# ---------------------------------------------------------------------------
# shared helpers
# ---------------------------------------------------------------------------
INGRESS_MANIFESTS = {
"kind": "https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml",
"docker-desktop": "https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/cloud/deploy.yaml",
}
def install_ingress_controller(target: str) -> None:
result = run(
["kubectl", "get", "namespace", "ingress-nginx"], check=False, capture=True
)
if result.returncode == 0:
return
print("Installing nginx ingress controller...")
run(["kubectl", "apply", "-f", INGRESS_MANIFESTS[target]])
import time
start = time.time()
while time.time() - start < 10:
result = run(
[
"kubectl",
"get",
"pods",
"--namespace",
"ingress-nginx",
"--selector=app.kubernetes.io/component=controller",
"--no-headers",
],
check=False,
capture=True,
)
if result.returncode == 0 and result.stdout.strip():
break
time.sleep(1)
run(
[
"kubectl",
"wait",
"--namespace",
"ingress-nginx",
"--for=condition=ready",
"pod",
"--selector=app.kubernetes.io/component=controller",
"--timeout=300s",
]
)
target_option = click.option(
"--target",
type=click.Choice(TARGETS),
default=None,
envvar="DEV_TARGET",
help="Kubernetes target cluster. [default: kind]",
)
apps_ns_option = click.option(
"--apps-namespace",
"apps_namespace",
is_flag=False,
flag_value=APPS_NAMESPACE,
default=None,
envvar="DEV_APPS_NAMESPACE",
help=(
"Run in split-namespace mode: put LlamaDeployment CRs and app "
f"resources in this namespace (default: {APPS_NAMESPACE}). "
"Unset = single-namespace mode."
),
)
def resolve_target(ctx: click.Context, target: str | None) -> str:
return target or ctx.obj.get("target") or "kind"
def resolve_apps_namespace(
ctx: click.Context, apps_namespace: str | None
) -> str | None:
return apps_namespace or ctx.obj.get("apps_namespace")
@click.group()
@target_option
@apps_ns_option
@click.pass_context
def cli(ctx: click.Context, target: str | None, apps_namespace: str | None) -> None:
"""Local development environment for cloud_llama_deploy."""
ctx.ensure_object(dict)
ctx.obj["target"] = target
ctx.obj["apps_namespace"] = apps_namespace
@cli.command()
@target_option
@apps_ns_option
@click.pass_context
def up(ctx: click.Context, target: str | None, apps_namespace: str | None) -> None:
"""Create/ensure cluster and start tilt."""
target = resolve_target(ctx, target)
apps_namespace = resolve_apps_namespace(ctx, apps_namespace)
# Check required tools
version_cmds: dict[str, list[str]] = {
"kubectl": ["kubectl", "version", "--client"],
"docker": ["docker", "--version"],
"tilt": ["tilt", "version"],
}
if target == "kind":
version_cmds["kind"] = ["kind", "--version"]
for tool, cmd in version_cmds.items():
if run(cmd, check=False, capture=True).returncode != 0:
print(f"Missing required tool: {tool}", file=sys.stderr)
sys.exit(1)
if target == "kind":
ensure_kind_cluster()
else:
ensure_docker_desktop_cluster()
# Ensure namespaces exist
for ns in [NAMESPACE] + ([apps_namespace] if apps_namespace else []):
result = run(["kubectl", "get", "namespace", ns], check=False, capture=True)
if result.returncode != 0:
run(["kubectl", "create", "namespace", ns])
if not (PROJECT_ROOT / ".env").exists():
print(
"Note: no .env file found. GitHub integration requires GITHUB_APP_PRIVATE_KEY, GITHUB_APP_CLIENT_ID, GITHUB_APP_NAME, GITHUB_APP_SECRET."
)
ingress_port = "8090" if target == "kind" else "80"
print("Starting tilt...")
print(" API: http://localhost:8011")
print(" Tilt UI: http://localhost:10350")
print(f" Ingress: *.127.0.0.1.nip.io:{ingress_port}")
if apps_namespace:
print(f" Apps namespace: {apps_namespace}")
tilt_args = [
"tilt",
"up",
"-f",
str(PROJECT_ROOT / "operator" / "Tiltfile"),
"--",
target,
]
if apps_namespace:
tilt_args += ["--apps-namespace", apps_namespace]
os.execvp("tilt", tilt_args)
@cli.command()
@click.option("--delete", is_flag=True, help="Also delete the kind cluster")
@target_option
@apps_ns_option
@click.pass_context
def down(
ctx: click.Context,
delete: bool,
target: str | None,
apps_namespace: str | None,
) -> None:
"""Tear down tilt resources. Use --delete to also remove the cluster (kind only)."""
target = resolve_target(ctx, target)
apps_namespace = resolve_apps_namespace(ctx, apps_namespace)
tilt_args = [
"tilt",
"down",
"-f",
str(PROJECT_ROOT / "operator" / "Tiltfile"),
"--",
target,
]
if apps_namespace:
tilt_args += ["--apps-namespace", apps_namespace]
run(tilt_args, check=False)
if delete:
if target != "kind":
print("--delete only applies to the kind target", file=sys.stderr)
return
if kind_cluster_exists():
print("Deleting kind cluster 'kind'...")
run(["kind", "delete", "cluster", "--name", "kind"])
@cli.command()
@target_option
@apps_ns_option
@click.pass_context
def status(ctx: click.Context, target: str | None, apps_namespace: str | None) -> None:
"""Show cluster and deployment status."""
target = resolve_target(ctx, target)
apps_namespace = resolve_apps_namespace(ctx, apps_namespace)
context = K8S_CONTEXTS[target]
if target == "kind":
if not kind_cluster_exists():
print("No kind cluster 'kind' found")
return
run(["kind", "export", "kubeconfig", "--name", "kind"])
else:
result = run(
["kubectl", "--context", context, "cluster-info"],
check=False,
capture=True,
)
if result.returncode != 0:
print(f"Kubernetes context '{context}' is not reachable")
return
run(["kubectl", "config", "use-context", context])
run(["kubectl", "get", "pods", "-n", NAMESPACE], check=False)
if apps_namespace and apps_namespace != NAMESPACE:
print()
run(["kubectl", "get", "pods", "-n", apps_namespace], check=False)
if __name__ == "__main__":
cli()