Skip to content

Commit ef324a6

Browse files
jolestarjolestar
andauthored
runtime: formalize required tools contract (#708)
* runtime: switch default base image to holon-base v0.1.0 * runtime: formalize required tools contract - add built-in runtime tools contract and installer script generator - refactor composed image build to use contract-driven install with fail-fast verification - add runtime tools contract docs and unit tests - remove old inline node-major parsing/install path from runtime build * runtime: address review feedback for tools installer - fix fd download URL/version to avoid 404 in integration builds - remove mutable exported required tools slice - add dnf/yum cache cleanup and include ripgrep in yum path - restore gh-webhook extension install (best effort) - avoid Dockerfile heredoc dependency by copying installer script file - align plan doc required list wording with command-based contract * runtime: pin yq version for deterministic builds Use a fixed yq release URL instead of latest to avoid non-deterministic composed image builds. * runtime: add apt retry for integration stability Mitigate transient apt mirror 5xx in ubuntu-based integration flows by retrying apt-get update/install with Acquire::Retries. * runtime: require Node 20+ in composed image Fix integration failure in solve-matrix-pr-fix-flow caused by legacy Node from ubuntu:22.04 package repos. Ensure Node 20+ via NodeSource when needed. --------- Co-authored-by: jolestar <host-user@example.com>
1 parent 4eea642 commit ef324a6

File tree

10 files changed

+431
-141
lines changed

10 files changed

+431
-141
lines changed

cmd/holon/runner_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"testing"
1111
"time"
1212

13+
"github.com/holon-run/holon/pkg/image"
1314
"github.com/holon-run/holon/pkg/runtime/docker"
1415
)
1516

