ship grants, a2a_client, discovery, sandbox SDK + tests

This commit is contained in:
robert
2026-05-09 12:43:07 -03:00
parent b6f6cd1643
commit 2dcb8a09cd
15 changed files with 1853 additions and 75 deletions

View File

@@ -88,6 +88,37 @@ class ControlPlaneClient:
},
)
def from_tarball(
self,
*,
name: str,
version: str,
entrypoint: str,
description: str,
public: bool,
tarball: bytes,
) -> dict[str, Any]:
with httpx.Client(timeout=120.0) as c:
resp = c.post(
f"{self.api_url}/v1/agents/from-tarball",
headers={"authorization": f"bearer {self.token}"} if self.token else {},
data={
"name": name,
"version": version,
"entrypoint": entrypoint,
"description": description,
"public": str(public).lower(),
},
files={"source": ("source.tar.gz", tarball, "application/gzip")},
)
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))
return resp.json()
def list_agents(self) -> list[dict[str, Any]]:
return self._request("GET", "/v1/agents")

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
@@ -204,16 +205,6 @@ def init(
"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
@@ -323,60 +314,71 @@ def build(
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``.
def _make_tarball(project: Path) -> bytes:
"""Tar.gz the user's project directory.
Idempotent: re-running on an existing repo just commits any changes
and pushes.
Excludes platform/dev artifacts so build images stay small. The
platform stamps in Dockerfile/workflow/manifests on the server side.
"""
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"])
import io
import tarfile
excluded_dirs = {
"__pycache__",
".venv",
".git",
".pytest_cache",
".mypy_cache",
"node_modules",
"dist",
"build",
".gitea",
"deploy",
}
excluded_files = {"Dockerfile", ".dockerignore"}
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for root, dirs, files in os.walk(project):
dirs[:] = [d for d in dirs if d not in excluded_dirs]
for fname in files:
if fname in excluded_files or fname.endswith(".pyc"):
continue
fpath = Path(root) / fname
arcname = fpath.relative_to(project)
tar.add(fpath, arcname=str(arcname))
return buf.getvalue()
def _wait_for_url(url: str, timeout: int = 180) -> bool:
import time
import httpx
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with httpx.Client(timeout=3.0) as c:
if c.get(f"{url}/healthz").status_code == 200:
return True
except Exception: # noqa: BLE001
pass
time.sleep(4)
return False
@app.command()
def deploy(
project: Path = typer.Option(Path("."), "--project", "-p"),
public: bool | None = typer.Option(None, "--public/--private"),
wait: bool = typer.Option(True, "--wait/--no-wait", help="Poll until URL is live"),
api: str | None = typer.Option(None, "--api", help="Override control plane URL"),
) -> None:
"""Push source to gitea; the platform builds + deploys.
"""Ship the agent.
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.
Tarballs your source, uploads to the control plane, and prints the
URL when it's live. No local docker. No git. No knowledge of how the
platform builds or deploys.
"""
creds = credentials.load()
if creds is None:
@@ -389,33 +391,29 @@ def deploy(
)
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."
)
console.print("[dim]packaging source...[/]")
tarball = _make_tarball(project)
size_kb = len(tarball) / 1024
console.print(f"[dim]uploading {size_kb:.1f}KB to {credentials.resolve_api_url(api)}...[/]")
client = _client(api)
console.print(f"[dim]asking control plane to provision repo + argo app...[/]")
try:
prov = client.from_source(
out = client.from_tarball(
name=cls.name,
description=description,
version=cfg["version"],
entrypoint=cfg["entrypoint"],
description=description,
public=is_public,
tarball=tarball,
)
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",
"agent": out["name"],
"version": out["version"],
"status": out["status"],
"url": out.get("url"),
}
console.print(
Panel.fit(
@@ -424,6 +422,17 @@ def deploy(
)
)
url = out.get("url")
if wait and url:
console.print(f"[dim]waiting for {url} ...[/]")
ready = _wait_for_url(url)
if ready:
console.print(f"[green]live[/]: {url}")
else:
console.print(
f"[yellow]still building[/]; check `a2a agents` or curl {url} in a bit"
)
# Used as `python -m a2a_pack.cli.main`
if __name__ == "__main__": # pragma: no cover