diff --git a/docs/en/runtime.md b/docs/en/runtime.md index 9e31f16..1bfc00e 100644 --- a/docs/en/runtime.md +++ b/docs/en/runtime.md @@ -19,6 +19,7 @@ Also available as the alias `ar rt`. - [apply](#apply) — cloud-build when configured, then create-or-update from YAML. - [cloud-build](#cloud-build) — build images from YAML without deploying. - [render](#render) — dry-run validate + render to SDK input. +- [export](#export) — export one existing runtime as apply-ready YAML. - [get](#get) — fetch one runtime by name. - [list](#list) — list runtimes; filter by `--created-by-cli` or `--workspace`. - [delete](#delete) — delete a runtime (waits by default). @@ -157,6 +158,43 @@ before `apply`. --- +## export + +``` +ar runtime export NAME [-f FILE] [--include-secrets] +``` + +Export an existing Agent Runtime and its endpoints as `ar runtime apply` YAML. +The command writes YAML to stdout by default, or to `--file` when provided. It +exports only fields supported by the CLI YAML schema and intentionally omits +server-owned state such as IDs, ARNs, versions, status, and timestamps. +`cloudBuild` cannot be reconstructed from a remote runtime because it depends on +local source/build settings. +Registry authentication secrets such as passwords are omitted by default. Use +`--include-secrets` only when you explicitly need a full-fidelity export, and +review the YAML before committing or sharing it. + +### Options + +| Flag | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `-f`, `--file` | path | no | | Write YAML to a file instead of stdout. | +| `--include-secrets` | flag | no | `false` | Include sensitive registry authentication fields in exported YAML. | + +### Examples + +```bash +# Export to stdout. +ar runtime export my-agent + +# Export, edit metadata.name, then create a copy. +ar runtime export my-agent -f copied-runtime.yaml +# edit copied-runtime.yaml: metadata.name: my-agent-copy +ar runtime apply -f copied-runtime.yaml +``` + +--- + ## get ``` diff --git a/docs/zh/runtime.md b/docs/zh/runtime.md index f3f0768..fa23865 100644 --- a/docs/zh/runtime.md +++ b/docs/zh/runtime.md @@ -16,6 +16,7 @@ YAML;用户不写时 CLI 会自动注入一个名为 `default` 的 endpoint( - [apply](#apply) — 配置后先云上构建,再从 YAML create-or-update。 - [cloud-build](#cloud-build) — 只按 YAML 构建镜像,不部署 runtime。 - [render](#render) — 校验 + 渲染为 SDK 输入(不调用服务端)。 +- [export](#export) — 将存量 runtime 导出为可 apply 的 YAML。 - [get](#get) — 按名字获取单个 runtime。 - [list](#list) — 列出 runtime;可用 `--created-by-cli` 或 `--workspace` 过滤。 - [delete](#delete) — 删除 runtime(默认等待)。 @@ -148,6 +149,40 @@ ar runtime render -f FILE --- +## export + +``` +ar runtime export NAME [-f FILE] [--include-secrets] +``` + +读取一个存量 Agent Runtime 及其 endpoints,并导出为 `ar runtime apply` 可消费的 +YAML。默认输出到 stdout;传入 `--file` 时写入文件。命令只导出当前 CLI YAML +schema 支持的字段,会刻意省略 ID、ARN、version、status、时间戳等服务端状态字段。 +`cloudBuild` 依赖本地源码目录和构建参数,无法从远端 runtime 反推,因此不会导出。 +镜像仓库认证密码等敏感字段默认不导出。只有明确需要完整导出时才使用 +`--include-secrets`,提交或共享前请先检查 YAML。 + +### Options + +| Flag | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `-f`, `--file` | path | no | | 将 YAML 写入文件,而不是 stdout。 | +| `--include-secrets` | flag | no | `false` | 在导出 YAML 中包含敏感的镜像仓库认证字段。 | + +### Examples + +```bash +# 导出到 stdout +ar runtime export my-agent + +# 导出后修改 metadata.name,再创建一份副本 +ar runtime export my-agent -f copied-runtime.yaml +# 编辑 copied-runtime.yaml:metadata.name: my-agent-copy +ar runtime apply -f copied-runtime.yaml +``` + +--- + ## get ``` diff --git a/src/agentrun_cli/_utils/cloud_build.py b/src/agentrun_cli/_utils/cloud_build.py index 723adc7..76ca707 100644 --- a/src/agentrun_cli/_utils/cloud_build.py +++ b/src/agentrun_cli/_utils/cloud_build.py @@ -323,13 +323,14 @@ def _artifact_name() -> str: def _go_platform() -> str: """Convert a Python platform name to a Go release platform name.""" - if sys.platform == "win32": + platform_name = str(sys.platform) + if platform_name == "win32": return "windows" - if sys.platform == "darwin": + if platform_name == "darwin": return "darwin" - if sys.platform.startswith("linux"): + if platform_name.startswith("linux"): return "linux" - raise CloudBuildError(f"unsupported platform: {sys.platform}") + raise CloudBuildError(f"unsupported platform: {platform_name}") def _go_arch() -> str: diff --git a/src/agentrun_cli/commands/runtime/__init__.py b/src/agentrun_cli/commands/runtime/__init__.py index 5503a49..45653f0 100644 --- a/src/agentrun_cli/commands/runtime/__init__.py +++ b/src/agentrun_cli/commands/runtime/__init__.py @@ -20,6 +20,7 @@ ) 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 export_cmd as _export_mod # noqa: E402 from agentrun_cli.commands.runtime import render_cmd as _render_mod # noqa: E402 from agentrun_cli.commands.runtime import status_cmd as _status_mod # noqa: E402 @@ -35,6 +36,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(_export_mod.export_cmd) runtime_group.add_command(_crud_mod.get_cmd) runtime_group.add_command(_crud_mod.list_cmd) runtime_group.add_command(_delete_mod.delete_cmd) diff --git a/src/agentrun_cli/commands/runtime/export_cmd.py b/src/agentrun_cli/commands/runtime/export_cmd.py new file mode 100644 index 0000000..42a08ca --- /dev/null +++ b/src/agentrun_cli/commands/runtime/export_cmd.py @@ -0,0 +1,444 @@ +"""``ar runtime export`` — dump an existing runtime as CLI YAML.""" + +from __future__ import annotations + +from typing import Any + +import click +import yaml + +from agentrun_cli._utils.config import build_sdk_config +from agentrun_cli._utils.error import EXIT_BAD_INPUT, EXIT_NOT_FOUND, handle_errors +from agentrun_cli._utils.output import echo_error +from agentrun_cli._utils.runtime_reconciler import find_runtime_by_name +from agentrun_cli.commands.runtime._helpers import ctx_cfg + +AgentRuntime: Any = None + + +class RuntimeExportError(ValueError): + """Raised when a remote runtime cannot be represented as CLI YAML.""" + + +def _lazy_sdk() -> Any: + global AgentRuntime + if AgentRuntime is None: + from agentrun.agent_runtime import AgentRuntime as _AR + + AgentRuntime = _AR + return AgentRuntime + + +@click.command( + "export", + help="Export an existing Agent Runtime as ar runtime apply YAML.", +) +@click.argument("name") +@click.option( + "-f", + "--file", + "file_path", + default=None, + type=click.Path(dir_okay=False, writable=True), + help="Write YAML to a file instead of stdout.", +) +@click.option( + "--include-secrets", + is_flag=True, + help="Include sensitive registry authentication fields in exported YAML.", +) +@click.pass_context +@handle_errors +def export_cmd(ctx, name, file_path, include_secrets): + rt_cls = _lazy_sdk() + profile, region = ctx_cfg(ctx) + build_sdk_config(profile_name=profile, region=region) + runtime = find_runtime_by_name(rt_cls, name) + if runtime is None: + echo_error("ResourceNotFound", f"AgentRuntime {name!r} not found.") + raise SystemExit(EXIT_NOT_FOUND) + try: + data = runtime_to_yaml_doc(runtime, include_secrets=include_secrets) + except RuntimeExportError as exc: + echo_error("UnsupportedRuntime", str(exc)) + raise SystemExit(EXIT_BAD_INPUT) from exc + + text = yaml.safe_dump(data, allow_unicode=True, sort_keys=False) + if file_path: + with open(file_path, "w", encoding="utf-8") as f: + f.write(text) + return + click.echo(text, nl=False) + + +def runtime_to_yaml_doc( + runtime: Any, *, include_secrets: bool = False +) -> dict[str, Any]: + artifact_type = _enum_value(_get(runtime, "artifact_type", "artifactType")) + if artifact_type and artifact_type != "Container": + raise RuntimeExportError( + f"AgentRuntime {_get(runtime, 'agent_runtime_name', 'agentRuntimeName')!r} " + f"uses artifact_type={artifact_type!r}; CLI YAML supports only Container." + ) + + container = _get(runtime, "container_configuration", "containerConfiguration") + image = _get(container, "image") + if not image: + raise RuntimeExportError("Remote runtime has no container image to export.") + + metadata: dict[str, Any] = { + "name": _get(runtime, "agent_runtime_name", "agentRuntimeName") + } + _set_if_present(metadata, "description", _get(runtime, "description")) + workspace_id = _get(runtime, "workspace_id", "workspaceId") + if workspace_id: + metadata["workspaceId"] = workspace_id + else: + _set_if_present( + metadata, "workspace", _get(runtime, "workspace_name", "workspaceName") + ) + + spec: dict[str, Any] = { + "container": _export_container(container, include_secrets=include_secrets) + } + for yaml_key, attr in [ + ("cpu", "cpu"), + ("memory", "memory"), + ("port", "port"), + ("diskSize", "disk_size"), + ("enableSessionIsolation", "enable_session_isolation"), + ("credentialName", "credential_name"), + ("executionRoleArn", "execution_role_arn"), + ( + "sessionConcurrencyLimitPerInstance", + "session_concurrency_limit_per_instance", + ), + ("sessionIdleTimeoutSeconds", "session_idle_timeout_seconds"), + ]: + _set_if_present(spec, yaml_key, _get(runtime, attr, _camel(attr))) + + _set_if_present( + spec, + "protocol", + _export_protocol( + _get(runtime, "protocol_configuration", "protocolConfiguration") + ), + ) + _set_if_present( + spec, + "network", + _export_network(_get(runtime, "network_configuration", "networkConfiguration")), + ) + _set_if_present( + spec, + "healthCheck", + _export_health( + _get(runtime, "health_check_configuration", "healthCheckConfiguration") + ), + ) + _set_if_present( + spec, + "log", + _export_log(_get(runtime, "log_configuration", "logConfiguration")), + ) + _set_if_present( + spec, + "env", + _get(runtime, "environment_variables", "environmentVariables"), + ) + _set_if_present(spec, "nas", _export_nas(_get(runtime, "nas_config", "nasConfig"))) + _set_if_present( + spec, + "ossMount", + _export_oss_mount(_get(runtime, "oss_mount_config", "ossMountConfig")), + ) + + if hasattr(runtime, "list_endpoints"): + endpoints = [_export_endpoint(ep) for ep in runtime.list_endpoints()] + if endpoints: + spec["endpoints"] = endpoints + + return { + "apiVersion": "agentrun/v1", + "kind": "AgentRuntime", + "metadata": metadata, + "spec": spec, + } + + +def _export_container(container: Any, *, include_secrets: bool) -> dict[str, Any]: + out: dict[str, Any] = {"image": _get(container, "image")} + command = _get(container, "command") + if command: + out["command"] = command + _set_if_present(out, "port", _get(container, "port")) + _set_if_present( + out, + "imageRegistryType", + _enum_value(_get(container, "image_registry_type", "imageRegistryType")), + ) + _set_if_present( + out, "acrInstanceId", _get(container, "acr_instance_id", "acrInstanceId") + ) + _set_if_present( + out, + "registryConfig", + _export_registry( + _get(container, "registry_config", "registryConfig"), + include_secrets=include_secrets, + ), + ) + return out + + +def _export_registry(registry: Any, *, include_secrets: bool) -> dict[str, Any] | None: + if registry is None: + return None + out: dict[str, Any] = {} + auth = _get(registry, "auth_config", "authConfig", "auth") + auth_out: dict[str, Any] = {} + _set_if_present(auth_out, "userName", _get(auth, "user_name", "userName")) + if include_secrets: + _set_if_present(auth_out, "password", _get(auth, "password")) + _set_if_present(out, "auth", auth_out) + + cert = _get(registry, "cert_config", "certConfig", "cert") + cert_out: dict[str, Any] = {} + _set_if_present(cert_out, "insecure", _get(cert, "insecure")) + _set_if_present( + cert_out, + "rootCaCertBase64", + _get(cert, "root_ca_cert_base_64", "rootCaCertBase64"), + ) + _set_if_present(out, "cert", cert_out) + + network = _get(registry, "network_config", "networkConfig", "network") + net_out: dict[str, Any] = {} + _set_if_present(net_out, "vpcId", _get(network, "vpc_id", "vpcId")) + _set_if_present(net_out, "vSwitchId", _get(network, "v_switch_id", "vSwitchId")) + _set_if_present( + net_out, + "securityGroupId", + _get(network, "security_group_id", "securityGroupId"), + ) + _set_if_present(out, "network", net_out) + return out or None + + +def _export_protocol(protocol: Any) -> dict[str, Any] | None: + if protocol is None: + return None + out: dict[str, Any] = {} + _set_if_present(out, "type", _enum_value(_get(protocol, "type"))) + settings = _get(protocol, "protocol_settings", "protocolSettings", "settings") + if settings: + exported: list[dict[str, Any]] = [] + for setting in settings: + item: dict[str, Any] = {} + for key, attr in [ + ("type", "type"), + ("name", "name"), + ("path", "path"), + ("pathPrefix", "path_prefix"), + ("method", "method"), + ("requestContentType", "request_content_type"), + ("responseContentType", "response_content_type"), + ("headers", "headers"), + ("inputBodyJsonSchema", "input_body_json_schema"), + ("outputBodyJsonSchema", "output_body_json_schema"), + ("a2aAgentCard", "a2a_agent_card"), + ("a2aAgentCardUrl", "a2a_agent_card_url"), + ("config", "config"), + ]: + value = _get(setting, attr, _camel(attr)) + if key == "type": + value = _enum_value(value) + _set_if_present(item, key, value) + if item: + exported.append(item) + if exported: + out["settings"] = exported + return out or None + + +def _export_network(network: Any) -> dict[str, Any] | None: + if network is None: + return None + out: dict[str, Any] = {} + _set_if_present( + out, + "mode", + _enum_value(_get(network, "network_mode", "networkMode", "mode")), + ) + _set_if_present(out, "vpcId", _get(network, "vpc_id", "vpcId")) + _set_if_present(out, "vswitchIds", _get(network, "vswitch_ids", "vswitchIds")) + _set_if_present( + out, + "securityGroupId", + _get(network, "security_group_id", "securityGroupId"), + ) + return out or None + + +def _export_health(health: Any) -> dict[str, Any] | None: + if health is None: + return None + out: dict[str, Any] = {} + for key, attr in [ + ("httpGetUrl", "http_get_url"), + ("initialDelaySeconds", "initial_delay_seconds"), + ("periodSeconds", "period_seconds"), + ("timeoutSeconds", "timeout_seconds"), + ("failureThreshold", "failure_threshold"), + ("successThreshold", "success_threshold"), + ]: + _set_if_present(out, key, _get(health, attr, _camel(attr))) + return out or None + + +def _export_log(log: Any) -> dict[str, Any] | None: + if log is None: + return None + out: dict[str, Any] = {} + _set_if_present(out, "project", _get(log, "project")) + _set_if_present(out, "logstore", _get(log, "logstore")) + return out or None + + +def _export_nas(nas: Any) -> dict[str, Any] | None: + if nas is None: + return None + out: dict[str, Any] = {} + _set_if_present(out, "userId", _get(nas, "user_id", "userId")) + _set_if_present(out, "groupId", _get(nas, "group_id", "groupId")) + points: list[dict[str, Any]] = [] + for mp in _get(nas, "mount_points", "mountPoints") or []: + item: dict[str, Any] = {} + _set_if_present(item, "serverAddr", _get(mp, "server_addr", "serverAddr")) + _set_if_present(item, "mountDir", _get(mp, "mount_dir", "mountDir")) + _set_if_present(item, "enableTLS", _get(mp, "enable_tls", "enableTLS")) + if item: + points.append(item) + if points: + out["mountPoints"] = points + return out or None + + +def _export_oss_mount(oss: Any) -> dict[str, Any] | None: + if oss is None: + return None + points: list[dict[str, Any]] = [] + for mp in _get(oss, "mount_points", "mountPoints") or []: + item: dict[str, Any] = {} + for key, attr in [ + ("bucketName", "bucket_name"), + ("mountDir", "mount_dir"), + ("bucketPath", "bucket_path"), + ("endpoint", "endpoint"), + ("readOnly", "read_only"), + ]: + _set_if_present(item, key, _get(mp, attr, _camel(attr))) + if item: + points.append(item) + return {"mountPoints": points} if points else None + + +def _export_endpoint(endpoint: Any) -> dict[str, Any]: + out: dict[str, Any] = { + "name": _get( + endpoint, "agent_runtime_endpoint_name", "agentRuntimeEndpointName" + ) + } + _set_if_present(out, "description", _get(endpoint, "description")) + routing = _export_routing( + _get(endpoint, "routing_configuration", "routingConfiguration") + ) + if routing: + out["routing"] = routing + else: + _set_if_present( + out, "targetVersion", _get(endpoint, "target_version", "targetVersion") + ) + _set_if_present( + out, + "disablePublicNetworkAccess", + _get(endpoint, "disable_public_network_access", "disablePublicNetworkAccess"), + ) + _set_if_present( + out, + "scaling", + _export_scaling(_get(endpoint, "scaling_config", "scalingConfig")), + ) + return out + + +def _export_routing(routing: Any) -> list[dict[str, Any]] | None: + weights = _get(routing, "version_weights", "versionWeights") + if not weights: + return None + out: list[dict[str, Any]] = [] + for weight in weights: + item: dict[str, Any] = {} + _set_if_present(item, "version", _get(weight, "version")) + _set_if_present(item, "weight", _get(weight, "weight")) + if item: + out.append(item) + return out or None + + +def _export_scaling(scaling: Any) -> dict[str, Any] | None: + if scaling is None: + return None + out: dict[str, Any] = {} + _set_if_present(out, "minInstances", _get(scaling, "min_instances", "minInstances")) + policies: list[dict[str, Any]] = [] + for policy in _get(scaling, "scheduled_policies", "scheduledPolicies") or []: + item: dict[str, Any] = {} + for key, attr in [ + ("name", "name"), + ("scheduleExpression", "schedule_expression"), + ("startTime", "start_time"), + ("endTime", "end_time"), + ("target", "target"), + ("timeZone", "time_zone"), + ]: + _set_if_present(item, key, _get(policy, attr, _camel(attr))) + if item: + policies.append(item) + if policies: + out["scheduledPolicies"] = policies + return out or None + + +def _set_if_present(target: dict[str, Any], key: str, value: Any) -> None: + if value is None or value == {}: + return + target[key] = value + + +def _get(obj: Any, *names: str) -> Any | None: + if obj is None: + return None + if isinstance(obj, dict): + for name in names: + if name in obj: + return obj[name] + return None + for name in names: + if hasattr(obj, name): + return getattr(obj, name) + return None + + +def _enum_value(value: Any) -> Any | None: + if value is None: + return None + if hasattr(value, "value"): + return value.value + text = str(value) + return text.rsplit(".", 1)[-1] + + +def _camel(name: str) -> str: + parts = name.split("_") + return parts[0] + "".join(p.capitalize() for p in parts[1:]) diff --git a/tests/integration/test_runtime_cmd.py b/tests/integration/test_runtime_cmd.py index f01c49d..896dd65 100644 --- a/tests/integration/test_runtime_cmd.py +++ b/tests/integration/test_runtime_cmd.py @@ -12,10 +12,17 @@ from unittest.mock import MagicMock, patch import click +import pytest +import yaml from click.testing import CliRunner +from agentrun_cli._utils.agentruntime_yaml import parse_yaml_text from agentrun_cli._utils.cloud_build import CloudBuildError, CloudBuildResult from agentrun_cli.commands.runtime import runtime_group +from agentrun_cli.commands.runtime.export_cmd import ( + RuntimeExportError, + runtime_to_yaml_doc, +) def _root(): @@ -43,6 +50,7 @@ def test_runtime_group_registered(): assert "apply" in result.output assert "cloud-build" in result.output assert "render" in result.output + assert "export" in result.output VALID_YAML = """ @@ -579,6 +587,326 @@ def test_list_runtimes_created_by_cli_filter(): assert {r["name"] for r in out} == {"cli-one"} +def _make_export_runtime(): + runtime = _make_runtime() + runtime.artifact_type = "Container" + runtime.description = "export me" + runtime.workspace_name = "ws-name" + runtime.workspace_id = "ws-1" + runtime.container_configuration = SimpleNamespace( + image="registry.example.com/ns/app:v1", + command=["python", "app.py"], + port=8000, + image_registry_type=SimpleNamespace(value="CUSTOM"), + acr_instance_id="acr-1", + registry_config=SimpleNamespace( + auth_config=SimpleNamespace( + user_name="repo-user", + password="repo-pass", # noqa: S106 - test fixture + ), + cert_config=SimpleNamespace(insecure=True, root_ca_cert_base_64="cert"), + network_config=SimpleNamespace( + vpc_id="vpc-1", + v_switch_id="vsw-1", + security_group_id="sg-1", + ), + ), + ) + runtime.cpu = 4.0 + runtime.memory = 8192 + runtime.port = 8000 + runtime.disk_size = 10240 + runtime.enable_session_isolation = True + runtime.network_configuration = SimpleNamespace( + network_mode="PUBLIC_AND_PRIVATE", + vpc_id="vpc-1", + vswitch_ids=["vsw-1"], + security_group_id="sg-1", + ) + runtime.health_check_configuration = SimpleNamespace( + http_get_url="/healthz", + initial_delay_seconds=5, + period_seconds=10, + timeout_seconds=3, + failure_threshold=2, + success_threshold=1, + ) + runtime.log_configuration = SimpleNamespace(project="proj", logstore="store") + runtime.environment_variables = {"LOG_LEVEL": "info"} + runtime.credential_name = "cred-1" + runtime.execution_role_arn = "acs:ram::1:role/runtime" + runtime.session_concurrency_limit_per_instance = 2 + runtime.session_idle_timeout_seconds = 600 + runtime.protocol_configuration = SimpleNamespace( + type="HTTP", + protocol_settings=[ + SimpleNamespace( + type=SimpleNamespace(value="HTTP"), + name="invoke", + path="/invoke", + path_prefix="/api", + method="POST", + request_content_type="application/json", + response_content_type="application/json", + headers='{"x-test":"1"}', + input_body_json_schema="{}", + output_body_json_schema="{}", + a2a_agent_card="{}", + a2a_agent_card_url="https://example.com/card.json", + config="{}", + ) + ], + ) + runtime.nas_config = SimpleNamespace( + user_id=10003, + group_id=10003, + mount_points=[ + SimpleNamespace( + server_addr="0a12b4.cn-hangzhou.nas.aliyuncs.com:/", + mount_dir="/mnt/nas", + enable_tls=True, + ) + ], + ) + runtime.oss_mount_config = SimpleNamespace( + mount_points=[ + SimpleNamespace( + bucket_name="bucket-1", + mount_dir="/mnt/oss", + bucket_path="prefix/", + endpoint="oss-cn-hangzhou.aliyuncs.com", + read_only=True, + ) + ] + ) + endpoint = SimpleNamespace( + agent_runtime_endpoint_name="default", + description="default endpoint", + target_version="LATEST", + routing_configuration=SimpleNamespace( + version_weights=[SimpleNamespace(version="1", weight=100.0)] + ), + disable_public_network_access=False, + scaling_config=SimpleNamespace( + min_instances=1, + scheduled_policies=[ + SimpleNamespace( + name="business-hours", + schedule_expression="cron(0 9 ? * MON-FRI *)", + start_time="2026-01-01T00:00:00Z", + end_time="2026-12-31T23:59:59Z", + target=2, + time_zone="Asia/Shanghai", + ) + ], + ), + ) + runtime.list_endpoints = MagicMock(return_value=[endpoint]) + return runtime + + +def test_export_runtime_outputs_apply_yaml(): + rt = _make_export_runtime() + rt_cls = MagicMock() + rt_cls.list_all.return_value = [rt] + with ( + patch( + "agentrun_cli.commands.runtime.export_cmd.build_sdk_config", + return_value=MagicMock(), + ), + patch("agentrun_cli.commands.runtime.export_cmd.AgentRuntime", rt_cls), + ): + result = CliRunner().invoke(_root(), ["runtime", "export", "my-agent"]) + assert result.exit_code == 0, result.output + out = yaml.safe_load(result.output) + assert out["apiVersion"] == "agentrun/v1" + assert out["kind"] == "AgentRuntime" + assert parse_yaml_text(result.output)[0].name == "my-agent" + assert out["metadata"] == { + "name": "my-agent", + "description": "export me", + "workspaceId": "ws-1", + } + assert out["spec"]["container"]["image"] == "registry.example.com/ns/app:v1" + assert out["spec"]["container"]["registryConfig"]["auth"]["userName"] == "repo-user" + assert "password" not in out["spec"]["container"]["registryConfig"]["auth"] + assert out["spec"]["protocol"]["settings"][0]["path"] == "/invoke" + assert out["spec"]["nas"]["mountPoints"][0]["mountDir"] == "/mnt/nas" + assert out["spec"]["ossMount"]["mountPoints"][0]["bucketName"] == "bucket-1" + assert out["spec"]["env"] == {"LOG_LEVEL": "info"} + assert out["spec"]["endpoints"] == [ + { + "name": "default", + "description": "default endpoint", + "routing": [{"version": "1", "weight": 100.0}], + "disablePublicNetworkAccess": False, + "scaling": { + "minInstances": 1, + "scheduledPolicies": [ + { + "name": "business-hours", + "scheduleExpression": "cron(0 9 ? * MON-FRI *)", + "startTime": "2026-01-01T00:00:00Z", + "endTime": "2026-12-31T23:59:59Z", + "target": 2, + "timeZone": "Asia/Shanghai", + } + ], + }, + } + ] + + +def test_export_runtime_writes_file(): + rt = _make_export_runtime() + rt_cls = MagicMock() + rt_cls.list_all.return_value = [rt] + with ( + patch( + "agentrun_cli.commands.runtime.export_cmd.build_sdk_config", + return_value=MagicMock(), + ), + patch("agentrun_cli.commands.runtime.export_cmd.AgentRuntime", rt_cls), + ): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke( + _root(), + ["runtime", "export", "my-agent", "--file", "runtime.yaml"], + ) + assert result.exit_code == 0, result.output + assert result.output == "" + with open("runtime.yaml", encoding="utf-8") as f: + out = yaml.safe_load(f) + assert out["metadata"]["name"] == "my-agent" + + +def test_export_runtime_can_include_secrets_explicitly(): + rt = _make_export_runtime() + rt_cls = MagicMock() + rt_cls.list_all.return_value = [rt] + with ( + patch( + "agentrun_cli.commands.runtime.export_cmd.build_sdk_config", + return_value=MagicMock(), + ), + patch("agentrun_cli.commands.runtime.export_cmd.AgentRuntime", rt_cls), + ): + result = CliRunner().invoke( + _root(), ["runtime", "export", "my-agent", "--include-secrets"] + ) + assert result.exit_code == 0, result.output + out = yaml.safe_load(result.output) + assert out["spec"]["container"]["registryConfig"]["auth"]["password"] == "repo-pass" + + +def test_export_runtime_omits_empty_endpoint_list(): + rt = _make_export_runtime() + rt.list_endpoints = MagicMock(return_value=[]) + rt_cls = MagicMock() + rt_cls.list_all.return_value = [rt] + with ( + patch( + "agentrun_cli.commands.runtime.export_cmd.build_sdk_config", + return_value=MagicMock(), + ), + patch("agentrun_cli.commands.runtime.export_cmd.AgentRuntime", rt_cls), + ): + result = CliRunner().invoke(_root(), ["runtime", "export", "my-agent"]) + assert result.exit_code == 0, result.output + out = yaml.safe_load(result.output) + assert "endpoints" not in out["spec"] + + +def test_export_runtime_not_found_exit_1(): + rt_cls = MagicMock() + rt_cls.list_all.return_value = [] + with ( + patch( + "agentrun_cli.commands.runtime.export_cmd.build_sdk_config", + return_value=MagicMock(), + ), + patch("agentrun_cli.commands.runtime.export_cmd.AgentRuntime", rt_cls), + ): + result = CliRunner().invoke(_root(), ["runtime", "export", "missing"]) + assert result.exit_code == 1 + + +def test_export_runtime_rejects_non_container_runtime(): + rt = _make_export_runtime() + rt.artifact_type = "Code" + rt_cls = MagicMock() + rt_cls.list_all.return_value = [rt] + with ( + patch( + "agentrun_cli.commands.runtime.export_cmd.build_sdk_config", + return_value=MagicMock(), + ), + patch("agentrun_cli.commands.runtime.export_cmd.AgentRuntime", rt_cls), + ): + result = CliRunner().invoke(_root(), ["runtime", "export", "my-agent"]) + assert result.exit_code == 2 + assert "Container" in result.stderr + + +def test_runtime_to_yaml_doc_minimal_dict_runtime(): + doc = runtime_to_yaml_doc( + { + "agentRuntimeName": "dict-agent", + "artifactType": "Container", + "containerConfiguration": { + "image": "registry.example.com/ns/app:v1", + "registryConfig": {}, + }, + } + ) + assert doc == { + "apiVersion": "agentrun/v1", + "kind": "AgentRuntime", + "metadata": {"name": "dict-agent"}, + "spec": {"container": {"image": "registry.example.com/ns/app:v1"}}, + } + + +def test_runtime_to_yaml_doc_minimal_object_runtime_branches(): + endpoint = SimpleNamespace( + agent_runtime_endpoint_name="default", + routing_configuration=None, + target_version="LATEST", + scaling_config=None, + ) + runtime = SimpleNamespace( + agent_runtime_name="minimal-agent", + artifact_type=None, + container_configuration=SimpleNamespace(image="registry.example.com/ns/app:v1"), + protocol_configuration=SimpleNamespace(type="HTTP"), + ) + runtime.list_endpoints = MagicMock(return_value=[endpoint]) + + doc = runtime_to_yaml_doc(runtime) + + assert doc == { + "apiVersion": "agentrun/v1", + "kind": "AgentRuntime", + "metadata": {"name": "minimal-agent"}, + "spec": { + "container": {"image": "registry.example.com/ns/app:v1"}, + "protocol": {"type": "HTTP"}, + "endpoints": [{"name": "default", "targetVersion": "LATEST"}], + }, + } + + +def test_runtime_to_yaml_doc_rejects_missing_container_image(): + runtime = SimpleNamespace( + agent_runtime_name="bad-agent", + artifact_type="Container", + container_configuration=SimpleNamespace(), + ) + with pytest.raises(RuntimeExportError, match="container image"): + runtime_to_yaml_doc(runtime) + + def test_delete_idempotent_when_missing(): rt_cls = MagicMock() rt_cls.list_all.return_value = [] @@ -682,6 +1010,7 @@ def test_real_cli_exposes_runtime_group(): assert result.exit_code == 0 assert "apply" in result.output assert "cloud-build" in result.output + assert "export" in result.output def test_real_cli_exposes_rt_alias():