@@ -703,8 +704,8 @@ func TestRunner_Integration(t *testing.T) {
703704
mockRuntime := &MockRuntime{
704705
RunHolonFunc: func(ctx context.Context, cfg *docker.ContainerConfig) (string, error) {
705706
// Verify all expected values are in the config
706-
if cfg.BaseImage != "golang:1.22" {
707-
t.Errorf("Expected BaseImage to be 'golang:1.22', got %q", cfg.BaseImage)
707+
if cfg.BaseImage != image.DefaultImage {
708+
t.Errorf("Expected BaseImage to be %q, got %q", image.DefaultImage, cfg.BaseImage)
708709
}
709710
if cfg.AgentBundle == "" {
710711
t.Errorf("Expected AgentBundle to be set")
@@ -756,7 +757,7 @@ func TestRunner_Integration(t *testing.T) {
756757
WorkspacePath: workspaceDir,
757758
ContextPath: contextDir,
758759
OutDir: outDir,
759-
BaseImage: "golang:1.22",
760+
BaseImage: image.DefaultImage,
760761
AgentBundle: bundlePath,
761762
AgentHome: t.TempDir(),
762763
RoleName: "coder",
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Runtime Tools Contract 重构方案
2+
3+
## 1. 背景与问题
4+
5+
当前 `buildComposedImageFromBundle``pkg/runtime/docker/runtime.go` 内部内联了工具安装逻辑(Node、git、gh、gh-webhook 等),存在几个问题:
6+
7+
1. 工具契约是隐式的,没有正式定义哪些工具是必须有的。
8+
2. 安装逻辑硬编码在字符串 Dockerfile 中,维护成本高,扩展困难。
9+
3. 当前 required tools 列表未正式化,难以作为稳定契约演进。
10+
4. 缺少正式契约导致工具保证边界不清晰,出现运行时不确定性。
11+
12+
## 2. 目标
13+
14+
1. 定义正式的 Runtime Tools Contract(仅 required)。
15+
2. 将 composed image 构建流程改为“内置契约驱动”,由 Holon 内部维护 required tools 列表。
16+
3. 保持默认行为可用:始终使用内置默认 required tools。
17+
4. 通过 fail-fast 明确缺失工具,降低运行时不确定性。
18+
19+
## 3. 非目标
20+
21+
1. 不在本阶段实现“容器启动后在线安装工具”。
22+
2. 不在本阶段支持所有包管理器生态(先覆盖 apt/dnf/yum)。
23+
3. 不在本阶段做复杂版本求解(先支持固定版本/latest)。
24+
4. 不为契约提供项目级配置或 CLI 覆盖能力。
25+
26+
## 4. 契约定义(v1)
27+
28+
新增文档:`docs/runtime-tools-contract.md`(后续实现时补齐)
29+
30+
契约只维护一个 `required` 列表(v1,按命令名定义):
31+
32+
- `bash`, `git`, `curl`, `jq`, `rg`, `find`, `sed`, `awk`, `xargs`
33+
- `tar`, `gzip`, `unzip`
34+
- `python3`
35+
- `node`, `npm`
36+
- `gh`
37+
- `yq`
38+
- `fd`(Ubuntu 实际包通常为 `fdfind`,需做命令别名)
39+
- `make`, `patch`
40+
41+
## 5. 配置模型
42+
43+
不提供用户配置入口。工具契约由 Holon 内部固定维护(代码内置常量 + 文档)。
44+
45+
## 6. 代码重构设计
46+
47+
## 6.1 新增模块
48+
49+
新增 `pkg/runtime/tools`(建议):
50+
51+
1. `contract.go`
52+
- 定义 `ToolContract`, `ToolSpec`
53+
- 提供内置 `required` 列表(单一事实来源)。
54+
55+
2. `resolver.go`
56+
- 根据基础镜像可用包管理器生成安装计划 `InstallPlan`
57+
58+
3. `installer_script.go`
59+
- 生成安装脚本片段(apt/dnf/yum)。
60+
- 处理 `fd`/`fdfind` 兼容和软链。
61+
62+
## 6.2 `buildComposedImageFromBundle` 重构点
63+
64+
`runtime.go` 中当前内联 Dockerfile 拼接拆为:
65+
66+
1. 读取内置 tools contract(单一来源)。
67+
2. 生成安装计划(仅 required)。
68+
3. 生成结构化 Dockerfile 片段:
69+
- base setup
70+
- install required tools(失败即退出)
71+
- 解包 agent bundle
72+
4. 保持现有镜像 tag 计算方式和缓存语义不变。
73+
74+
## 7. 失败策略
75+
76+
1. required tools 安装失败:构建失败,报明确错误。
77+
2. 无支持包管理器:如果 required 中有未满足项,则失败并输出缺失列表。
78+
79+
## 8. 测试计划
80+
81+
### 8.1 单元测试
82+
83+
1. contract 常量与解析测试(内置 required 列表)
84+
2. install plan 测试(apt/dnf/yum/unknown)
85+
3. installer script 生成测试(required 安装路径)
86+
4. required 缺失时报错信息测试
87+
88+
### 8.2 集成测试
89+
90+
1. 默认内置契约下 composed image 构建成功
91+
2. required tools 缺失时自动安装(支持包管理器)
92+
3. 故意注入不存在工具时 required fail-fast(测试桩)
93+
94+
## 9. 迁移与兼容策略
95+
96+
本次按“无兼容包袱”执行(项目尚未正式发布):
97+
98+
1. 保留旧逻辑仅作为过渡分支内实现细节,不保留外部行为承诺。
99+
2. 文档统一切换到 Runtime Tools Contract 概念。
100+
3. 相关 prompt/skills 文档改为依赖内置 required 契约,不再引用能力文件。
101+
102+
## 10. 实施分解
103+
104+
1. 第一步:落地 contract 数据结构和内置 required 列表
105+
2. 第二步:重构 composed image 构建为内置契约驱动安装
106+
3. 第三步:文档与 prompt 更新,补齐 e2e 用例
107+
108+
## 11. 验收标准
109+
110+
1. `run/solve/serve` 在默认契约下行为稳定且可重复。
111+
2. 自定义镜像场景下,Holon 根据内置 required 列表自动补齐(在支持的包管理器上)。
112+
3. required 不满足时 fail-fast,错误信息可诊断。
113+
4. CI 覆盖 required 的成功与失败路径。

docs/runtime-tools-contract.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Runtime Tools Contract
2+
3+
This document defines Holon's built-in runtime tools contract.
4+
5+
## Scope
6+
7+
- This contract is internal to Holon runtime.
8+
- It has no project-level config and no CLI override.
9+
- Custom images can preinstall these tools; Holon still verifies and installs missing tools during composed-image build.
10+
11+
## Required Tools
12+
13+
The following commands are required in runtime containers:
14+
15+
- `bash`
16+
- `git`
17+
- `curl`
18+
- `jq`
19+
- `rg`
20+
- `find`
21+
- `sed`
22+
- `awk`
23+
- `xargs`
24+
- `tar`
25+
- `gzip`
26+
- `unzip`
27+
- `python3`
28+
- `node` (20+)
29+
- `npm`
30+
- `gh`
31+
- `yq`
32+
- `fd`
33+
- `make`
34+
- `patch`
35+
36+
## Behavior
37+
38+
- Holon installs missing required tools when building composed images.
39+
- Supported package-manager families: `apt-get`, `dnf`, `yum`.
40+
- If required tools cannot be ensured, build fails fast with a clear missing-tools error.

pkg/config/config_test.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"os"
55
"path/filepath"
66
"testing"
7+
8+
"github.com/holon-run/holon/pkg/image"
79
)
810

911
func TestLoad_NoConfigFile(t *testing.T) {
@@ -200,24 +202,24 @@ func TestResolveBaseImage(t *testing.T) {
200202
name: "CLI overrides config",
201203
baseImage: "python:3.11",
202204
cliValue: "node:20",
203-
defaultValue: "golang:1.22",
205+
defaultValue: image.DefaultImage,
204206
wantValue: "node:20",
205207
wantSource: "cli",
206208
},
207209
{
208210
name: "Config overrides default",
209211
baseImage: "python:3.11",
210212
cliValue: "",
211-
defaultValue: "golang:1.22",
213+
defaultValue: image.DefaultImage,
212214
wantValue: "python:3.11",
213215
wantSource: "config",
214216
},
215217
{
216218
name: "Default when no CLI or config",
217219
baseImage: "",
218220
cliValue: "",
219-
defaultValue: "golang:1.22",
220-
wantValue: "golang:1.22",
221+
defaultValue: image.DefaultImage,
222+
wantValue: image.DefaultImage,
221223
wantSource: "default",
222224
},
223225
}

pkg/image/detect.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import (
1414
)
1515

1616
// DefaultImage is the fallback Docker image used when no language signal is detected.
17-
const DefaultImage = "golang:1.22"
17+
const DefaultImage = "ghcr.io/holon-run/holon-base:0.1.0"
1818

1919
// Detector detects the appropriate Docker base image for a workspace.
2020
type Detector struct {
@@ -82,7 +82,7 @@ func (d *Detector) Detect() *DetectResult {
8282
return &DetectResult{
8383
Image: DefaultImage,
8484
Signals: []string{},
85-
Rationale: "No language signals detected, using default Go image",
85+
Rationale: "No language signals detected, using default base image",
8686
}
8787
}
8888

@@ -146,7 +146,7 @@ func (d *Detector) DetectDebug() *DebugDetectResult {
146146
result = &DetectResult{
147147
Image: DefaultImage,
148148
Signals: []string{},
149-
Rationale: "No language signals detected, using default Go image",
149+
Rationale: "No language signals detected, using default base image",
150150
}
151151
} else {
152152
bestSignal := d.scoreSignals(signals)

pkg/image/detect_test.go

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,8 @@ func TestDetect_NoSignal(t *testing.T) {
151151
createFile(t, dir, "README.md", "# Test\n")
152152

153153
result := Detect(dir)
154-
if result.Image != "golang:1.22" {
155-
t.Errorf("Expected default golang:1.22, got %s", result.Image)
154+
if result.Image != DefaultImage {
155+
t.Errorf("Expected default %s, got %s", DefaultImage, result.Image)
156156
}
157157
if len(result.Signals) != 0 {
158158
t.Errorf("Expected no signals, got %v", result.Signals)
@@ -170,8 +170,8 @@ func TestDetect_SkipsNodeModules(t *testing.T) {
170170

171171
result := Detect(dir)
172172
// Should return default, not node:22
173-
if result.Image != "golang:1.22" {
174-
t.Errorf("Expected default golang:1.22 (node_modules should be skipped), got %s", result.Image)
173+
if result.Image != DefaultImage {
174+
t.Errorf("Expected default %s (node_modules should be skipped), got %s", DefaultImage, result.Image)
175175
}
176176
}
177177

@@ -186,8 +186,8 @@ func TestDetect_SkipsVendor(t *testing.T) {
186186

187187
result := Detect(dir)
188188
// Should return default, not golang:1.23
189-
if result.Image != "golang:1.22" {
190-
t.Errorf("Expected default golang:1.22 (vendor should be skipped), got %s", result.Image)
189+
if result.Image != DefaultImage {
190+
t.Errorf("Expected default %s (vendor should be skipped), got %s", DefaultImage, result.Image)
191191
}
192192
}
193193

@@ -203,8 +203,8 @@ func TestDetect_SkipsHiddenFiles(t *testing.T) {
203203

204204
result := Detect(dir)
205205
// Should return default, not node:22
206-
if result.Image != "golang:1.22" {
207-
t.Errorf("Expected default golang:1.22 (hidden files should be skipped), got %s", result.Image)
206+
if result.Image != DefaultImage {
207+
t.Errorf("Expected default %s (hidden files should be skipped), got %s", DefaultImage, result.Image)
208208
}
209209
}
210210

@@ -250,11 +250,11 @@ func TestFormatResult(t *testing.T) {
250250
{
251251
name: "no signals",
252252
result: &DetectResult{
253-
Image: "golang:1.22",
253+
Image: DefaultImage,
254254
Signals: []string{},
255-
Rationale: "No language signals detected, using default Go image",
255+
Rationale: "No language signals detected, using default base image",
256256
},
257-
expected: "Detected image: golang:1.22 (signals: none) - No language signals detected, using default Go image",
257+
expected: "Detected image: " + DefaultImage + " (signals: none) - No language signals detected, using default base image",
258258
},
259259
{
260260
name: "disabled",
@@ -684,8 +684,8 @@ func TestDetect_ScanMode_EmptyDirectory(t *testing.T) {
684684
// Empty directory with no signals
685685

686686
result := Detect(dir)
687-
if result.Image != "golang:1.22" {
688-
t.Errorf("Expected default golang:1.22, got %s", result.Image)
687+
if result.Image != DefaultImage {
688+
t.Errorf("Expected default %s, got %s", DefaultImage, result.Image)
689689
}
690690

691691
// Check scan mode - should be full-recursive (no root signals found)

0 commit comments

Comments
 (0)