Track builder workspace deployment base
All checks were successful
build / build (push) Successful in 27s
All checks were successful
build / build (push) Successful in 27s
This commit is contained in:
@@ -92,6 +92,7 @@ def _ensure_bucket(s3: Any, bucket: str) -> None:
|
||||
|
||||
|
||||
_AGENT_NAME_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
||||
_BUILDER_STATE_FILE = ".a2a-builder-state.json"
|
||||
|
||||
|
||||
def _validate_name(name: str) -> None:
|
||||
@@ -106,6 +107,10 @@ def _agent_prefix(name: str) -> str:
|
||||
return f"agents/{name}/"
|
||||
|
||||
|
||||
def _builder_state_key(name: str) -> str:
|
||||
return _agent_prefix(name) + _BUILDER_STATE_FILE
|
||||
|
||||
|
||||
def build_tools(ctx: ToolContext) -> list[Any]:
|
||||
bucket = ctx.bucket
|
||||
settings = ctx.settings
|
||||
@@ -156,8 +161,11 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
||||
paginator = s3.get_paginator("list_objects_v2")
|
||||
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
|
||||
for obj in page.get("Contents") or ():
|
||||
rel = obj["Key"][len(prefix):]
|
||||
if rel == _BUILDER_STATE_FILE:
|
||||
continue
|
||||
out.append({
|
||||
"path": obj["Key"][len(prefix):],
|
||||
"path": rel,
|
||||
"size": int(obj.get("Size") or 0),
|
||||
})
|
||||
return json.dumps({"agent": name, "files": out})
|
||||
@@ -173,7 +181,10 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
||||
prefix = _agent_prefix(name)
|
||||
except ValueError as exc:
|
||||
return json.dumps({"error": str(exc)})
|
||||
key = prefix + path.lstrip("/")
|
||||
rel = path.lstrip("/")
|
||||
if rel == _BUILDER_STATE_FILE:
|
||||
return json.dumps({"error": f"{_BUILDER_STATE_FILE} is managed by deploys"})
|
||||
key = prefix + rel
|
||||
s3.put_object(
|
||||
Bucket=bucket, Key=key, Body=content.encode("utf-8"),
|
||||
ContentType="text/plain; charset=utf-8",
|
||||
@@ -187,7 +198,10 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
||||
prefix = _agent_prefix(name)
|
||||
except ValueError as exc:
|
||||
return json.dumps({"error": str(exc)})
|
||||
key = prefix + path.lstrip("/")
|
||||
rel = path.lstrip("/")
|
||||
if rel == _BUILDER_STATE_FILE:
|
||||
return json.dumps({"error": f"{_BUILDER_STATE_FILE} is managed by deploys"})
|
||||
key = prefix + rel
|
||||
try:
|
||||
obj = s3.get_object(Bucket=bucket, Key=key)
|
||||
except ClientError as exc:
|
||||
@@ -246,7 +260,12 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
||||
})
|
||||
|
||||
@tool
|
||||
async def cp_deploy_tarball(name: str, version: str = "0.1.0", public: bool = True) -> str:
|
||||
async def cp_deploy_tarball(
|
||||
name: str,
|
||||
version: str = "0.1.0",
|
||||
public: bool = True,
|
||||
force: bool = False,
|
||||
) -> str:
|
||||
"""Tar the agent at ``agents/<name>/`` and POST it to the control
|
||||
plane's ``/v1/agents/from-tarball`` endpoint on the user's behalf,
|
||||
then BLOCK until the live ``.well-known/agent-card`` reports the
|
||||
@@ -263,6 +282,11 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
||||
actually accepts. Mismatches cause downstream ``agent 400`` from
|
||||
``call_agent``.
|
||||
|
||||
``force`` defaults to false so a stale MinIO workspace cannot
|
||||
silently overwrite source that was deployed from a user's computer
|
||||
or another tool. Only set ``force=True`` after the user explicitly
|
||||
accepts replacing the current managed repo contents.
|
||||
|
||||
Requires the orchestrator to have forwarded the user's CP JWT
|
||||
(the platform does this automatically when this agent's Card
|
||||
declared ``wants_cp_jwt=True``).
|
||||
@@ -276,6 +300,23 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
||||
"error": "no CP JWT forwarded — agent declaration missing wants_cp_jwt=True",
|
||||
})
|
||||
|
||||
try:
|
||||
latest_deploy = await _latest_cp_deployment(settings.cp_url, ctx.cp_jwt, name)
|
||||
except RuntimeError as exc:
|
||||
return json.dumps({"error": str(exc)})
|
||||
builder_state = _read_builder_state(s3, bucket, name)
|
||||
drift = _deployment_drift_error(
|
||||
name,
|
||||
latest_deploy,
|
||||
builder_state,
|
||||
force=force,
|
||||
)
|
||||
if drift is not None:
|
||||
return json.dumps(drift)
|
||||
base_head = builder_state.get("repo_head_sha")
|
||||
if force:
|
||||
base_head = None
|
||||
|
||||
bundle_bytes = _tarball_workspace_dir(s3, bucket, prefix)
|
||||
if not bundle_bytes:
|
||||
return json.dumps({"error": f"no files at agents/{name}/"})
|
||||
@@ -290,6 +331,8 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
||||
"entrypoint": entrypoint, "public": "true" if public else "false",
|
||||
"description": "Built by agent-builder.",
|
||||
}
|
||||
if isinstance(base_head, str) and base_head:
|
||||
data["base_head_sha"] = base_head
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as c:
|
||||
r = await c.post(
|
||||
@@ -305,6 +348,21 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
||||
name_ = body.get("name")
|
||||
version_ = body.get("version")
|
||||
url_ = body.get("url")
|
||||
head_sha = body.get("head_sha")
|
||||
if isinstance(head_sha, str) and head_sha:
|
||||
_write_builder_state(
|
||||
s3,
|
||||
bucket,
|
||||
name,
|
||||
{
|
||||
"agent": name,
|
||||
"repo_head_sha": head_sha,
|
||||
"deployment_id": body.get("deployment_id"),
|
||||
"version": version_,
|
||||
"updated_at": int(time.time()),
|
||||
"source": "agent-builder",
|
||||
},
|
||||
)
|
||||
|
||||
# CP returns immediately with status="building". The agent isn't
|
||||
# actually callable until the pod rolls + serves the new card.
|
||||
@@ -320,6 +378,9 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
||||
"version": version_,
|
||||
"status": body.get("status"),
|
||||
"url": url_,
|
||||
"head_sha": head_sha,
|
||||
"deployment_id": body.get("deployment_id"),
|
||||
"workspace_base_head_sha": base_head,
|
||||
"live": live,
|
||||
}
|
||||
if live:
|
||||
@@ -478,6 +539,7 @@ def _tarball_workspace_dir(s3: Any, bucket: str, prefix: str) -> bytes:
|
||||
gzipped tarball whose entries are relative to ``prefix``."""
|
||||
paginator = s3.get_paginator("list_objects_v2")
|
||||
buf = io.BytesIO()
|
||||
added = False
|
||||
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
||||
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
|
||||
for obj in page.get("Contents") or ():
|
||||
@@ -485,12 +547,112 @@ def _tarball_workspace_dir(s3: Any, bucket: str, prefix: str) -> bytes:
|
||||
rel = key[len(prefix):]
|
||||
if not rel:
|
||||
continue
|
||||
if rel == _BUILDER_STATE_FILE:
|
||||
continue
|
||||
body = s3.get_object(Bucket=bucket, Key=key)["Body"].read()
|
||||
info = tarfile.TarInfo(name=rel)
|
||||
info.size = len(body)
|
||||
info.mode = 0o644
|
||||
tf.addfile(info, io.BytesIO(body))
|
||||
return buf.getvalue() if buf.getbuffer().nbytes > 0 else b""
|
||||
added = True
|
||||
return buf.getvalue() if added else b""
|
||||
|
||||
|
||||
def _read_builder_state(s3: Any, bucket: str, name: str) -> dict[str, Any]:
|
||||
try:
|
||||
obj = s3.get_object(Bucket=bucket, Key=_builder_state_key(name))
|
||||
except ClientError as exc:
|
||||
code = exc.response.get("Error", {}).get("Code")
|
||||
if code in {"404", "NoSuchKey", "NotFound"}:
|
||||
return {}
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(obj["Body"].read().decode("utf-8"))
|
||||
except (TypeError, ValueError, UnicodeDecodeError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _write_builder_state(
|
||||
s3: Any,
|
||||
bucket: str,
|
||||
name: str,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
body = json.dumps(state, sort_keys=True, indent=2).encode("utf-8")
|
||||
s3.put_object(
|
||||
Bucket=bucket,
|
||||
Key=_builder_state_key(name),
|
||||
Body=body,
|
||||
ContentType="application/json",
|
||||
)
|
||||
|
||||
|
||||
async def _latest_cp_deployment(
|
||||
cp_url: str,
|
||||
cp_jwt: str,
|
||||
name: str,
|
||||
) -> dict[str, Any] | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as c:
|
||||
r = await c.get(
|
||||
f"{cp_url}/v1/agents/{name}/deployments",
|
||||
headers={"authorization": f"bearer {cp_jwt}"},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise RuntimeError(f"cp unreachable: {exc}") from exc
|
||||
if r.status_code == 404:
|
||||
return None
|
||||
if r.status_code >= 400:
|
||||
raise RuntimeError(f"cp {r.status_code}: {r.text[:1000]}")
|
||||
body = r.json() or []
|
||||
if not isinstance(body, list) or not body:
|
||||
return None
|
||||
first = body[0]
|
||||
return first if isinstance(first, dict) else None
|
||||
|
||||
|
||||
def _deployment_drift_error(
|
||||
name: str,
|
||||
latest_deployment: dict[str, Any] | None,
|
||||
builder_state: dict[str, Any],
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
if force or latest_deployment is None:
|
||||
return None
|
||||
latest_head = latest_deployment.get("head_sha")
|
||||
base_head = builder_state.get("repo_head_sha")
|
||||
if isinstance(latest_head, str) and latest_head:
|
||||
if isinstance(base_head, str) and base_head == latest_head:
|
||||
return None
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "workspace_drift",
|
||||
"agent": name,
|
||||
"message": (
|
||||
"This MinIO workspace is not based on the latest managed "
|
||||
"repo deployment. Refresh it before deploying, or use "
|
||||
"force=True only if the user wants to replace the repo."
|
||||
),
|
||||
"workspace_base_head_sha": base_head,
|
||||
"current_head_sha": latest_head,
|
||||
"current_deployment_id": latest_deployment.get("deploy_id"),
|
||||
}
|
||||
if not isinstance(base_head, str) or not base_head:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "workspace_untracked",
|
||||
"agent": name,
|
||||
"message": (
|
||||
"This agent already has managed source, but the MinIO "
|
||||
"workspace has no deploy base marker. Refresh it before "
|
||||
"deploying, or use force=True only if the user wants to "
|
||||
"replace the repo."
|
||||
),
|
||||
"current_deployment_id": latest_deployment.get("deploy_id"),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _read_entrypoint(bundle: bytes) -> str | None:
|
||||
|
||||
Reference in New Issue
Block a user