Skip to content

Commit f677311

Browse files
committed
fix(runtime): validate cloud-build inputs and checksum
Signed-off-by: 117503445 <t117503445@gmail.com>
1 parent 3e19180 commit f677311

6 files changed

Lines changed: 262 additions & 16 deletions

File tree

docs/en/runtime.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,19 @@ ar runtime cloud-build -f FILE
114114
Runs only the `spec.container.cloudBuild` step and does not create or update the
115115
runtime. For each document, the command invokes docker-image-builder and reports
116116
`completed` when the builder exits successfully. The builder skips existing
117-
target tags by default.
117+
target tags by default. For multi-document YAML (`---` separated), every
118+
document must define `spec.container.cloudBuild`; otherwise the command fails
119+
before invoking any builder process.
118120

119121
`cloud-build` uses the same credentials as `apply`: AgentRun profile values for
120122
Aliyun UID/AK/SK, and `DOCKER_IMAGE_BUILDER_USERNAME` /
121123
`DOCKER_IMAGE_BUILDER_PASSWORD` for registry auth unless the YAML overrides them
122124
under `cloudBuild.registry`.
123125

126+
When the CLI downloads docker-image-builder automatically, it verifies the
127+
sibling `.sha256` file before caching or running the binary. Set
128+
`DOCKER_IMAGE_BUILDER_BINPATH` to use an explicit local builder binary.
129+
124130
### Examples
125131

126132
```bash

docs/zh/runtime.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,16 @@ ar runtime cloud-build -f FILE
109109

110110
只执行 `spec.container.cloudBuild` 构建步骤,不创建或更新 runtime。每篇文档都会调用
111111
docker-image-builder;builder 成功退出时输出 `completed`。docker-image-builder
112-
默认会跳过已存在的目标 tag。
112+
默认会跳过已存在的目标 tag。对于用 `---` 分隔的多文档 YAML,每篇都必须声明
113+
`spec.container.cloudBuild`;否则命令会在启动任何 builder 进程前失败。
113114

114115
`cloud-build` 使用与 `apply` 相同的凭据来源:阿里云 UID/AK/SK 读取 AgentRun profile;
115116
镜像仓库用户名和密码优先读 YAML 的 `cloudBuild.registry`,否则读取
116117
`DOCKER_IMAGE_BUILDER_USERNAME` / `DOCKER_IMAGE_BUILDER_PASSWORD`
117118

119+
CLI 自动下载 docker-image-builder 时,会先校验同名 `.sha256` 文件,再缓存或执行该
120+
二进制。设置 `DOCKER_IMAGE_BUILDER_BINPATH` 可以使用显式指定的本地 builder。
121+
118122
### Examples
119123

120124
```bash

src/agentrun_cli/_utils/cloud_build.py

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@
1010
import time
1111
import urllib.request
1212
from dataclasses import dataclass
13+
from hashlib import sha256
1314
from pathlib import Path
1415
from typing import Any
1516

1617
from agentrun_cli._utils.agentruntime_yaml import ParsedAgentRuntime, ParsedCloudBuild
1718

18-
BUILDER_RELEASE_TAG = "v0.0.0-20260527-022927-3f8907ca6b2f"
19+
BUILDER_RELEASE_TAG = "latest"
1920
BUILDER_BASE_URL = "https://images.devsapp.cn/docker-image-builder"
2021

2122

@@ -206,14 +207,17 @@ def ensure_builder_binary() -> str:
206207
tag = os.getenv("DOCKER_IMAGE_BUILDER_BINTAG", "").strip() or BUILDER_RELEASE_TAG
207208
install_dir = Path.home() / ".docker-image-builder" / tag
208209
target = install_dir / _executable_name()
209-
if _is_executable(target):
210-
return str(target)
211210

