initial a2a-pack
This commit is contained in:
1
a2a_pack/cli/__init__.py
Normal file
1
a2a_pack/cli/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""a2a CLI package."""
|
||||
98
a2a_pack/cli/api_client.py
Normal file
98
a2a_pack/cli/api_client.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Thin HTTP client for the control plane API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
def __init__(self, status: int, message: str) -> None:
|
||||
self.status = status
|
||||
super().__init__(f"API {status}: {message}")
|
||||
|
||||
|
||||
class ControlPlaneClient:
|
||||
def __init__(self, api_url: str, token: str | None = None) -> None:
|
||||
self.api_url = api_url.rstrip("/")
|
||||
self.token = token
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
h = {"content-type": "application/json"}
|
||||
if self.token:
|
||||
h["authorization"] = f"bearer {self.token}"
|
||||
return h
|
||||
|
||||
def _request(self, method: str, path: str, **kw: Any) -> Any:
|
||||
url = f"{self.api_url}{path}"
|
||||
with httpx.Client(timeout=30.0) as c:
|
||||
resp = c.request(method, url, headers=self._headers(), **kw)
|
||||
if resp.status_code >= 400:
|
||||
try:
|
||||
detail = resp.json().get("detail", resp.text)
|
||||
except Exception: # noqa: BLE001
|
||||
detail = resp.text
|
||||
raise ApiError(resp.status_code, str(detail))
|
||||
if resp.status_code == 204 or not resp.content:
|
||||
return None
|
||||
return resp.json()
|
||||
|
||||
def signup(self, email: str, password: str) -> dict[str, Any]:
|
||||
return self._request("POST", "/v1/auth/signup", json={"email": email, "password": password})
|
||||
|
||||
def login(self, email: str, password: str) -> dict[str, Any]:
|
||||
return self._request("POST", "/v1/auth/login", json={"email": email, "password": password})
|
||||
|
||||
def me(self) -> dict[str, Any]:
|
||||
return self._request("GET", "/v1/me")
|
||||
|
||||
def register_agent(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
version: str,
|
||||
image: str,
|
||||
public: bool,
|
||||
card: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return self._request(
|
||||
"POST",
|
||||
"/v1/agents",
|
||||
json={
|
||||
"name": name,
|
||||
"description": description,
|
||||
"version": version,
|
||||
"image": image,
|
||||
"public": public,
|
||||
"card": card,
|
||||
},
|
||||
)
|
||||
|
||||
def from_source(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
version: str,
|
||||
public: bool,
|
||||
) -> dict[str, Any]:
|
||||
return self._request(
|
||||
"POST",
|
||||
"/v1/agents/from-source",
|
||||
json={
|
||||
"name": name,
|
||||
"description": description,
|
||||
"version": version,
|
||||
"public": public,
|
||||
},
|
||||
)
|
||||
|
||||
def list_agents(self) -> list[dict[str, Any]]:
|
||||
return self._request("GET", "/v1/agents")
|
||||
|
||||
def get_agent(self, name: str) -> dict[str, Any]:
|
||||
return self._request("GET", f"/v1/agents/{name}")
|
||||
|
||||
def delete_agent(self, name: str) -> None:
|
||||
self._request("DELETE", f"/v1/agents/{name}")
|
||||
68
a2a_pack/cli/credentials.py
Normal file
68
a2a_pack/cli/credentials.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Credentials store at ``~/.a2a/credentials.json``."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_API_URL = "http://api.127-0-0-1.nip.io"
|
||||
|
||||
|
||||
def _config_dir() -> Path:
|
||||
return Path.home() / ".a2a"
|
||||
|
||||
|
||||
def _creds_path() -> Path:
|
||||
return _config_dir() / "credentials.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Credentials:
|
||||
api_url: str
|
||||
token: str
|
||||
email: str
|
||||
|
||||
|
||||
def save(api_url: str, token: str, email: str) -> Path:
|
||||
d = _config_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
path = _creds_path()
|
||||
path.write_text(json.dumps({"api_url": api_url, "token": token, "email": email}))
|
||||
os.chmod(path, 0o600)
|
||||
return path
|
||||
|
||||
|
||||
def load() -> Credentials | None:
|
||||
path = _creds_path()
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return Credentials(
|
||||
api_url=data.get("api_url", DEFAULT_API_URL),
|
||||
token=data["token"],
|
||||
email=data.get("email", ""),
|
||||
)
|
||||
|
||||
|
||||
def clear() -> bool:
|
||||
path = _creds_path()
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def resolve_api_url(override: str | None = None) -> str:
|
||||
if override:
|
||||
return override
|
||||
env = os.environ.get("A2A_API_URL")
|
||||
if env:
|
||||
return env
|
||||
creds = load()
|
||||
if creds is not None:
|
||||
return creds.api_url
|
||||
return DEFAULT_API_URL
|
||||
34
a2a_pack/cli/loader.py
Normal file
34
a2a_pack/cli/loader.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Load an :class:`A2AAgent` subclass from a string entrypoint."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ..agent import A2AAgent
|
||||
|
||||
|
||||
def load_agent_class(entrypoint: str, *, project_dir: Path | None = None) -> type[A2AAgent]:
|
||||
"""Resolve ``module:ClassName`` to an :class:`A2AAgent` subclass.
|
||||
|
||||
If ``project_dir`` is given, it is prepended to ``sys.path`` so a local
|
||||
``agent.py`` can be imported without packaging.
|
||||
"""
|
||||
if ":" not in entrypoint:
|
||||
raise ValueError(
|
||||
f"entrypoint must be 'module:ClassName' (got {entrypoint!r})"
|
||||
)
|
||||
module_name, class_name = entrypoint.split(":", 1)
|
||||
|
||||
if project_dir is not None:
|
||||
path = str(project_dir.resolve())
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
module = importlib.import_module(module_name)
|
||||
obj = getattr(module, class_name, None)
|
||||
if obj is None:
|
||||
raise AttributeError(f"{module_name} has no attribute {class_name!r}")
|
||||
if not isinstance(obj, type) or not issubclass(obj, A2AAgent):
|
||||
raise TypeError(f"{entrypoint} is not an A2AAgent subclass")
|
||||
return obj
|
||||
430
a2a_pack/cli/main.py
Normal file
430
a2a_pack/cli/main.py
Normal file
@@ -0,0 +1,430 @@
|
||||
"""``a2a`` CLI: scaffold, validate, build, deploy."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
import yaml
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
from . import credentials
|
||||
from .api_client import ApiError, ControlPlaneClient
|
||||
from .loader import load_agent_class
|
||||
from .manifests import INGRESS_HOST_TEMPLATE, NAMESPACE, render_manifests
|
||||
|
||||
app = typer.Typer(
|
||||
add_completion=False,
|
||||
no_args_is_help=True,
|
||||
help="Build, package, and deploy A2A agents.",
|
||||
)
|
||||
console = Console()
|
||||
|
||||
DEFAULT_REGISTRY_HOST = "localhost:30500"
|
||||
SDK_PYPI_PACKAGE = "a2a_pack"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# helpers #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _fail(msg: str, code: int = 1) -> None:
|
||||
console.print(f"[bold red]error:[/] {msg}")
|
||||
raise typer.Exit(code)
|
||||
|
||||
|
||||
def _read_yaml(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
_fail(f"missing {path.name} (run `a2a init` first)")
|
||||
return yaml.safe_load(path.read_text()) or {}
|
||||
|
||||
|
||||
def _render_template(template: str, /, **vars: Any) -> str:
|
||||
text = (
|
||||
resources.files("a2a_pack.cli.templates")
|
||||
.joinpath(template)
|
||||
.read_text(encoding="utf-8")
|
||||
)
|
||||
# tiny mustache-style substitution; avoids pulling Jinja just for {{ x }}
|
||||
for k, v in vars.items():
|
||||
text = text.replace("{{ " + k + " }}", str(v))
|
||||
return text
|
||||
|
||||
|
||||
def _slug_to_class(slug: str) -> str:
|
||||
parts = re.split(r"[-_\s]+", slug.strip())
|
||||
return "".join(p[:1].upper() + p[1:].lower() for p in parts if p) or "MyAgent"
|
||||
|
||||
|
||||
def _git_short_sha(project: Path) -> str | None:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "-C", str(project), "rev-parse", "--short=12", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
return out or None
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return None
|
||||
|
||||
|
||||
def _sdk_source_dir() -> Path:
|
||||
"""Locate the on-disk a2a_pack source for inclusion in the build context."""
|
||||
pkg_dir = Path(__file__).resolve().parents[1] # .../a2a_pack
|
||||
project_root = pkg_dir.parent # .../apps/a2a
|
||||
if not (project_root / "pyproject.toml").exists():
|
||||
_fail(f"could not find a2a-pack source root from {pkg_dir}")
|
||||
return project_root
|
||||
|
||||
|
||||
def _run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||
console.print(f"[dim]$ {' '.join(cmd)}[/]")
|
||||
return subprocess.run(cmd, check=True, text=True, **kwargs)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# auth #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _client(api: str | None = None) -> ControlPlaneClient:
|
||||
api_url = credentials.resolve_api_url(api)
|
||||
creds = credentials.load()
|
||||
token = creds.token if creds is not None else None
|
||||
return ControlPlaneClient(api_url, token=token)
|
||||
|
||||
|
||||
@app.command()
|
||||
def signup(
|
||||
email: str = typer.Option(..., "--email", "-e", prompt=True),
|
||||
password: str = typer.Option(
|
||||
..., "--password", "-p", prompt=True, hide_input=True, confirmation_prompt=True
|
||||
),
|
||||
api: str = typer.Option(credentials.DEFAULT_API_URL, "--api"),
|
||||
) -> None:
|
||||
"""Create an account on the control plane and store the JWT locally."""
|
||||
try:
|
||||
out = ControlPlaneClient(api).signup(email, password)
|
||||
except ApiError as exc:
|
||||
_fail(str(exc))
|
||||
credentials.save(api, out["access_token"], out["user"]["email"])
|
||||
console.print(f"[green]signed up[/] as [cyan]{out['user']['email']}[/] @ {api}")
|
||||
|
||||
|
||||
@app.command()
|
||||
def login(
|
||||
email: str = typer.Option(..., "--email", "-e", prompt=True),
|
||||
password: str = typer.Option(..., "--password", "-p", prompt=True, hide_input=True),
|
||||
api: str = typer.Option(credentials.DEFAULT_API_URL, "--api"),
|
||||
) -> None:
|
||||
"""Authenticate with the control plane and cache the JWT."""
|
||||
try:
|
||||
out = ControlPlaneClient(api).login(email, password)
|
||||
except ApiError as exc:
|
||||
_fail(str(exc))
|
||||
credentials.save(api, out["access_token"], out["user"]["email"])
|
||||
console.print(f"[green]logged in[/] as [cyan]{out['user']['email']}[/]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def logout() -> None:
|
||||
"""Forget the cached JWT."""
|
||||
cleared = credentials.clear()
|
||||
console.print("[green]logged out[/]" if cleared else "(not logged in)")
|
||||
|
||||
|
||||
@app.command()
|
||||
def whoami() -> None:
|
||||
"""Show the currently logged-in user."""
|
||||
creds = credentials.load()
|
||||
if creds is None:
|
||||
_fail("not logged in (run `a2a login` or `a2a signup`)")
|
||||
try:
|
||||
me = _client().me()
|
||||
except ApiError as exc:
|
||||
_fail(str(exc))
|
||||
console.print(f"[cyan]{me['email']}[/] ({creds.api_url})")
|
||||
|
||||
|
||||
@app.command(name="agents")
|
||||
def list_agents(
|
||||
api: str | None = typer.Option(None, "--api"),
|
||||
) -> None:
|
||||
"""List agents visible to the current user."""
|
||||
try:
|
||||
rows = _client(api).list_agents()
|
||||
except ApiError as exc:
|
||||
_fail(str(exc))
|
||||
if not rows:
|
||||
console.print("(no agents)")
|
||||
return
|
||||
for r in rows:
|
||||
url = r.get("url") or "-"
|
||||
console.print(
|
||||
f" [cyan]{r['name']}[/] v{r['version']} [{r['status']}] {url}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# init #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@app.command()
|
||||
def init(
|
||||
name: str = typer.Argument(..., help="Agent / project slug, e.g. research-agent"),
|
||||
description: str = typer.Option("A new A2A agent", "--description", "-d"),
|
||||
target: Path = typer.Option(Path("."), "--target", "-t", help="Parent dir for the new project"),
|
||||
) -> None:
|
||||
"""Scaffold a new agent project."""
|
||||
project = target / name
|
||||
if project.exists():
|
||||
_fail(f"{project} already exists")
|
||||
project.mkdir(parents=True)
|
||||
class_name = _slug_to_class(name)
|
||||
|
||||
files = {
|
||||
"agent.py": _render_template(
|
||||
"agent.py.tmpl",
|
||||
name=name,
|
||||
class_name=class_name,
|
||||
description=description,
|
||||
),
|
||||
"a2a.yaml": _render_template(
|
||||
"a2a.yaml.tmpl", name=name, class_name=class_name
|
||||
),
|
||||
"requirements.txt": _render_template("requirements.txt.tmpl"),
|
||||
"Dockerfile": _render_template(
|
||||
"Dockerfile.tmpl", entrypoint=f"agent:{class_name}"
|
||||
),
|
||||
".dockerignore": _render_template("dockerignore.tmpl"),
|
||||
".gitea/workflows/build.yml": _render_template(
|
||||
"workflow.yml.tmpl", name=name
|
||||
),
|
||||
"deploy/20-deployment.yaml": _render_template(
|
||||
"deployment.yaml.tmpl", name=name
|
||||
),
|
||||
}
|
||||
for relpath, content in files.items():
|
||||
target_path = project / relpath
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_text(content)
|
||||
console.print(f" [green]+[/] {project}/{relpath}")
|
||||
|
||||
console.print(
|
||||
Panel.fit(
|
||||
f"[bold]{name}[/] scaffolded at [cyan]{project}[/]\n\n"
|
||||
"next:\n"
|
||||
f" cd {project}\n"
|
||||
" a2a card # see what your agent declares\n"
|
||||
" a2a deploy # ship it",
|
||||
title="ok",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# validate / card / run #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@app.command()
|
||||
def validate(
|
||||
project: Path = typer.Option(Path("."), "--project", "-p"),
|
||||
) -> None:
|
||||
"""Load the agent and print its Card schema. Exits non-zero on errors."""
|
||||
cfg = _read_yaml(project / "a2a.yaml")
|
||||
cls = load_agent_class(cfg["entrypoint"], project_dir=project)
|
||||
console.print(f"[green]ok[/] {cls.name} v{cls.version} ({len(cls._skills)} skills)")
|
||||
|
||||
|
||||
@app.command()
|
||||
def card(
|
||||
project: Path = typer.Option(Path("."), "--project", "-p"),
|
||||
) -> None:
|
||||
"""Print the Agent Card JSON for the project's agent."""
|
||||
cfg = _read_yaml(project / "a2a.yaml")
|
||||
cls = load_agent_class(cfg["entrypoint"], project_dir=project)
|
||||
console.print_json(cls().card().model_dump_json())
|
||||
|
||||
|
||||
@app.command()
|
||||
def run(
|
||||
entrypoint: str = typer.Option(..., "--entrypoint", "-e", help="module:Class"),
|
||||
host: str = typer.Option("0.0.0.0", "--host"),
|
||||
port: int = typer.Option(8000, "--port"),
|
||||
project: Path = typer.Option(Path("."), "--project", "-p"),
|
||||
) -> None:
|
||||
"""Run the agent's HTTP server locally (used inside the container too)."""
|
||||
from ..serve import serve
|
||||
|
||||
cls = load_agent_class(entrypoint, project_dir=project)
|
||||
serve(cls(), host=host, port=port)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# build / deploy #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _resolve_image_ref(name: str, version: str, project: Path, registry: str) -> str:
|
||||
sha = _git_short_sha(project)
|
||||
tag = f"{version}-{sha}" if sha else version
|
||||
return f"{registry}/agents/{name}:{tag}"
|
||||
|
||||
|
||||
def _stage_build_context(project: Path, dst: Path) -> None:
|
||||
"""Copy project files + bundled SDK source into a temp build dir."""
|
||||
for item in project.iterdir():
|
||||
if item.name in {".git", ".venv", "__pycache__", "_a2a_sdk"}:
|
||||
continue
|
||||
target = dst / item.name
|
||||
if item.is_dir():
|
||||
shutil.copytree(item, target, ignore=shutil.ignore_patterns("__pycache__"))
|
||||
else:
|
||||
shutil.copy2(item, target)
|
||||
sdk_src = _sdk_source_dir()
|
||||
sdk_dst = dst / "_a2a_sdk"
|
||||
shutil.copytree(
|
||||
sdk_src,
|
||||
sdk_dst,
|
||||
ignore=shutil.ignore_patterns(".venv", "dist", "build", "__pycache__", "*.egg-info", ".pytest_cache"),
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def build(
|
||||
project: Path = typer.Option(Path("."), "--project", "-p"),
|
||||
registry: str = typer.Option(DEFAULT_REGISTRY_HOST, "--registry"),
|
||||
push: bool = typer.Option(False, "--push", help="Also push the built image"),
|
||||
) -> None:
|
||||
"""Build (and optionally push) the container image for the agent."""
|
||||
cfg = _read_yaml(project / "a2a.yaml")
|
||||
image = _resolve_image_ref(cfg["name"], cfg["version"], project, registry)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="a2a-build-") as tmp:
|
||||
ctx = Path(tmp)
|
||||
_stage_build_context(project, ctx)
|
||||
_run(["docker", "build", "-t", image, str(ctx)])
|
||||
|
||||
console.print(f"[green]built[/] [cyan]{image}[/]")
|
||||
if push:
|
||||
_run(["docker", "push", image])
|
||||
console.print(f"[green]pushed[/] [cyan]{image}[/]")
|
||||
|
||||
|
||||
def _git_push_source(project: Path, push_url: str) -> None:
|
||||
"""Initialize the project as a git repo (if needed) and push to ``push_url``.
|
||||
|
||||
Idempotent: re-running on an existing repo just commits any changes
|
||||
and pushes.
|
||||
"""
|
||||
git_dir = project / ".git"
|
||||
if not git_dir.exists():
|
||||
_run(["git", "-C", str(project), "init", "-q", "-b", "main"])
|
||||
_run(["git", "-C", str(project), "config", "user.email", "agent@a2a.local"])
|
||||
_run(["git", "-C", str(project), "config", "user.name", "agent"])
|
||||
# Ensure remote
|
||||
have_remote = subprocess.run(
|
||||
["git", "-C", str(project), "remote", "get-url", "origin"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
).returncode == 0
|
||||
if have_remote:
|
||||
_run(["git", "-C", str(project), "remote", "set-url", "origin", push_url])
|
||||
else:
|
||||
_run(["git", "-C", str(project), "remote", "add", "origin", push_url])
|
||||
_run(["git", "-C", str(project), "add", "-A"])
|
||||
status = subprocess.run(
|
||||
["git", "-C", str(project), "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
if status:
|
||||
_run(["git", "-C", str(project), "commit", "-q", "-m", "deploy"])
|
||||
# Pull --rebase first to integrate any auto-bump commits the runner pushed back.
|
||||
subprocess.run(
|
||||
["git", "-C", str(project), "pull", "--rebase", "origin", "main"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
_run(["git", "-C", str(project), "push", "-u", "origin", "main"])
|
||||
|
||||
|
||||
@app.command()
|
||||
def deploy(
|
||||
project: Path = typer.Option(Path("."), "--project", "-p"),
|
||||
public: bool | None = typer.Option(None, "--public/--private"),
|
||||
api: str | None = typer.Option(None, "--api", help="Override control plane URL"),
|
||||
) -> None:
|
||||
"""Push source to gitea; the platform builds + deploys.
|
||||
|
||||
No local docker. The CLI:
|
||||
1. asks the control plane to provision a gitea repo + ArgoCD app
|
||||
2. ``git push``es the project source to that repo
|
||||
The Gitea Actions runner builds the image and ArgoCD reconciles
|
||||
``deploy/`` onto the cluster. Returns the public URL once registered.
|
||||
"""
|
||||
creds = credentials.load()
|
||||
if creds is None:
|
||||
_fail("not logged in (run `a2a signup` or `a2a login`)")
|
||||
|
||||
cfg = _read_yaml(project / "a2a.yaml")
|
||||
cls = load_agent_class(cfg["entrypoint"], project_dir=project)
|
||||
is_public = (
|
||||
public if public is not None else bool(cfg.get("expose", {}).get("public", True))
|
||||
)
|
||||
description = cfg.get("description", cls.description or "")
|
||||
|
||||
if not (project / "deploy").exists() or not (project / ".gitea/workflows/build.yml").exists():
|
||||
_fail(
|
||||
"project missing deploy/ or .gitea/workflows/. "
|
||||
"Re-run `a2a init` or upgrade the scaffold."
|
||||
)
|
||||
|
||||
client = _client(api)
|
||||
console.print(f"[dim]asking control plane to provision repo + argo app...[/]")
|
||||
try:
|
||||
prov = client.from_source(
|
||||
name=cls.name,
|
||||
description=description,
|
||||
version=cfg["version"],
|
||||
public=is_public,
|
||||
)
|
||||
except ApiError as exc:
|
||||
_fail(str(exc))
|
||||
|
||||
console.print(f"[dim]pushing source → {prov['repo_url']}[/]")
|
||||
_git_push_source(project, prov["push_url"])
|
||||
|
||||
summary = {
|
||||
"agent": prov["name"],
|
||||
"repo": prov["repo_url"],
|
||||
"url": prov.get("expected_url"),
|
||||
"card": f"{prov['expected_url']}/.well-known/agent-card" if prov.get("expected_url") else None,
|
||||
"next": "the runner is building. `a2a agents` to check status, or curl the url in ~30s",
|
||||
}
|
||||
console.print(
|
||||
Panel.fit(
|
||||
json.dumps(summary, indent=2),
|
||||
title="[bold green]shipped[/]",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Used as `python -m a2a_pack.cli.main`
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app()
|
||||
133
a2a_pack/cli/manifests.py
Normal file
133
a2a_pack/cli/manifests.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Generate Kubernetes manifests for a deployed agent.
|
||||
|
||||
Targets the existing local cluster: namespace ``agents``, registry at
|
||||
``localhost:30500``, traefik ingress at ``<name>.127-0-0-1.nip.io``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from ..agent import A2AAgent
|
||||
|
||||
NAMESPACE = "agents"
|
||||
INGRESS_HOST_TEMPLATE = "{name}.127-0-0-1.nip.io"
|
||||
|
||||
|
||||
def render_manifests(
|
||||
agent_cls: type[A2AAgent],
|
||||
*,
|
||||
image: str,
|
||||
public: bool = True,
|
||||
) -> str:
|
||||
"""Return a multi-doc YAML string ready for ``kubectl apply -f -``."""
|
||||
rt = agent_cls.runtime()
|
||||
name = agent_cls.name
|
||||
docs: list[dict[str, Any]] = []
|
||||
|
||||
docs.append(
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Namespace",
|
||||
"metadata": {"name": NAMESPACE},
|
||||
}
|
||||
)
|
||||
|
||||
docs.append(
|
||||
{
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "Deployment",
|
||||
"metadata": {
|
||||
"name": name,
|
||||
"namespace": NAMESPACE,
|
||||
"labels": {
|
||||
"app": name,
|
||||
"a2a/version": agent_cls.version,
|
||||
"a2a/lifecycle": rt.lifecycle.value,
|
||||
},
|
||||
},
|
||||
"spec": {
|
||||
"replicas": 1,
|
||||
"selector": {"matchLabels": {"app": name}},
|
||||
"template": {
|
||||
"metadata": {"labels": {"app": name}},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{
|
||||
"name": "agent",
|
||||
"image": image,
|
||||
"imagePullPolicy": "Always",
|
||||
"ports": [{"containerPort": 8000, "name": "http"}],
|
||||
"readinessProbe": {
|
||||
"httpGet": {"path": "/healthz", "port": 8000},
|
||||
"initialDelaySeconds": 2,
|
||||
"periodSeconds": 5,
|
||||
},
|
||||
"livenessProbe": {
|
||||
"httpGet": {"path": "/healthz", "port": 8000},
|
||||
"initialDelaySeconds": 10,
|
||||
"periodSeconds": 15,
|
||||
},
|
||||
"resources": {
|
||||
"requests": {
|
||||
"cpu": rt.resources.cpu,
|
||||
"memory": rt.resources.memory,
|
||||
},
|
||||
"limits": {
|
||||
"cpu": rt.resources.cpu,
|
||||
"memory": rt.resources.memory,
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
docs.append(
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Service",
|
||||
"metadata": {"name": name, "namespace": NAMESPACE},
|
||||
"spec": {
|
||||
"type": "ClusterIP",
|
||||
"selector": {"app": name},
|
||||
"ports": [{"name": "http", "port": 80, "targetPort": 8000}],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if public:
|
||||
docs.append(
|
||||
{
|
||||
"apiVersion": "networking.k8s.io/v1",
|
||||
"kind": "Ingress",
|
||||
"metadata": {"name": name, "namespace": NAMESPACE},
|
||||
"spec": {
|
||||
"rules": [
|
||||
{
|
||||
"host": INGRESS_HOST_TEMPLATE.format(name=name),
|
||||
"http": {
|
||||
"paths": [
|
||||
{
|
||||
"path": "/",
|
||||
"pathType": "Prefix",
|
||||
"backend": {
|
||||
"service": {
|
||||
"name": name,
|
||||
"port": {"number": 80},
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return "---\n".join(yaml.safe_dump(d, sort_keys=False) for d in docs)
|
||||
14
a2a_pack/cli/templates/Dockerfile.tmpl
Normal file
14
a2a_pack/cli/templates/Dockerfile.tmpl
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM registry.127-0-0-1.nip.io/a2a/a2a-pack-base:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV A2A_ENTRYPOINT={{ entrypoint }}
|
||||
ENV PORT=8000
|
||||
EXPOSE 8000
|
||||
|
||||
CMD a2a run --entrypoint "$A2A_ENTRYPOINT" --host 0.0.0.0 --port 8000
|
||||
8
a2a_pack/cli/templates/a2a.yaml.tmpl
Normal file
8
a2a_pack/cli/templates/a2a.yaml.tmpl
Normal file
@@ -0,0 +1,8 @@
|
||||
# Project identity for `a2a deploy`. Most metadata (resources, scopes,
|
||||
# secrets, workspace, etc.) lives on the Python class — this file only
|
||||
# tells the CLI how to find it.
|
||||
name: {{ name }}
|
||||
version: 0.1.0
|
||||
entrypoint: agent:{{ class_name }}
|
||||
expose:
|
||||
public: true
|
||||
24
a2a_pack/cli/templates/agent.py.tmpl
Normal file
24
a2a_pack/cli/templates/agent.py.tmpl
Normal file
@@ -0,0 +1,24 @@
|
||||
"""{{ name }} agent."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from a2a_pack import A2AAgent, NoAuth, RunContext, skill
|
||||
|
||||
|
||||
class {{ class_name }}Config(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class {{ class_name }}(A2AAgent[{{ class_name }}Config, NoAuth]):
|
||||
name = "{{ name }}"
|
||||
description = "{{ description }}"
|
||||
version = "0.1.0"
|
||||
|
||||
config_model = {{ class_name }}Config
|
||||
auth_model = NoAuth
|
||||
|
||||
@skill(description="Say hello")
|
||||
async def hello(self, ctx: RunContext[NoAuth], who: str = "world") -> str:
|
||||
await ctx.emit_progress(f"greeting {who}")
|
||||
return f"hello {who}"
|
||||
71
a2a_pack/cli/templates/deployment.yaml.tmpl
Normal file
71
a2a_pack/cli/templates/deployment.yaml.tmpl
Normal file
@@ -0,0 +1,71 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ name }}
|
||||
namespace: agents
|
||||
labels:
|
||||
app: {{ name }}
|
||||
a2a/managed-by: control-plane
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ name }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: {{ name }}
|
||||
spec:
|
||||
containers:
|
||||
- name: agent
|
||||
# tag is rewritten by the build workflow on every push
|
||||
image: registry.127-0-0-1.nip.io/agents/{{ name }}:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
name: http
|
||||
readinessProbe:
|
||||
httpGet: {path: /healthz, port: 8000}
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet: {path: /healthz, port: 8000}
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 15
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 256Mi}
|
||||
limits: {cpu: 200m, memory: 256Mi}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ name }}
|
||||
namespace: agents
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: {{ name }}
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 8000
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ name }}
|
||||
namespace: agents
|
||||
spec:
|
||||
rules:
|
||||
- host: {{ name }}.127-0-0-1.nip.io
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ name }}
|
||||
port:
|
||||
number: 80
|
||||
7
a2a_pack/cli/templates/dockerignore.tmpl
Normal file
7
a2a_pack/cli/templates/dockerignore.tmpl
Normal file
@@ -0,0 +1,7 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.venv
|
||||
.git
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
node_modules
|
||||
1
a2a_pack/cli/templates/requirements.txt.tmpl
Normal file
1
a2a_pack/cli/templates/requirements.txt.tmpl
Normal file
@@ -0,0 +1 @@
|
||||
# add agent-specific deps here; a2a-pack is auto-installed by the deploy build
|
||||
35
a2a_pack/cli/templates/workflow.yml.tmpl
Normal file
35
a2a_pack/cli/templates/workflow.yml.tmpl
Normal file
@@ -0,0 +1,35 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'deploy/**'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: build image
|
||||
run: |
|
||||
IMG=registry.127-0-0-1.nip.io/agents/{{ name }}
|
||||
docker build -t "$IMG:$GITHUB_SHA" -t "$IMG:latest" .
|
||||
docker push "$IMG:$GITHUB_SHA"
|
||||
docker push "$IMG:latest"
|
||||
|
||||
- name: bump deploy manifest
|
||||
run: |
|
||||
IMG=registry.127-0-0-1.nip.io/agents/{{ name }}
|
||||
sed -i "s|image: $IMG:.*|image: $IMG:$GITHUB_SHA|" deploy/20-deployment.yaml
|
||||
git config user.email "ci@a2a.local"
|
||||
git config user.name "ci"
|
||||
git add deploy/20-deployment.yaml
|
||||
if git diff --staged --quiet; then
|
||||
echo "no manifest changes"
|
||||
else
|
||||
git commit -m "ci: bump image to $GITHUB_SHA"
|
||||
git push "http://gitea_admin:gitea_admin@gitea-http.gitea.svc.cluster.local:3000/gitea_admin/{{ name }}.git" HEAD:main
|
||||
fi
|
||||
Reference in New Issue
Block a user