diff --git a/README.md b/README.md index 5eb32d2..3d6a61e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ agents that you configure declaratively without writing or deploying any runtime - **One-command super agent** — `ar super-agent run` creates a hosted agent and drops you into a chat REPL in seconds. - **Declarative deployment** — Kubernetes-style YAML (`ar sa apply -f superagent.yaml`) for reproducible, version-controlled agents. -- **Runtime declarative deploy** — `ar runtime apply -f runtime.yaml` builds an Agent Runtime from a container image and waits for it to reach `READY`. +- **Runtime declarative deploy** — `ar runtime apply -f runtime.yaml` deploys an Agent Runtime from an image, or invokes a cloud build first when the YAML defines `cloudBuild`. - **Seven resource groups** — `config`, `model`, `sandbox`, `tool`, `skill`, `super-agent`, `runtime`, all following the same `ar ` pattern. - **Multi-profile config** — store multiple sets of credentials in `~/.agentrun/config.json` and switch with `--profile`. - **Multiple output formats** — `json` (default), `table`, `yaml`, and `quiet` for shell piping. @@ -191,6 +191,29 @@ EOF ar runtime apply -f runtime.yaml ``` +To cloud-build the image before deploy, add `cloudBuild`. The target image is +the same `spec.container.image`; docker-image-builder skips existing tags by +default. + +```bash +cat > runtime-build.yaml < ` 模式。 - **多 Profile 配置** — `~/.agentrun/config.json` 支持多套凭证,通过 `--profile` 切换。 - **多种输出格式** — 默认 `json`,支持 `table` / `yaml` / `quiet`(适合 shell 管道)。 @@ -187,6 +187,28 @@ EOF ar runtime apply -f runtime.yaml ``` +如需部署前云上构建镜像,可增加 `cloudBuild`。目标镜像就是同一个 +`spec.container.image`;docker-image-builder 默认会跳过已存在 tag。 + +```bash +cat > runtime-build.yaml < runtime-build.yaml < runtime-build.yaml < # required + cloudBuild: ... # optional, build image in cloud before apply + cloudBuild.baseContainerConfig.image: command: [, ...] port: imageRegistryType: @@ -75,6 +77,24 @@ class ParsedRegistryConfig: network: ParsedRegistryNetwork | None = None +@dataclass +class ParsedCloudBuildRegistry: + username: str | None = None + password: str | None = field(default=None, repr=False) + + +@dataclass +class ParsedCloudBuild: + dir: str = "." + setup_script: str = "scripts/setup.sh" + timeout_minutes: str = "20" + cpu: str = "4" + memory: str = "8192" + region: str | None = None + registry: ParsedCloudBuildRegistry | None = None + base_container_image: str | None = None + + @dataclass class ParsedContainer: image: str @@ -83,6 +103,7 @@ class ParsedContainer: image_registry_type: str | None = None acr_instance_id: str | None = None registry_config: ParsedRegistryConfig | None = None + cloud_build: ParsedCloudBuild | None = None @dataclass @@ -240,10 +261,137 @@ def _require_mapping(value: Any, where: str) -> dict: return value +def _require_known_keys(raw: dict, allowed: set[str], where: str) -> None: + """Validate that a config mapping only contains supported fields. + + Args: + raw: YAML mapping to validate. + allowed: Allowed field names. + where: Path used in error messages. + """ + unknown = sorted(set(raw) - allowed) + if unknown: + raise YamlSchemaError( + f"{where} has unsupported field(s): {', '.join(unknown)}." + ) + + +def _as_string(value: Any, where: str) -> str: + """Convert a YAML scalar to a string. + + Args: + value: Field value. + where: Path used in error messages. + """ + if isinstance(value, bool): + raise YamlSchemaError(f"{where} must be a string or number.") + if isinstance(value, (str, int, float)): + return str(value) + raise YamlSchemaError(f"{where} must be a string or number.") + + +def _parse_cloud_build_registry(raw: Any) -> ParsedCloudBuildRegistry | None: + """Parse `cloudBuild.registry`. + + Args: + raw: Registry mapping, or None when omitted. + """ + if raw is None: + return None + if not isinstance(raw, dict): + raise YamlSchemaError("spec.container.cloudBuild.registry must be a mapping.") + _require_known_keys( + raw, + {"username", "password"}, + "spec.container.cloudBuild.registry", + ) + username = raw.get("username") + password = raw.get("password") + if username is not None and not isinstance(username, str): + raise YamlSchemaError( + "spec.container.cloudBuild.registry.username must be a string." + ) + if password is not None and not isinstance(password, str): + raise YamlSchemaError( + "spec.container.cloudBuild.registry.password must be a string." + ) + return ParsedCloudBuildRegistry(username=username, password=password) + + +def _parse_cloud_build_base_container_config(raw: Any) -> str | None: + """Parse `cloudBuild.baseContainerConfig.image`. + + Args: + raw: Base container config mapping, or None when omitted. + """ + if raw is None: + return None + if not isinstance(raw, dict): + raise YamlSchemaError( + "spec.container.cloudBuild.baseContainerConfig must be a mapping." + ) + _require_known_keys( + raw, + {"image"}, + "spec.container.cloudBuild.baseContainerConfig", + ) + image = raw.get("image") + if image is None: + return None + return _as_string(image, "spec.container.cloudBuild.baseContainerConfig.image") + + +def _parse_cloud_build(raw: Any) -> ParsedCloudBuild | None: + """Parse `cloudBuild` configuration. + + Args: + raw: `cloudBuild` mapping. + """ + if raw is None: + return None + if not isinstance(raw, dict): + raise YamlSchemaError("spec.container.cloudBuild must be a mapping.") + _require_known_keys( + raw, + { + "dir", + "setupScript", + "timeoutMinutes", + "cpu", + "memory", + "region", + "registry", + "baseContainerConfig", + }, + "spec.container.cloudBuild", + ) + + def field(name: str, default: str | None = None) -> str | None: + value = raw.get(name) + if value is None: + return default + return _as_string(value, f"spec.container.cloudBuild.{name}") + + region = field("region") + return ParsedCloudBuild( + dir=field("dir", ".") or ".", + setup_script=field("setupScript", "scripts/setup.sh") or "", + timeout_minutes=field("timeoutMinutes", "20") or "20", + cpu=field("cpu", "4") or "4", + memory=field("memory", "8192") or "8192", + region=region, + registry=_parse_cloud_build_registry(raw.get("registry")), + base_container_image=_parse_cloud_build_base_container_config( + raw.get("baseContainerConfig") + ), + ) + + def _parse_container(raw: dict) -> ParsedContainer: image = raw.get("image") if not isinstance(image, str) or not image: raise YamlSchemaError("spec.container.image is required and must be a string.") + cloud_build = _parse_cloud_build(raw.get("cloudBuild")) image_registry_type = raw.get("imageRegistryType") if image_registry_type is not None and image_registry_type not in ( "ACR", @@ -273,6 +421,7 @@ def _parse_container(raw: dict) -> ParsedContainer: image_registry_type=image_registry_type, acr_instance_id=raw.get("acrInstanceId"), registry_config=registry_config, + cloud_build=cloud_build, ) diff --git a/src/agentrun_cli/_utils/cloud_build.py b/src/agentrun_cli/_utils/cloud_build.py new file mode 100644 index 0000000..723adc7 --- /dev/null +++ b/src/agentrun_cli/_utils/cloud_build.py @@ -0,0 +1,373 @@ +"""Agent Runtime cloud image build helpers.""" + +from __future__ import annotations + +import os +import platform +import stat +import subprocess +import sys +import time +import urllib.request +from dataclasses import dataclass +from hashlib import sha256 +from pathlib import Path +from typing import Any + +from agentrun_cli._utils.agentruntime_yaml import ParsedAgentRuntime, ParsedCloudBuild + +BUILDER_RELEASE_TAG = "latest" +BUILDER_BASE_URL = "https://images.devsapp.cn/docker-image-builder" + + +class CloudBuildError(RuntimeError): + """Raised when cloud build fails.""" + + +@dataclass +class CloudBuildResult: + """Cloud build result for one runtime.""" + + name: str + image: str + build_status: str + elapsed_seconds: float + + +def load_dotenv(path: Path | None = None) -> None: + """Load dotenv values into process environment. + + Args: + path: Dotenv path. Defaults to `.env` under the current working directory. + """ + env_path = path or Path.cwd() / ".env" + if not env_path.exists(): + return + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + + +def build_runtime_image( + parsed: ParsedAgentRuntime, + cfg: Any, +) -> CloudBuildResult | None: + """Build the runtime image according to `cloudBuild`. + + Args: + parsed: Parsed runtime document. + cfg: AgentRun SDK config or a test double. + """ + cloud_build = parsed.container.cloud_build + if cloud_build is None: + return None + + started = time.monotonic() + image = parsed.container.image + binary_path = ensure_builder_binary() + env = build_builder_env(cfg, cloud_build) + args = build_builder_args(binary_path, image, cloud_build) + completed = subprocess.run( # noqa: S603 + args, + env=env, + stdout=sys.stderr, + stderr=sys.stderr, + check=False, + ) + if completed.returncode != 0: + raise CloudBuildError( + f"docker-image-builder exited with code {completed.returncode}" + ) + + return CloudBuildResult( + name=parsed.name, + image=image, + build_status="completed", + elapsed_seconds=round(time.monotonic() - started, 3), + ) + + +def build_builder_env( + cfg: Any, + cloud_build: ParsedCloudBuild, +) -> dict[str, str]: + """Build docker-image-builder subprocess environment variables. + + Args: + cfg: AgentRun SDK config or a test double. + cloud_build: Parsed build configuration. + """ + env = os.environ.copy() + _set_env_if_present(env, "DOCKER_IMAGE_BUILDER_UID", _cfg_value(cfg, "account_id")) + _set_env_if_present( + env, + "DOCKER_IMAGE_BUILDER_AK", + _cfg_value(cfg, "access_key_id"), + ) + _set_env_if_present( + env, + "DOCKER_IMAGE_BUILDER_SK", + _cfg_value(cfg, "access_key_secret"), + ) + _set_env_if_present( + env, + "DOCKER_IMAGE_BUILDER_REGION", + cloud_build.region or _cfg_value(cfg, "region_id"), + ) + if cloud_build.registry and cloud_build.registry.username: + env["DOCKER_IMAGE_BUILDER_USERNAME"] = cloud_build.registry.username + if cloud_build.registry and cloud_build.registry.password: + env["DOCKER_IMAGE_BUILDER_PASSWORD"] = cloud_build.registry.password + return env + + +def build_builder_args( + binary_path: str, + image: str, + cloud_build: ParsedCloudBuild, +) -> list[str]: + """Build docker-image-builder CLI arguments. + + Args: + binary_path: Path to the builder executable. + image: Target image. + cloud_build: Parsed build configuration. + """ + args = [ + binary_path, + "build", + f"--image={image}", + f"--dir={cloud_build.dir}", + f"--setup-script={cloud_build.setup_script}", + f"--timeout-minutes={cloud_build.timeout_minutes}", + f"--cpu={cloud_build.cpu}", + f"--memory={cloud_build.memory}", + ] + if cloud_build.region: + args.append(f"--region={cloud_build.region}") + if cloud_build.base_container_image: + args.append(f"--base-image={cloud_build.base_container_image}") + return args + + +def serialize_cloud_build_plan(parsed: ParsedAgentRuntime) -> dict | None: + """Serialize `cloudBuild` configuration for render output. + + Args: + parsed: Parsed runtime document. + """ + cloud_build = parsed.container.cloud_build + if cloud_build is None: + return None + plan: dict[str, Any] = { + "image": parsed.container.image, + "dir": cloud_build.dir, + "setupScript": cloud_build.setup_script, + "timeoutMinutes": cloud_build.timeout_minutes, + "cpu": cloud_build.cpu, + "memory": cloud_build.memory, + "region": cloud_build.region, + } + if cloud_build.base_container_image: + plan["baseContainerConfig"] = {"image": cloud_build.base_container_image} + return plan + + +def serialize_cloud_build_result(result: CloudBuildResult) -> dict: + """Serialize a build result for CLI output. + + Args: + result: Cloud build result for one runtime. + """ + return { + "name": result.name, + "image": result.image, + "buildStatus": result.build_status, + "elapsedSeconds": result.elapsed_seconds, + } + + +def ensure_builder_binary() -> str: + """Return an executable docker-image-builder path.""" + configured = os.getenv("DOCKER_IMAGE_BUILDER_BINPATH", "").strip() + if configured: + if _is_executable(Path(configured)): + return configured + raise CloudBuildError( + "DOCKER_IMAGE_BUILDER_BINPATH does not exist or is not executable: " + f"{configured}" + ) + + tag = os.getenv("DOCKER_IMAGE_BUILDER_BINTAG", "").strip() or BUILDER_RELEASE_TAG + install_dir = Path.home() / ".docker-image-builder" / tag + target = install_dir / _executable_name() + + install_dir.mkdir(parents=True, exist_ok=True) + tmp = install_dir / f"{_executable_name()}.tmp-{os.getpid()}" + artifact = _artifact_name() + url = f"{BUILDER_BASE_URL}/{tag}/{artifact}" + try: + expected_sha256 = _download_sha256(f"{url}.sha256", artifact) + if _is_executable(target) and _sha256_file(target) == expected_sha256: + return str(target) + _download_binary(url, tmp) + _verify_sha256(tmp, expected_sha256) + tmp.chmod(tmp.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + tmp.replace(target) + except Exception as exc: + tmp.unlink(missing_ok=True) + raise CloudBuildError(f"download docker-image-builder failed: {exc}") from exc + return str(target) + + +def _download_binary(url: str, target: Path) -> None: + """Download a binary to a temporary local path. + + Args: + url: Download URL. + target: Target file path. + """ + with urllib.request.urlopen(url, timeout=120) as resp: # noqa: S310 + target.write_bytes(resp.read()) + + +def _download_sha256(url: str, artifact_name: str) -> str: + """Download and parse a SHA256 checksum file. + + Args: + url: Checksum URL. + artifact_name: Expected release artifact name. + """ + with urllib.request.urlopen(url, timeout=30) as resp: # noqa: S310 + text = resp.read().decode("utf-8") + return _parse_sha256(text, artifact_name) + + +def _parse_sha256(text: str, artifact_name: str) -> str: + """Parse a SHA256 checksum file. + + Args: + text: Checksum file content. + artifact_name: Expected release artifact name. + """ + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + digest = parts[0].lower() + if len(digest) != 64 or any(ch not in "0123456789abcdef" for ch in digest): + continue + if len(parts) == 1 or parts[-1].lstrip("*") == artifact_name: + return digest + raise CloudBuildError(f"invalid sha256 checksum file for {artifact_name}") + + +def _verify_sha256(path: Path, expected_sha256: str) -> None: + """Verify a local file against an expected SHA256 digest. + + Args: + path: File path to verify. + expected_sha256: Expected SHA256 digest. + """ + actual_sha256 = _sha256_file(path) + if actual_sha256 != expected_sha256: + raise CloudBuildError( + "checksum mismatch for docker-image-builder: " + f"expected {expected_sha256}, got {actual_sha256}" + ) + + +def _sha256_file(path: Path) -> str: + """Compute the SHA256 digest of a local file. + + Args: + path: File path to hash. + """ + digest = sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _is_executable(path: Path) -> bool: + """Return whether the path is an executable file. + + Args: + path: Path to inspect. + """ + return path.is_file() and os.access(path, os.X_OK) + + +def _executable_name() -> str: + """Return the executable filename for the current platform.""" + if sys.platform == "win32": + return "docker-image-builder.exe" + return "docker-image-builder" + + +def _artifact_name() -> str: + """Return the release artifact name on OSS.""" + os_name = _go_platform() + arch = _go_arch() + suffix = ".exe" if os_name == "windows" else "" + return f"docker-image-builder-{os_name}-{arch}{suffix}" + + +def _go_platform() -> str: + """Convert a Python platform name to a Go release platform name.""" + if sys.platform == "win32": + return "windows" + if sys.platform == "darwin": + return "darwin" + if sys.platform.startswith("linux"): + return "linux" + raise CloudBuildError(f"unsupported platform: {sys.platform}") + + +def _go_arch() -> str: + """Convert a Python architecture name to a Go release architecture name.""" + machine = platform.machine().lower() + if machine in ("x86_64", "amd64"): + return "amd64" + if machine in ("aarch64", "arm64"): + return "arm64" + raise CloudBuildError(f"unsupported arch: {platform.machine()}") + + +def _cfg_value(cfg: Any, name: str) -> str | None: + """Read a field value from an SDK Config object. + + Args: + cfg: SDK Config object or test double. + name: Field name without the `get_` prefix. + """ + method_name = f"get_{name}" + for candidate in (method_name, name): + if not hasattr(cfg, candidate): + continue + value = getattr(cfg, candidate) + if callable(value): + value = value() + if value: + return str(value) + return None + + +def _set_env_if_present(env: dict[str, str], key: str, value: str | None) -> None: + """Set a non-empty environment value when the key is missing. + + Args: + env: Child process environment mapping. + key: Environment variable name. + value: Candidate value. + """ + if value and not env.get(key): + env[key] = value diff --git a/src/agentrun_cli/commands/runtime/__init__.py b/src/agentrun_cli/commands/runtime/__init__.py index e117ad2..5503a49 100644 --- a/src/agentrun_cli/commands/runtime/__init__.py +++ b/src/agentrun_cli/commands/runtime/__init__.py @@ -15,6 +15,9 @@ import click # noqa: E402 from agentrun_cli.commands.runtime import apply_cmd as _apply_mod # noqa: E402 +from agentrun_cli.commands.runtime import ( + cloud_build_cmd as _cloud_build_mod, # noqa: E402 +) from agentrun_cli.commands.runtime import crud_cmd as _crud_mod # noqa: E402 from agentrun_cli.commands.runtime import delete_cmd as _delete_mod # noqa: E402 from agentrun_cli.commands.runtime import render_cmd as _render_mod # noqa: E402 @@ -30,6 +33,7 @@ def runtime_group(): runtime_group.add_command(_apply_mod.apply_cmd) +runtime_group.add_command(_cloud_build_mod.cloud_build_cmd) runtime_group.add_command(_render_mod.render_cmd) runtime_group.add_command(_crud_mod.get_cmd) runtime_group.add_command(_crud_mod.list_cmd) diff --git a/src/agentrun_cli/commands/runtime/apply_cmd.py b/src/agentrun_cli/commands/runtime/apply_cmd.py index b4fc30b..4f05fc6 100644 --- a/src/agentrun_cli/commands/runtime/apply_cmd.py +++ b/src/agentrun_cli/commands/runtime/apply_cmd.py @@ -11,6 +11,11 @@ YamlSchemaError, parse_yaml_file, ) +from agentrun_cli._utils.cloud_build import ( + build_runtime_image, + load_dotenv, + serialize_cloud_build_result, +) from agentrun_cli._utils.config import build_sdk_config from agentrun_cli._utils.error import EXIT_BAD_INPUT, handle_errors from agentrun_cli._utils.output import echo_error, format_output @@ -103,8 +108,9 @@ def _progress(stream, parsed, runtime, elapsed): @handle_errors def apply_cmd(ctx, file_path, wait, timeout, prune_endpoints): runtime_cls = _lazy_sdk() + load_dotenv() profile, region = ctx_cfg(ctx) - build_sdk_config(profile_name=profile, region=region) + cfg = build_sdk_config(profile_name=profile, region=region) docs = _parse(file_path) timeout_seconds = parse_duration(timeout) or DEFAULT_APPLY_TIMEOUT_SECONDS @@ -113,6 +119,7 @@ def apply_cmd(ctx, file_path, wait, timeout, prune_endpoints): results = [] for parsed in docs: started = time.monotonic() + build_result = build_runtime_image(parsed, cfg) rt_res = reconcile_runtime(parsed, client=runtime_cls) runtime = rt_res.runtime @@ -160,6 +167,11 @@ def apply_cmd(ctx, file_path, wait, timeout, prune_endpoints): { "action": rt_res.action, "runtime": serialize_runtime(runtime), + "cloudBuild": ( + serialize_cloud_build_result(build_result) + if build_result is not None + else None + ), "endpoints": [ { **serialize_endpoint(a.endpoint or _empty_ep(a.name)), diff --git a/src/agentrun_cli/commands/runtime/cloud_build_cmd.py b/src/agentrun_cli/commands/runtime/cloud_build_cmd.py new file mode 100644 index 0000000..8f200fa --- /dev/null +++ b/src/agentrun_cli/commands/runtime/cloud_build_cmd.py @@ -0,0 +1,100 @@ +"""Build Agent Runtime images in the cloud from YAML.""" + +from __future__ import annotations + +import sys + +import click + +from agentrun_cli._utils.agentruntime_yaml import ( + ParsedAgentRuntime, + YamlSchemaError, + parse_yaml_file, +) +from agentrun_cli._utils.cloud_build import ( + build_runtime_image, + load_dotenv, + serialize_cloud_build_result, +) +from agentrun_cli._utils.config import build_sdk_config +from agentrun_cli._utils.error import EXIT_BAD_INPUT, handle_errors +from agentrun_cli._utils.output import echo_error, format_output +from agentrun_cli.commands.runtime._helpers import ctx_cfg + + +def _parse_file(path: str): + """Parse a runtime YAML file. + + Args: + path: YAML file path. + """ + try: + return parse_yaml_file(path) + except YamlSchemaError as exc: + echo_error("InvalidYaml", str(exc)) + raise SystemExit(EXIT_BAD_INPUT) from exc + + +def _require_cloud_build_blocks(docs: list[ParsedAgentRuntime]) -> None: + """Validate that all runtime documents declare cloud build config. + + Args: + docs: Parsed runtime documents. + """ + missing = [ + f"Document #{idx + 1} runtime {parsed.name!r}" + for idx, parsed in enumerate(docs) + if parsed.container.cloud_build is None + ] + if not missing: + return + echo_error( + "InvalidYaml", + "All runtime documents must define spec.container.cloudBuild before " + f"cloud-build starts; missing: {'; '.join(missing)}.", + ) + raise SystemExit(EXIT_BAD_INPUT) + + +@click.command( + "cloud-build", + help="Build Agent Runtime images in the cloud from YAML.", +) +@click.option( + "-f", + "--file", + "file_path", + required=True, + help="YAML file path (supports multi-document).", +) +@click.pass_context +@handle_errors +def cloud_build_cmd(ctx, file_path): + load_dotenv() + docs = _parse_file(file_path) + _require_cloud_build_blocks(docs) + + profile, region = ctx_cfg(ctx) + cfg = build_sdk_config(profile_name=profile, region=region) + + results = [] + for parsed in docs: + result = build_runtime_image(parsed, cfg) + if result is None: + continue + results.append(serialize_cloud_build_result(result)) + + if not results: + echo_error("InvalidYaml", "No spec.container.cloudBuild blocks found.") + raise SystemExit(EXIT_BAD_INPUT) + + if sys.stderr.isatty(): + for item in results: + sys.stderr.write( + f"[runtime {item['name']}] cloudBuild={item['buildStatus']} " + f"image={item['image']}\n" + ) + format_output(ctx, results, quiet_field="image") + + +__all__ = ["cloud_build_cmd"] diff --git a/src/agentrun_cli/commands/runtime/render_cmd.py b/src/agentrun_cli/commands/runtime/render_cmd.py index 215a31c..98e5532 100644 --- a/src/agentrun_cli/commands/runtime/render_cmd.py +++ b/src/agentrun_cli/commands/runtime/render_cmd.py @@ -6,6 +6,7 @@ YamlSchemaError, parse_yaml_file, ) +from agentrun_cli._utils.cloud_build import serialize_cloud_build_plan from agentrun_cli._utils.error import EXIT_BAD_INPUT, handle_errors from agentrun_cli._utils.output import echo_error, format_output @@ -61,6 +62,7 @@ def render_cmd(ctx, file_path): ei.model_dump() if hasattr(ei, "model_dump") else ei for ei in ep_inputs ], + "cloudBuildPlan": serialize_cloud_build_plan(parsed), } ) format_output(ctx, results) diff --git a/tests/integration/test_runtime_cmd.py b/tests/integration/test_runtime_cmd.py index 595ecf1..f01c49d 100644 --- a/tests/integration/test_runtime_cmd.py +++ b/tests/integration/test_runtime_cmd.py @@ -14,6 +14,7 @@ import click from click.testing import CliRunner +from agentrun_cli._utils.cloud_build import CloudBuildError, CloudBuildResult from agentrun_cli.commands.runtime import runtime_group @@ -40,6 +41,7 @@ def test_runtime_group_registered(): result = CliRunner().invoke(_root(), ["runtime", "--help"]) assert result.exit_code == 0 assert "apply" in result.output + assert "cloud-build" in result.output assert "render" in result.output @@ -53,6 +55,35 @@ def test_runtime_group_registered(): image: img:v1 """ +CLOUD_BUILD_YAML = """ +apiVersion: agentrun/v1 +kind: AgentRuntime +metadata: + name: my-agent +spec: + container: + image: registry.example.com/ns/app:v1 + cloudBuild: + dir: . + setupScript: "" + baseContainerConfig: + image: registry.example.com/ns/worker:tag +""" + +MULTI_DOC_PARTIAL_CLOUD_BUILD_YAML = ( + CLOUD_BUILD_YAML + + """ +--- +apiVersion: agentrun/v1 +kind: AgentRuntime +metadata: + name: plain-agent +spec: + container: + image: registry.example.com/ns/plain:v1 +""" +) + def test_render_outputs_rendered_input(): fake_input = MagicMock() @@ -87,6 +118,131 @@ def test_render_outputs_rendered_input(): assert out[0]["name"] == "my-agent" assert out[0]["renderedCreateInput"]["systemTags"] == ["x-agentrun-cli"] assert out[0]["renderedEndpoints"][0]["agentRuntimeEndpointName"] == "default" + assert out[0]["cloudBuildPlan"] is None + + +def test_render_outputs_cloud_build_plan(): + fake_input = MagicMock() + fake_input.model_dump.return_value = {"agentRuntimeName": "my-agent"} + with ( + patch( + "agentrun_cli.commands.runtime.render_cmd.to_runtime_create_input", + return_value=fake_input, + ), + patch( + "agentrun_cli.commands.runtime.render_cmd.to_endpoint_create_inputs", + return_value=[], + ), + ): + runner = CliRunner() + with runner.isolated_filesystem(): + with open("rt.yaml", "w") as f: + f.write(CLOUD_BUILD_YAML) + result = runner.invoke(_root(), ["runtime", "render", "-f", "rt.yaml"]) + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out[0]["cloudBuildPlan"]["image"] == "registry.example.com/ns/app:v1" + assert out[0]["cloudBuildPlan"]["setupScript"] == "" + assert ( + out[0]["cloudBuildPlan"]["baseContainerConfig"]["image"] + == "registry.example.com/ns/worker:tag" + ) + + +def test_cloud_build_command_success(): + result_obj = CloudBuildResult( + name="my-agent", + image="registry.example.com/ns/app:v1", + build_status="completed", + elapsed_seconds=0.1, + ) + with ( + patch( + "agentrun_cli.commands.runtime.cloud_build_cmd.build_sdk_config", + return_value=MagicMock(), + ), + patch( + "agentrun_cli.commands.runtime.cloud_build_cmd.build_runtime_image", + return_value=result_obj, + ) as build_mock, + ): + runner = CliRunner() + with runner.isolated_filesystem(): + with open("rt.yaml", "w") as f: + f.write(CLOUD_BUILD_YAML) + result = runner.invoke(_root(), ["runtime", "cloud-build", "-f", "rt.yaml"]) + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out[0]["buildStatus"] == "completed" + build_mock.assert_called_once() + + +def test_cloud_build_command_requires_cloud_build_block(): + runner = CliRunner() + with runner.isolated_filesystem(): + with open("rt.yaml", "w") as f: + f.write(VALID_YAML) + result = runner.invoke(_root(), ["runtime", "cloud-build", "-f", "rt.yaml"]) + assert result.exit_code == 2 + + +def test_cloud_build_command_prescans_all_docs_before_building(): + result_obj = CloudBuildResult( + name="my-agent", + image="registry.example.com/ns/app:v1", + build_status="completed", + elapsed_seconds=0.1, + ) + with ( + patch( + "agentrun_cli.commands.runtime.cloud_build_cmd.build_sdk_config", + return_value=MagicMock(), + ) as cfg_mock, + patch( + "agentrun_cli.commands.runtime.cloud_build_cmd.build_runtime_image", + return_value=result_obj, + ) as build_mock, + ): + runner = CliRunner() + with runner.isolated_filesystem(): + with open("rt.yaml", "w") as f: + f.write(MULTI_DOC_PARTIAL_CLOUD_BUILD_YAML) + result = runner.invoke(_root(), ["runtime", "cloud-build", "-f", "rt.yaml"]) + assert result.exit_code == 2 + assert "plain-agent" in result.output + cfg_mock.assert_not_called() + build_mock.assert_not_called() + + +def test_cloud_build_command_no_results_exit_code_2(): + with ( + patch( + "agentrun_cli.commands.runtime.cloud_build_cmd.build_sdk_config", + return_value=MagicMock(), + ), + patch( + "agentrun_cli.commands.runtime.cloud_build_cmd.build_runtime_image", + return_value=None, + ), + ): + runner = CliRunner() + with runner.isolated_filesystem(): + with open("rt.yaml", "w") as f: + f.write(CLOUD_BUILD_YAML) + result = runner.invoke(_root(), ["runtime", "cloud-build", "-f", "rt.yaml"]) + assert result.exit_code == 2 + + +def test_cloud_build_invalid_yaml_exit_code_2(): + runner = CliRunner() + with runner.isolated_filesystem(): + with open("bad.yaml", "w") as f: + f.write( + "apiVersion: wrong/v1\nkind: AgentRuntime\nmetadata: {name: x}\n" + "spec: {container: {image: i}}\n" + ) + result = runner.invoke(_root(), ["runtime", "cloud-build", "-f", "bad.yaml"]) + assert result.exit_code == 2 def test_render_invalid_yaml_exit_code_2(): @@ -180,6 +336,84 @@ def _refresh(self=None, *a, **k): assert out[0]["endpoints"] == [] +def test_apply_cloud_build_before_runtime_submit(monkeypatch): + monkeypatch.setattr("time.sleep", lambda *_: None) + events = [] + fake_runtime_cls = MagicMock() + created = _make_runtime(status="CREATING") + created.refresh = lambda *a, **k: created + created.list_endpoints = MagicMock(return_value=[]) + created.create_endpoint = MagicMock(return_value=_make_endpoint()) + + def fake_create(*_args, **_kwargs): + events.append("runtime") + return created + + def fake_build(*_args, **_kwargs): + events.append("build") + return CloudBuildResult( + name="my-agent", + image="registry.example.com/ns/app:v1", + build_status="completed", + elapsed_seconds=0.1, + ) + + fake_runtime_cls.list_all.return_value = [] + fake_runtime_cls.create.side_effect = fake_create + + with ( + patch( + "agentrun_cli.commands.runtime.apply_cmd.build_sdk_config", + return_value=MagicMock(), + ), + patch( + "agentrun_cli.commands.runtime.apply_cmd.build_runtime_image", + fake_build, + ), + patch( + "agentrun_cli.commands.runtime.apply_cmd.AgentRuntime", + fake_runtime_cls, + ), + ): + runner = CliRunner() + with runner.isolated_filesystem(): + with open("rt.yaml", "w") as f: + f.write(CLOUD_BUILD_YAML) + result = runner.invoke( + _root(), + ["runtime", "apply", "-f", "rt.yaml", "--no-wait"], + ) + assert result.exit_code == 0, result.output + assert events == ["build", "runtime"] + out = json.loads(result.output) + assert out[0]["cloudBuild"]["buildStatus"] == "completed" + + +def test_apply_cloud_build_failure_skips_runtime(): + fake_runtime_cls = MagicMock() + fake_runtime_cls.list_all.return_value = [] + + with ( + patch( + "agentrun_cli.commands.runtime.apply_cmd.build_sdk_config", + return_value=MagicMock(), + ), + patch( + "agentrun_cli.commands.runtime.apply_cmd.build_runtime_image", + side_effect=CloudBuildError("build failed"), + ), + patch("agentrun_cli.commands.runtime.apply_cmd.AgentRuntime", fake_runtime_cls), + ): + runner = CliRunner() + with runner.isolated_filesystem(): + with open("rt.yaml", "w") as f: + f.write(CLOUD_BUILD_YAML) + result = runner.invoke(_root(), ["runtime", "apply", "-f", "rt.yaml"]) + assert result.exit_code == 4 + fake_runtime_cls.list_all.assert_not_called() + fake_runtime_cls.create.assert_not_called() + + def test_apply_update_path(monkeypatch): monkeypatch.setattr("time.sleep", lambda *_: None) existing = _make_runtime(status="UPDATING") @@ -447,6 +681,7 @@ def test_real_cli_exposes_runtime_group(): result = CliRunner().invoke(real_cli, ["runtime", "--help"]) assert result.exit_code == 0 assert "apply" in result.output + assert "cloud-build" in result.output def test_real_cli_exposes_rt_alias(): diff --git a/tests/unit/test_cloud_build.py b/tests/unit/test_cloud_build.py new file mode 100644 index 0000000..7d059e4 --- /dev/null +++ b/tests/unit/test_cloud_build.py @@ -0,0 +1,412 @@ +"""Tests for Agent Runtime cloud build helpers.""" + +from __future__ import annotations + +import os +import stat +import sys +from hashlib import sha256 +from types import SimpleNamespace + +import pytest + +from agentrun_cli._utils import cloud_build as cloud_build_mod +from agentrun_cli._utils.agentruntime_yaml import ( + ParsedAgentRuntime, + ParsedCloudBuild, + ParsedCloudBuildRegistry, + ParsedContainer, +) +from agentrun_cli._utils.cloud_build import ( + BUILDER_RELEASE_TAG, + CloudBuildError, + build_builder_args, + build_builder_env, + build_runtime_image, + ensure_builder_binary, + load_dotenv, + serialize_cloud_build_plan, + serialize_cloud_build_result, +) + + +def _runtime(cloud_build: ParsedCloudBuild | None = None): + return ParsedAgentRuntime( + name="my-agent", + container=ParsedContainer( + image="registry.example.com/ns/app:v1", + cloud_build=cloud_build, + ), + ) + + +def test_build_builder_env_uses_cfg_and_yaml_registry(monkeypatch): + monkeypatch.delenv("DOCKER_IMAGE_BUILDER_UID", raising=False) + cfg = SimpleNamespace( + get_account_id=lambda: "123", + get_access_key_id=lambda: "ak", + get_access_key_secret=lambda: "sk", + get_region_id=lambda: "cn-hangzhou", + ) + cloud_build = ParsedCloudBuild( + registry=ParsedCloudBuildRegistry("yaml-u", "yaml-p") + ) + env = build_builder_env( + cfg, + cloud_build, + ) + assert env["DOCKER_IMAGE_BUILDER_UID"] == "123" + assert env["DOCKER_IMAGE_BUILDER_AK"] == "ak" + assert env["DOCKER_IMAGE_BUILDER_SK"] == "sk" + assert env["DOCKER_IMAGE_BUILDER_REGION"] == "cn-hangzhou" + assert env["DOCKER_IMAGE_BUILDER_USERNAME"] == "yaml-u" + assert env["DOCKER_IMAGE_BUILDER_PASSWORD"] == "yaml-p" + + +def test_load_dotenv_sets_missing_values(monkeypatch, tmp_path): + env_file = tmp_path / ".env" + env_file.write_text( + "\n".join( + [ + "DOCKER_IMAGE_BUILDER_UID=123", + "QUOTED='value'", + "EXISTING=from-file", + "# ignored", + "invalid-line", + ] + ), + encoding="utf-8", + ) + monkeypatch.delenv("DOCKER_IMAGE_BUILDER_UID", raising=False) + monkeypatch.delenv("QUOTED", raising=False) + monkeypatch.setenv("EXISTING", "from-env") + + load_dotenv(env_file) + + assert os.environ["DOCKER_IMAGE_BUILDER_UID"] == "123" + assert os.environ["QUOTED"] == "value" + assert os.environ["EXISTING"] == "from-env" + + +def test_build_builder_args_do_not_include_secrets_or_registry_mode(): + cloud_build = ParsedCloudBuild( + dir=".", + setup_script="", + timeout_minutes="30", + cpu="8c", + memory="16384", + registry=ParsedCloudBuildRegistry("u", "secret"), + ) + args = build_builder_args("/bin/docker-image-builder", "reg/ns/app:v1", cloud_build) + joined = " ".join(args) + assert "--image=reg/ns/app:v1" in args + assert "--setup-script=" in args + assert "secret" not in joined + assert "registry-mode" not in joined + + +def test_build_builder_args_include_base_container_image(): + cloud_build = ParsedCloudBuild( + base_container_image="registry.example.com/ns/worker:tag" + ) + args = build_builder_args("/bin/docker-image-builder", "reg/ns/app:v1", cloud_build) + assert "--base-image=registry.example.com/ns/worker:tag" in args + + +def test_build_builder_args_include_region(): + cloud_build = ParsedCloudBuild(region="cn-shanghai") + args = build_builder_args("/bin/docker-image-builder", "reg/ns/app:v1", cloud_build) + assert "--region=cn-shanghai" in args + + +def test_serialize_cloud_build_plan_and_result(): + cloud_build = ParsedCloudBuild( + base_container_image="registry.example.com/ns/worker:tag" + ) + plan = serialize_cloud_build_plan(_runtime(cloud_build)) + assert plan and plan["baseContainerConfig"]["image"] == ( + "registry.example.com/ns/worker:tag" + ) + result = serialize_cloud_build_result( + cloud_build_mod.CloudBuildResult( + name="my-agent", + image="registry.example.com/ns/app:v1", + build_status="completed", + elapsed_seconds=0.1, + ) + ) + assert result == { + "name": "my-agent", + "image": "registry.example.com/ns/app:v1", + "buildStatus": "completed", + "elapsedSeconds": 0.1, + } + assert serialize_cloud_build_plan(_runtime(None)) is None + + +def test_ensure_builder_binary_uses_binpath(monkeypatch, tmp_path): + binary = tmp_path / "docker-image-builder" + binary.write_text("#!/bin/sh\n", encoding="utf-8") + binary.chmod(binary.stat().st_mode | stat.S_IXUSR) + monkeypatch.setenv("DOCKER_IMAGE_BUILDER_BINPATH", str(binary)) + assert ensure_builder_binary() == str(binary) + + +def test_ensure_builder_binary_rejects_bad_binpath(monkeypatch, tmp_path): + bad_path = tmp_path / "missing" + monkeypatch.setenv("DOCKER_IMAGE_BUILDER_BINPATH", str(bad_path)) + with pytest.raises(CloudBuildError, match="BINPATH"): + ensure_builder_binary() + + +def test_ensure_builder_binary_downloads_latest_with_checksum(monkeypatch, tmp_path): + monkeypatch.delenv("DOCKER_IMAGE_BUILDER_BINPATH", raising=False) + monkeypatch.delenv("DOCKER_IMAGE_BUILDER_BINTAG", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr( + "agentrun_cli._utils.cloud_build._artifact_name", + lambda: "docker-image-builder-linux-amd64", + ) + content = b"#!/bin/sh\n" + + def fake_download(url, target): + assert f"/{BUILDER_RELEASE_TAG}/" in url + target.write_bytes(content) + + def fake_download_sha256(url, artifact_name): + assert url.endswith("/docker-image-builder-linux-amd64.sha256") + assert artifact_name == "docker-image-builder-linux-amd64" + return sha256(content).hexdigest() + + monkeypatch.setattr( + "agentrun_cli._utils.cloud_build._download_binary", + fake_download, + ) + monkeypatch.setattr( + "agentrun_cli._utils.cloud_build._download_sha256", + fake_download_sha256, + ) + binary = ensure_builder_binary() + expected_suffix = ( + f".docker-image-builder/{BUILDER_RELEASE_TAG}/docker-image-builder" + ) + assert binary.endswith(expected_suffix) + assert os.access(binary, os.X_OK) + + +def test_ensure_builder_binary_uses_cached_bintag(monkeypatch, tmp_path): + monkeypatch.delenv("DOCKER_IMAGE_BUILDER_BINPATH", raising=False) + monkeypatch.setenv("DOCKER_IMAGE_BUILDER_BINTAG", "custom-tag") + monkeypatch.setenv("HOME", str(tmp_path)) + cached = tmp_path / ".docker-image-builder" / "custom-tag" / "docker-image-builder" + cached.parent.mkdir(parents=True) + content = b"#!/bin/sh\n" + cached.write_bytes(content) + cached.chmod(cached.stat().st_mode | stat.S_IXUSR) + monkeypatch.setattr( + "agentrun_cli._utils.cloud_build._download_sha256", + lambda *_args: sha256(content).hexdigest(), + ) + monkeypatch.setattr( + "agentrun_cli._utils.cloud_build._download_binary", + lambda *_args: pytest.fail("cached binary should not be downloaded"), + ) + assert ensure_builder_binary() == str(cached) + + +def test_ensure_builder_binary_replaces_stale_cached_latest(monkeypatch, tmp_path): + monkeypatch.delenv("DOCKER_IMAGE_BUILDER_BINPATH", raising=False) + monkeypatch.delenv("DOCKER_IMAGE_BUILDER_BINTAG", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr( + "agentrun_cli._utils.cloud_build._artifact_name", + lambda: "docker-image-builder-linux-amd64", + ) + cached = ( + tmp_path + / ".docker-image-builder" + / BUILDER_RELEASE_TAG + / "docker-image-builder" + ) + cached.parent.mkdir(parents=True) + cached.write_bytes(b"old") + cached.chmod(cached.stat().st_mode | stat.S_IXUSR) + new_content = b"new" + monkeypatch.setattr( + "agentrun_cli._utils.cloud_build._download_sha256", + lambda *_args: sha256(new_content).hexdigest(), + ) + + def fake_download(_url, target): + target.write_bytes(new_content) + + monkeypatch.setattr( + "agentrun_cli._utils.cloud_build._download_binary", + fake_download, + ) + assert ensure_builder_binary() == str(cached) + assert cached.read_bytes() == new_content + + +def test_ensure_builder_binary_rejects_checksum_mismatch(monkeypatch, tmp_path): + monkeypatch.delenv("DOCKER_IMAGE_BUILDER_BINPATH", raising=False) + monkeypatch.delenv("DOCKER_IMAGE_BUILDER_BINTAG", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr( + "agentrun_cli._utils.cloud_build._artifact_name", + lambda: "docker-image-builder-linux-amd64", + ) + monkeypatch.setattr( + "agentrun_cli._utils.cloud_build._download_sha256", + lambda *_args: sha256(b"expected").hexdigest(), + ) + monkeypatch.setattr( + "agentrun_cli._utils.cloud_build._download_binary", + lambda _url, target: target.write_bytes(b"actual"), + ) + with pytest.raises(CloudBuildError, match="checksum mismatch"): + ensure_builder_binary() + + +def test_ensure_builder_binary_download_failure(monkeypatch, tmp_path): + monkeypatch.delenv("DOCKER_IMAGE_BUILDER_BINPATH", raising=False) + monkeypatch.delenv("DOCKER_IMAGE_BUILDER_BINTAG", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr( + "agentrun_cli._utils.cloud_build._artifact_name", + lambda: "docker-image-builder-linux-amd64", + ) + monkeypatch.setattr( + "agentrun_cli._utils.cloud_build._download_sha256", + lambda *_args: sha256(b"bin").hexdigest(), + ) + monkeypatch.setattr( + "agentrun_cli._utils.cloud_build._download_binary", + lambda *_args: (_ for _ in ()).throw(RuntimeError("boom")), + ) + with pytest.raises(CloudBuildError, match="download docker-image-builder failed"): + ensure_builder_binary() + + +def test_download_binary(monkeypatch, tmp_path): + class Resp: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + return b"bin" + + target = tmp_path / "docker-image-builder" + monkeypatch.setattr("urllib.request.urlopen", lambda *_a, **_k: Resp()) + cloud_build_mod._download_binary("https://example.com/bin", target) + assert target.read_bytes() == b"bin" + + +def test_download_sha256(monkeypatch): + class Resp: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + return ( + b"619ab54b0f5dd2208ce04c910b6b6800daf591adb6c3873e3cd9eecdedac341f" + b" docker-image-builder-linux-amd64\n" + ) + + monkeypatch.setattr("urllib.request.urlopen", lambda *_a, **_k: Resp()) + assert ( + cloud_build_mod._download_sha256( + "https://example.com/bin.sha256", + "docker-image-builder-linux-amd64", + ) + == "619ab54b0f5dd2208ce04c910b6b6800daf591adb6c3873e3cd9eecdedac341f" + ) + + +def test_parse_sha256_accepts_raw_digest(): + digest = "a" * 64 + assert cloud_build_mod._parse_sha256(digest, "artifact") == digest + + +def test_parse_sha256_rejects_missing_artifact(): + with pytest.raises(CloudBuildError, match="invalid sha256"): + cloud_build_mod._parse_sha256("a" * 64 + " other-artifact", "artifact") + + +def test_build_runtime_image_runs_builder(monkeypatch): + calls = {} + cloud_build = ParsedCloudBuild() + monkeypatch.setattr( + "agentrun_cli._utils.cloud_build.ensure_builder_binary", + lambda: "/bin/dib", + ) + + def fake_run(args, env, stdout, stderr, check): + calls["args"] = args + calls["env"] = env + calls["stdout"] = stdout + calls["stderr"] = stderr + calls["check"] = check + return SimpleNamespace(returncode=0) + + monkeypatch.setattr("subprocess.run", fake_run) + result = build_runtime_image(_runtime(cloud_build), SimpleNamespace()) + assert result and result.build_status == "completed" + assert calls["args"][0] == "/bin/dib" + assert calls["args"][1] == "build" + assert calls["stdout"] is sys.stderr + assert calls["stderr"] is sys.stderr + assert calls["check"] is False + + +def test_build_runtime_image_without_cloud_build_returns_none(): + assert build_runtime_image(_runtime(None), SimpleNamespace()) is None + + +def test_build_runtime_image_failure_raises(monkeypatch): + cloud_build = ParsedCloudBuild() + monkeypatch.setattr( + "agentrun_cli._utils.cloud_build.ensure_builder_binary", + lambda: "/bin/dib", + ) + monkeypatch.setattr( + "subprocess.run", + lambda *_a, **_k: SimpleNamespace(returncode=7), + ) + with pytest.raises(CloudBuildError, match="code 7"): + build_runtime_image(_runtime(cloud_build), SimpleNamespace()) + + +def test_platform_and_arch_helpers(monkeypatch): + monkeypatch.setattr(cloud_build_mod.sys, "platform", "darwin") + assert cloud_build_mod._go_platform() == "darwin" + monkeypatch.setattr(cloud_build_mod.sys, "platform", "win32") + assert cloud_build_mod._go_platform() == "windows" + assert cloud_build_mod._executable_name() == "docker-image-builder.exe" + assert cloud_build_mod._artifact_name().endswith(".exe") + monkeypatch.setattr(cloud_build_mod.sys, "platform", "plan9") + with pytest.raises(CloudBuildError, match="unsupported platform"): + cloud_build_mod._go_platform() + + monkeypatch.setattr(cloud_build_mod.platform, "machine", lambda: "arm64") + assert cloud_build_mod._go_arch() == "arm64" + monkeypatch.setattr(cloud_build_mod.platform, "machine", lambda: "sparc") + with pytest.raises(CloudBuildError, match="unsupported arch"): + cloud_build_mod._go_arch() + + +def test_cfg_value_and_set_env_helpers(): + assert cloud_build_mod._cfg_value(SimpleNamespace(account_id="123"), "account_id") + assert ( + cloud_build_mod._cfg_value(SimpleNamespace(account_id=""), "account_id") is None + ) + env = {"KEY": "old"} + cloud_build_mod._set_env_if_present(env, "KEY", "new") + cloud_build_mod._set_env_if_present(env, "EMPTY", None) + assert env == {"KEY": "old"} diff --git a/tests/unit/test_runtime_yaml.py b/tests/unit/test_runtime_yaml.py index 6826b26..39af3e0 100644 --- a/tests/unit/test_runtime_yaml.py +++ b/tests/unit/test_runtime_yaml.py @@ -151,6 +151,95 @@ def test_container_full_fields(): assert rt.container.acr_instance_id == "cri-xxx" +def test_cloud_build_defaults_parsed(): + text = _doc_with( + spec={ + "container": { + "image": "registry.example.com/ns/app:v1", + "cloudBuild": {}, + } + } + ) + cloud_build = parse_yaml_text(text)[0].container.cloud_build + assert cloud_build is not None + assert cloud_build.dir == "." + assert cloud_build.setup_script == "scripts/setup.sh" + assert cloud_build.timeout_minutes == "20" + assert cloud_build.cpu == "4" + assert cloud_build.memory == "8192" + + +def test_cloud_build_registry_parsed(): + text = _doc_with( + spec={ + "container": { + "image": "registry.example.com/ns/app:v1", + "cloudBuild": { + "dir": "src", + "setupScript": "", + "timeoutMinutes": 30, + "cpu": "8c", + "memory": "16384", + "region": "cn-shanghai", + "registry": {"username": "u", "password": "p"}, + "baseContainerConfig": { + "image": "registry.example.com/ns/worker:tag" + }, + }, + } + } + ) + cloud_build = parse_yaml_text(text)[0].container.cloud_build + assert cloud_build is not None + assert cloud_build.dir == "src" + assert cloud_build.setup_script == "" + assert cloud_build.timeout_minutes == "30" + assert cloud_build.cpu == "8c" + assert cloud_build.memory == "16384" + assert cloud_build.region == "cn-shanghai" + assert cloud_build.registry and cloud_build.registry.username == "u" + assert cloud_build.base_container_image == "registry.example.com/ns/worker:tag" + + +def test_cloud_build_allows_image_without_prevalidation(): + text = _doc_with( + spec={ + "container": { + "image": "registry.example.com/ns/app", + "cloudBuild": {}, + } + } + ) + cloud_build = parse_yaml_text(text)[0].container.cloud_build + assert cloud_build is not None + + +def test_cloud_build_rejects_registry_mode_field(): + text = _doc_with( + spec={ + "container": { + "image": "registry.example.com/ns/app:v1", + "cloudBuild": {"registryMode": "fc-registry"}, + } + } + ) + with pytest.raises(YamlSchemaError, match="unsupported field"): + parse_yaml_text(text) + + +def test_cloud_build_rejects_acree_base_fields(): + text = _doc_with( + spec={ + "container": { + "image": "registry.example.com/ns/app:v1", + "cloudBuild": {"baseAcrInstanceId": "cri-xxx"}, + } + } + ) + with pytest.raises(YamlSchemaError, match="unsupported field"): + parse_yaml_text(text) + + def test_custom_registry_requires_config(): text = _doc_with( spec={"container": {"image": "img", "imageRegistryType": "CUSTOM"}}