212211
install_dir.mkdir(parents=True, exist_ok=True)
213212
tmp = install_dir / f"{_executable_name()}.tmp-{os.getpid()}"
214-
url = f"{BUILDER_BASE_URL}/{tag}/{_artifact_name()}"
213+
artifact = _artifact_name()
214+
url = f"{BUILDER_BASE_URL}/{tag}/{artifact}"
215215
try:
216+
expected_sha256 = _download_sha256(f"{url}.sha256", artifact)
217+
if _is_executable(target) and _sha256_file(target) == expected_sha256:
218+
return str(target)
216219
_download_binary(url, tmp)
220+
_verify_sha256(tmp, expected_sha256)
217221
tmp.chmod(tmp.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
218222
tmp.replace(target)
219223
except Exception as exc:
@@ -233,6 +237,66 @@ def _download_binary(url: str, target: Path) -> None:
233237
target.write_bytes(resp.read())
234238

235239

240+
def _download_sha256(url: str, artifact_name: str) -> str:
241+
"""Download and parse a SHA256 checksum file.
242+
243+
Args:
244+
url: Checksum URL.
245+
artifact_name: Expected release artifact name.
246+
"""
247+
with urllib.request.urlopen(url, timeout=30) as resp: # noqa: S310
248+
text = resp.read().decode("utf-8")
249+
return _parse_sha256(text, artifact_name)
250+
251+
252+
def _parse_sha256(text: str, artifact_name: str) -> str:
253+
"""Parse a SHA256 checksum file.
254+
255+
Args:
256+
text: Checksum file content.
257+
artifact_name: Expected release artifact name.
258+
"""
259+
for raw_line in text.splitlines():
260+
line = raw_line.strip()
261+
if not line or line.startswith("#"):
262+
continue
263+
parts = line.split()
264+
digest = parts[0].lower()
265+
if len(digest) != 64 or any(ch not in "0123456789abcdef" for ch in digest):
266+
continue
267+
if len(parts) == 1 or parts[-1].lstrip("*") == artifact_name:
268+
return digest
269+
raise CloudBuildError(f"invalid sha256 checksum file for {artifact_name}")
270+
271+
272+
def _verify_sha256(path: Path, expected_sha256: str) -> None:
273+
"""Verify a local file against an expected SHA256 digest.
274+
275+
Args:
276+
path: File path to verify.
277+
expected_sha256: Expected SHA256 digest.
278+
"""
279+
actual_sha256 = _sha256_file(path)
280+
if actual_sha256 != expected_sha256:
281+
raise CloudBuildError(
282+
"checksum mismatch for docker-image-builder: "
283+
f"expected {expected_sha256}, got {actual_sha256}"
284+
)
285+
286+
287+
def _sha256_file(path: Path) -> str:
288+
"""Compute the SHA256 digest of a local file.
289+
290+
Args:
291+
path: File path to hash.
292+
"""
293+
digest = sha256()
294+
with path.open("rb") as f:
295+
for chunk in iter(lambda: f.read(1024 * 1024), b""):
296+
digest.update(chunk)
297+
return digest.hexdigest()
298+
299+
236300
def _is_executable(path: Path) -> bool:
237301
"""Return whether the path is an executable file.
238302

src/agentrun_cli/commands/runtime/cloud_build_cmd.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import click
88

99
from agentrun_cli._utils.agentruntime_yaml import (
10+
ParsedAgentRuntime,
1011
YamlSchemaError,
1112
parse_yaml_file,
1213
)
@@ -34,6 +35,27 @@ def _parse_file(path: str):
3435
raise SystemExit(EXIT_BAD_INPUT) from exc
3536

3637

38+
def _require_cloud_build_blocks(docs: list[ParsedAgentRuntime]) -> None:
39+
"""Validate that all runtime documents declare cloud build config.
40+
41+
Args:
42+
docs: Parsed runtime documents.
43+
"""
44+
missing = [
45+
f"Document #{idx + 1} runtime {parsed.name!r}"
46+
for idx, parsed in enumerate(docs)
47+
if parsed.container.cloud_build is None
48+
]
49+
if not missing:
50+
return
51+
echo_error(
52+
"InvalidYaml",
53+
"All runtime documents must define spec.container.cloudBuild before "
54+
f"cloud-build starts; missing: {'; '.join(missing)}.",
55+
)
56+
raise SystemExit(EXIT_BAD_INPUT)
57+
58+
3759
@click.command(
3860
"cloud-build",
3961
help="Build Agent Runtime images in the cloud from YAML.",
@@ -49,18 +71,14 @@ def _parse_file(path: str):
4971
@handle_errors
5072
def cloud_build_cmd(ctx, file_path):
5173
load_dotenv()
74+
docs = _parse_file(file_path)
75+
_require_cloud_build_blocks(docs)
76+
5277
profile, region = ctx_cfg(ctx)
5378
cfg = build_sdk_config(profile_name=profile, region=region)
54-
docs = _parse_file(file_path)
5579

5680
results = []
5781
for parsed in docs:
58-
if parsed.container.cloud_build is None:
59-
echo_error(
60-
"InvalidYaml",
61-
f"runtime {parsed.name!r} does not define spec.container.cloudBuild.",
62-
)
63-
raise SystemExit(EXIT_BAD_INPUT)
6482
result = build_runtime_image(parsed, cfg)
6583
if result is None:
6684
continue

tests/integration/test_runtime_cmd.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,20 @@ def test_runtime_group_registered():
7070
image: registry.example.com/ns/worker:tag
7171
"""
7272

73+
MULTI_DOC_PARTIAL_CLOUD_BUILD_YAML = (
74+
CLOUD_BUILD_YAML
75+
+ """
76+
---
77+
apiVersion: agentrun/v1
78+
kind: AgentRuntime
79+
metadata:
80+
name: plain-agent
81+
spec:
82+
container:
83+
image: registry.example.com/ns/plain:v1
84+
"""
85+
)
86+
7387

7488
def test_render_outputs_rendered_input():
7589
fake_input = MagicMock()
@@ -172,6 +186,34 @@ def test_cloud_build_command_requires_cloud_build_block():
172186
assert result.exit_code == 2
173187

174188

189+
def test_cloud_build_command_prescans_all_docs_before_building():
190+
result_obj = CloudBuildResult(
191+
name="my-agent",
192+
image="registry.example.com/ns/app:v1",
193+
build_status="completed",
194+
elapsed_seconds=0.1,
195+
)
196+
with (
197+
patch(
198+
"agentrun_cli.commands.runtime.cloud_build_cmd.build_sdk_config",
199+
return_value=MagicMock(),
200+
) as cfg_mock,
201+
patch(
202+
"agentrun_cli.commands.runtime.cloud_build_cmd.build_runtime_image",
203+
return_value=result_obj,
204+
) as build_mock,
205+
):
206+
runner = CliRunner()
207+
with runner.isolated_filesystem():
208+
with open("rt.yaml", "w") as f:
209+
f.write(MULTI_DOC_PARTIAL_CLOUD_BUILD_YAML)
210+
result = runner.invoke(_root(), ["runtime", "cloud-build", "-f", "rt.yaml"])
211+
assert result.exit_code == 2
212+
assert "plain-agent" in result.output
213+
cfg_mock.assert_not_called()
214+
build_mock.assert_not_called()
215+
216+
175217
def test_cloud_build_command_no_results_exit_code_2():
176218
with (
177219
patch(

0 commit comments

Comments
 (0)