282 lines
11 KiBLFS
Python
282 lines
11 KiBLFS
Python
"""Normalize AgentBeats deployment artifacts into an operator-ready bundle."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from skillsbench_agentbeats.config import DEFAULT_PUBLIC_TASK_SET, repo_root_from_file
|
|
from skillsbench_agentbeats.task_sets import load_task_set_manifest
|
|
|
|
DEPLOY_BUNDLE_SCHEMA_VERSION = "skillsbench.agentbeats.deploy_bundle.v1"
|
|
PUBLIC_IMAGE_PLATFORM = "linux/amd64"
|
|
|
|
COMPONENTS = ("green", "worker", "purple")
|
|
COMPONENT_MANIFEST_PATHS = {
|
|
"green": "integrations/agentbeats/green_agent/amber-manifest.json5",
|
|
"worker": "integrations/agentbeats/worker/amber-manifest.json5",
|
|
"purple": "integrations/agentbeats/agent_under_test/amber-manifest.json5",
|
|
}
|
|
COMPONENT_REPOSITORIES = {
|
|
"green": "ghcr.io/benchflow-ai/skillsbench-agentbeats-green",
|
|
"worker": "ghcr.io/benchflow-ai/skillsbench-agentbeats-worker",
|
|
"purple": "ghcr.io/benchflow-ai/skillsbench-agentbeats-purple",
|
|
}
|
|
|
|
SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
GIT_SHA_RE = re.compile(r"^[0-9a-f]{7,64}$")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DeployBundleIssue:
|
|
path: str
|
|
message: str
|
|
|
|
def format(self) -> str:
|
|
return f"{self.path}: {self.message}"
|
|
|
|
|
|
class DeployBundleError(ValueError):
|
|
def __init__(self, issues: list[DeployBundleIssue]) -> None:
|
|
self.issues = issues
|
|
super().__init__("\n".join(issue.format() for issue in issues))
|
|
|
|
|
|
def build_deploy_bundle(
|
|
*,
|
|
repo_root: Path | None = None,
|
|
branch_images: Mapping[str, Any],
|
|
task_environment_images: Mapping[str, Any],
|
|
task_set: str = DEFAULT_PUBLIC_TASK_SET,
|
|
) -> dict[str, Any]:
|
|
"""Build a normalized deployment bundle from GitHub Actions artifacts."""
|
|
|
|
root = repo_root or repo_root_from_file()
|
|
issues = validate_deploy_bundle_inputs(
|
|
repo_root=root,
|
|
branch_images=branch_images,
|
|
task_environment_images=task_environment_images,
|
|
task_set=task_set,
|
|
)
|
|
if issues:
|
|
raise DeployBundleError(issues)
|
|
|
|
manifest = load_task_set_manifest(task_set, repo_root=root)
|
|
if manifest is None:
|
|
raise DeployBundleError([DeployBundleIssue("$.task_set", f"task-set manifest {task_set!r} was not found")])
|
|
|
|
branch_image_map = _as_mapping(branch_images["images"])
|
|
task_ids = _manifest_task_ids(manifest)
|
|
worker_prebuilt_images = {task_id: _as_string(task_environment_images[task_id]) for task_id in task_ids}
|
|
components = {component: _component_record(component, _as_string(branch_image_map[component])) for component in COMPONENTS}
|
|
return {
|
|
"schema_version": DEPLOY_BUNDLE_SCHEMA_VERSION,
|
|
"source_revision": branch_images["source_revision"],
|
|
"image_tag": branch_images["image_tag"],
|
|
"task_set": {
|
|
"task_set": manifest["task_set"],
|
|
"task_count": manifest["task_count"],
|
|
"task_set_digest": manifest["task_set_digest"],
|
|
},
|
|
"components": components,
|
|
"public_readiness_images": _public_readiness_images(components),
|
|
"worker_prebuilt_images": worker_prebuilt_images,
|
|
"public_readiness_task_environment_images": {
|
|
"images": [
|
|
{
|
|
"task_id": task_id,
|
|
"image": image,
|
|
"image_digest": _image_digest(image),
|
|
"image_platform": PUBLIC_IMAGE_PLATFORM,
|
|
}
|
|
for task_id, image in worker_prebuilt_images.items()
|
|
],
|
|
},
|
|
}
|
|
|
|
|
|
def validate_deploy_bundle_inputs(
|
|
*,
|
|
repo_root: Path | None = None,
|
|
branch_images: Mapping[str, Any],
|
|
task_environment_images: Mapping[str, Any],
|
|
task_set: str = DEFAULT_PUBLIC_TASK_SET,
|
|
) -> list[DeployBundleIssue]:
|
|
"""Validate branch runtime images and prebuilt task environment images."""
|
|
|
|
root = repo_root or repo_root_from_file()
|
|
issues: list[DeployBundleIssue] = []
|
|
manifest = load_task_set_manifest(task_set, repo_root=root)
|
|
if manifest is None:
|
|
issues.append(DeployBundleIssue("$.task_set", f"task-set manifest {task_set!r} was not found"))
|
|
return issues
|
|
_validate_branch_images(branch_images, issues)
|
|
_validate_task_environment_images(task_environment_images, _manifest_task_ids(manifest), issues, task_set=task_set)
|
|
return issues
|
|
|
|
|
|
def load_json_mapping(path: Path) -> dict[str, Any]:
|
|
payload = json.loads(path.read_text())
|
|
if not isinstance(payload, dict):
|
|
raise ValueError(f"{path} must contain a JSON object")
|
|
return payload
|
|
|
|
|
|
def _validate_branch_images(branch_images: Mapping[str, Any], issues: list[DeployBundleIssue]) -> None:
|
|
source_revision = branch_images.get("source_revision")
|
|
if not isinstance(source_revision, str) or GIT_SHA_RE.fullmatch(source_revision) is None:
|
|
issues.append(DeployBundleIssue("$.branch_images.source_revision", "must be a git SHA or abbreviated git SHA"))
|
|
image_tag = branch_images.get("image_tag")
|
|
if not isinstance(image_tag, str) or not image_tag:
|
|
issues.append(DeployBundleIssue("$.branch_images.image_tag", "must be a non-empty string"))
|
|
images = branch_images.get("images")
|
|
if not isinstance(images, Mapping):
|
|
issues.append(DeployBundleIssue("$.branch_images.images", "must be an object"))
|
|
return
|
|
missing_components = sorted(set(COMPONENTS) - set(images))
|
|
extra_components = sorted(set(images) - set(COMPONENTS))
|
|
if missing_components:
|
|
issues.append(DeployBundleIssue("$.branch_images.images", f"missing component image(s): {missing_components}"))
|
|
if extra_components:
|
|
issues.append(DeployBundleIssue("$.branch_images.images", f"unexpected component image(s): {extra_components}"))
|
|
for component in COMPONENTS:
|
|
image = images.get(component)
|
|
if not isinstance(image, str):
|
|
issues.append(DeployBundleIssue(f"$.branch_images.images.{component}", "must be a string"))
|
|
continue
|
|
_validate_digest_pinned_image(image, f"$.branch_images.images.{component}", issues)
|
|
expected_repository = COMPONENT_REPOSITORIES[component]
|
|
if not image.startswith(f"{expected_repository}@"):
|
|
issues.append(DeployBundleIssue(f"$.branch_images.images.{component}", f"must use repository {expected_repository!r}"))
|
|
|
|
|
|
def _validate_task_environment_images(
|
|
task_environment_images: Mapping[str, Any],
|
|
expected_task_ids: list[str],
|
|
issues: list[DeployBundleIssue],
|
|
*,
|
|
task_set: str,
|
|
) -> None:
|
|
actual_task_ids = {key for key in task_environment_images if isinstance(key, str)}
|
|
expected_task_id_set = set(expected_task_ids)
|
|
missing_task_ids = sorted(expected_task_id_set - actual_task_ids)
|
|
extra_task_ids = sorted(actual_task_ids - expected_task_id_set)
|
|
if missing_task_ids:
|
|
issues.append(
|
|
DeployBundleIssue(
|
|
"$.task_environment_images",
|
|
f"missing {len(missing_task_ids)} {task_set} task environment image(s): {missing_task_ids[:5]}",
|
|
)
|
|
)
|
|
if extra_task_ids:
|
|
issues.append(
|
|
DeployBundleIssue(
|
|
"$.task_environment_images",
|
|
f"contains task ids outside {task_set}: {extra_task_ids[:5]}",
|
|
)
|
|
)
|
|
for task_id, image in task_environment_images.items():
|
|
if not isinstance(task_id, str):
|
|
issues.append(DeployBundleIssue("$.task_environment_images", "task ids must be strings"))
|
|
continue
|
|
if not isinstance(image, str):
|
|
issues.append(DeployBundleIssue(f"$.task_environment_images.{task_id}", "must be a string"))
|
|
continue
|
|
_validate_digest_pinned_image(image, f"$.task_environment_images.{task_id}", issues)
|
|
|
|
|
|
def _validate_digest_pinned_image(image: str, path: str, issues: list[DeployBundleIssue]) -> None:
|
|
digest = _image_digest(image)
|
|
if digest is None:
|
|
issues.append(DeployBundleIssue(path, "must be pinned as IMAGE@sha256:<64 hex chars>"))
|
|
elif SHA256_RE.fullmatch(digest) is None:
|
|
issues.append(DeployBundleIssue(path, "must use a valid sha256 digest"))
|
|
|
|
|
|
def _component_record(component: str, image: str) -> dict[str, str]:
|
|
digest = _image_digest(image)
|
|
if digest is None:
|
|
raise ValueError("component image was not validated")
|
|
return {
|
|
"image": image,
|
|
"image_digest": digest,
|
|
"image_platform": PUBLIC_IMAGE_PLATFORM,
|
|
"manifest_path": COMPONENT_MANIFEST_PATHS[component],
|
|
}
|
|
|
|
|
|
def _public_readiness_images(components: Mapping[str, Mapping[str, str]]) -> dict[str, str]:
|
|
return {
|
|
"green_image": components["green"]["image"],
|
|
"green_image_digest": components["green"]["image_digest"],
|
|
"green_image_platform": components["green"]["image_platform"],
|
|
"worker_image": components["worker"]["image"],
|
|
"worker_image_digest": components["worker"]["image_digest"],
|
|
"worker_image_platform": components["worker"]["image_platform"],
|
|
"purple_image": components["purple"]["image"],
|
|
"purple_image_digest": components["purple"]["image_digest"],
|
|
"purple_image_platform": components["purple"]["image_platform"],
|
|
}
|
|
|
|
|
|
def _image_digest(image: str) -> str | None:
|
|
if "@sha256:" not in image:
|
|
return None
|
|
return image.rsplit("@", 1)[1]
|
|
|
|
|
|
def _manifest_task_ids(manifest: Mapping[str, Any]) -> list[str]:
|
|
tasks = manifest.get("tasks")
|
|
if not isinstance(tasks, list):
|
|
return []
|
|
task_ids = [task.get("task_id") for task in tasks if isinstance(task, Mapping)]
|
|
return [task_id for task_id in task_ids if isinstance(task_id, str)]
|
|
|
|
|
|
def _as_mapping(value: Any) -> Mapping[str, Any]:
|
|
if not isinstance(value, Mapping):
|
|
raise TypeError("expected mapping")
|
|
return value
|
|
|
|
|
|
def _as_string(value: Any) -> str:
|
|
if not isinstance(value, str):
|
|
raise TypeError("expected string")
|
|
return value
|
|
|
|
|
|
def _main() -> None:
|
|
parser = argparse.ArgumentParser(description="Build an AgentBeats deployment bundle from published image artifacts.")
|
|
parser.add_argument("--repo-root", type=Path, default=repo_root_from_file())
|
|
parser.add_argument("--branch-images", type=Path, required=True, help="agentbeats-branch-image-digests.json artifact.")
|
|
parser.add_argument("--task-environment-images", type=Path, required=True, help="prebuilt task environment image map artifact.")
|
|
parser.add_argument("--task-set", default=DEFAULT_PUBLIC_TASK_SET)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
args = parser.parse_args()
|
|
|
|
branch_images = load_json_mapping(args.branch_images)
|
|
task_environment_images = load_json_mapping(args.task_environment_images)
|
|
try:
|
|
bundle = build_deploy_bundle(
|
|
repo_root=args.repo_root,
|
|
branch_images=branch_images,
|
|
task_environment_images=task_environment_images,
|
|
task_set=args.task_set,
|
|
)
|
|
except DeployBundleError as exc:
|
|
for issue in exc.issues:
|
|
print(issue.format(), file=sys.stderr)
|
|
parser.exit(1)
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(json.dumps(bundle, indent=2, sort_keys=True) + "\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
_main()
|