Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions deploy/install-source.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,12 @@ def install(bundle, destination, host, port, public_url, upgrade):
root = destination if existing else stage
payload = root / 'node-payload'
node_payload.publish(payload, bundle)
# These files contain only public distribution bytes and must be readable by Web.
for directory, dirs, files in os.walk(payload):
# Core's image carries only the native installer catalog; Core serves the archives from here.
native = bundle / 'native-installers'
if native.is_dir():
shutil.copytree(native, root / 'native-installers' / revision, dirs_exist_ok=True)
# These files contain only public distribution bytes and must be readable by Web and Core.
for directory, dirs, files in [*os.walk(payload), *os.walk(root / 'native-installers')]:
os.chmod(directory, 0o755)
for name in files:
os.chmod(Path(directory) / name, 0o644)
Expand All @@ -149,7 +153,7 @@ def install(bundle, destination, host, port, public_url, upgrade):
for name in ('core', 'web', 'database'):
template = template.replace('__OAC_' + name.upper() + '_IMAGE__', images[name])
(stage / 'compose.yaml').write_text(template)
# The installer owns only the service images and Web's payload bind; other entries are the operator's.
# The installer owns only the service images and the payload binds; other entries are the operator's.
saved = destination / 'compose.override.yaml'
try:
override = json.loads(saved.read_text()) if saved.is_file() else {}
Expand All @@ -162,6 +166,10 @@ def install(bundle, destination, host, port, public_url, upgrade):
web = services['web']
web['volumes'] = [volume for volume in web.get('volumes', []) if not (isinstance(volume, dict) and volume.get('target') == '/node-payload')]
web['volumes'].append({'type': 'bind', 'source': str(destination / 'node-payload'), 'target': '/node-payload', 'read_only': True})
core = services['core']
core['volumes'] = [volume for volume in core.get('volumes', []) if not (isinstance(volume, dict) and volume.get('target') == '/opt/oac/native-installers')]
if native.is_dir():
core['volumes'].append({'type': 'bind', 'source': str(destination / 'native-installers' / revision), 'target': '/opt/oac/native-installers', 'read_only': True})
(stage / 'compose.override.yaml').write_text(json.dumps(override, indent=2) + '\n')
os.chmod(stage / 'compose.override.yaml', 0o600)
shutil.copyfile(bundle / 'oac', stage / 'oac')
Expand All @@ -182,6 +190,8 @@ def install(bundle, destination, host, port, public_url, upgrade):
else:
run('Compose configuration check failed; see the output above. Nothing was installed.', *compose, 'config', '--quiet', cwd=destination)
node_payload.activate(payload, revision)
if not existing and native.is_dir():
os.replace(stage / 'native-installers', destination / 'native-installers')
# compose.yaml goes last: it marks the directory as an installation.
for name in ('oac', 'compose.override.yaml', 'compose.yaml') if existing else ('node-payload', '.env', 'oac', 'compose.override.yaml', 'compose.yaml'):
os.replace(stage / name, destination / name)
Expand All @@ -198,6 +208,10 @@ def install(bundle, destination, host, port, public_url, upgrade):
published = docker('docker', 'compose', 'port', 'web', '8080', cwd=destination)
if published.returncode or not published.stdout.strip():
raise ValueError(f"Web is running without its published port {web_port}; something else holds it.{port_holder_hint(web_port)}\nFree the port, then rerun the same command.")
# Only the running release's native installers are served.
for other in (destination / 'native-installers').glob('*'):
if other.name != revision:
shutil.rmtree(other)
print(f"Console: {settings.get('OAC_PUBLIC_URL') or 'http://localhost:' + web_port}")
if previous and previous != revision:
print(f"Upgraded from {previous[:12]} to {revision[:12]}. Settings → Sandbox and scheduling offers this release's Runtime; sandboxes keep their current Runtime until you apply it.")
Expand Down
21 changes: 21 additions & 0 deletions deploy/test_install_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,27 @@ def test_upgrade_checks_the_new_release_before_replacing_it_and_keeps_operator_e
self.assertEqual(json.loads((payload / 'active.json').read_text()), {'source_commit': upgraded})
self.assertEqual(sorted(path.name for path in (payload / 'releases').iterdir()), [self.revision, upgraded])

def test_core_serves_the_bundled_native_installers_of_the_running_release(self):
native = self.bundle / 'native-installers'
native.mkdir()
(native / 'catalog.json').write_text('{}')
(native / 'linux-amd64.tar.gz').write_bytes(b'native')
self.install()
served = self.destination / 'native-installers' / self.revision
config = json.loads(subprocess.check_output(['docker', 'compose', 'config', '--format', 'json'], cwd=self.destination, text=True))
mount = {v['target']: v for v in config['services']['core']['volumes']}['/opt/oac/native-installers']
self.assertEqual((mount['type'], mount['source'], mount['read_only']), ('bind', str(served), True))
self.assertEqual((served / 'linux-amd64.tar.gz').read_bytes(), b'native')
self.assertEqual(os.stat(served / 'linux-amd64.tar.gz').st_mode & 0o777, 0o644)
upgraded = 'b' * 40
self.manifest['source_commit'] = upgraded
(self.bundle / 'manifest.json').write_text(json.dumps(self.manifest))
self.write_sums()
self.install(upgrade=True)
self.assertEqual([path.name for path in (self.destination / 'native-installers').iterdir()], [upgraded])
core = json.loads((self.destination / 'compose.override.yaml').read_text())['services']['core']
self.assertEqual([volume['source'] for volume in core['volumes']], [str(self.destination / 'native-installers' / upgraded)])

def test_thin_bundle_rejected_before_creating_installation(self):
self.manifest['artifacts'] = {'native/bin/node': {'filename': 'missing'}}
(self.bundle / 'manifest.json').write_text(json.dumps(self.manifest))
Expand Down
2 changes: 1 addition & 1 deletion docs/maintainers.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ node scripts/build-native-catalog.mjs INPUT_DIR OUTPUT_DIR
export OAC_NATIVE_INSTALLER_BUILD_DIR=OUTPUT_DIR
```

The catalog records the commit, the Runtime protocol version, each archive's SHA-256 and, with a release base, its versioned URL. The control archive and Core image carry only `native-installers/catalog.json`; the archives become separate `oac-native-<commit>-<platform>.tar.gz` release assets, and the offline archive holds one copy of each outside the Core image. Without a catalog, Sessions report the install command as unavailable, and the release workflow refuses to publish.
The catalog records the commit, the Runtime protocol version, each archive's SHA-256 and, with a release base, its versioned URL. The control archive and Core image carry only `native-installers/catalog.json`; the archives become separate `oac-native-<commit>-<platform>.tar.gz` release assets, and the offline archive holds one copy of each outside the Core image, which its `install.py` copies into the installation for Core to serve. Without a catalog, Sessions report the install command as unavailable, and the release workflow refuses to publish.

### Runtime images and helpers

Expand Down
4 changes: 2 additions & 2 deletions docs/zh/maintainers.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "构建并发布 OpenAgentCore"
source: docs/maintainers.md
source_hash: 8f41d8581db31b3a8bac4c54cc2808cf30f70c609c852cd9172d915ad466abf2
source_hash: 12472ab16b7a282f88c4a53d0f204ed27694665f15962f1c93a20a6bde96023b
---

本指南面向负责构建和发布 OpenAgentCore 的维护者。要安装 Core 和 Web,请按 [安装 Core 和 Web](getting-started/install.md) 操作。安装程序代码遵循的规则见 [部署](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/README.md) 和 [节点安装程序](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/node/README.md);必需检查见 [CONTRIBUTING](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md#required-checks)。
Expand Down Expand Up @@ -56,7 +56,7 @@ node scripts/build-native-catalog.mjs INPUT_DIR OUTPUT_DIR
export OAC_NATIVE_INSTALLER_BUILD_DIR=OUTPUT_DIR
```

该目录会记录提交、Runtime 协议版本、每个归档的 SHA-256,并在提供发布基址时记录其带版本号的 URL。控制归档和 Core 镜像仅携带 `native-installers/catalog.json`;这些归档会成为独立的 `oac-native-<commit>-<platform>.tar.gz` 发布资源,离线归档则会在 Core 镜像之外为每个平台保留一份副本。如果没有目录,Sessions 会将安装命令报告为不可用,并且发布工作流会拒绝发布。
该目录会记录提交、Runtime 协议版本、每个归档的 SHA-256,并在提供发布基址时记录其带版本号的 URL。控制归档和 Core 镜像仅携带 `native-installers/catalog.json`;这些归档会成为独立的 `oac-native-<commit>-<platform>.tar.gz` 发布资源,离线归档则会在 Core 镜像之外为每个平台保留一份副本,由其 `install.py` 复制到安装目录中供 Core 提供下载。如果没有目录,Sessions 会将安装命令报告为不可用,并且发布工作流会拒绝发布。

### Runtime 镜像和辅助程序 {#runtime-images-and-helpers}

Expand Down
32 changes: 19 additions & 13 deletions services/core/cmd/oac/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,8 @@ func (i installer) installLocked(ctx context.Context, o installOptions, detected
compose.files = []string{"-f", filepath.Join(stage, "compose.yaml")}
override := filepath.Join(o.dir, "compose.override.yaml")
if raw, err := os.ReadFile(override); err == nil {
if cleaned, changed := dropPayloadBind(raw); changed {
// The release serves the node payload from the data volume, so the offline installer's host bind goes.
if cleaned, changed := dropPayloadBinds(raw); changed {
// The release serves the node payload from the data volume and its native installers from the Core image, so the offline installer's host binds go.
if err := os.WriteFile(filepath.Join(stage, "compose.override.yaml"), cleaned, 0o600); err != nil {
return err
}
Expand Down Expand Up @@ -364,8 +364,9 @@ func (i installer) installLocked(ctx context.Context, o installOptions, detected
}
}
if cleanedOverride {
// Nothing serves the offline installer's host copy any more.
// Nothing serves the offline installer's host copies any more.
_ = os.RemoveAll(filepath.Join(o.dir, "node-payload"))
_ = os.RemoveAll(filepath.Join(o.dir, "native-installers"))
}
}
if !fresh {
Expand Down Expand Up @@ -418,25 +419,30 @@ func (i installer) installLocked(ctx context.Context, o installOptions, detected
return nil
}

// dropPayloadBind removes the offline installer's Web bind of /node-payload from a JSON override and keeps every other entry. Other formats are the operator's and stay as they are.
func dropPayloadBind(raw []byte) ([]byte, bool) {
// dropPayloadBinds removes the offline installer's binds of Web's /node-payload and Core's /opt/oac/native-installers from a JSON override and keeps every other entry. Other formats are the operator's and stay as they are.
func dropPayloadBinds(raw []byte) ([]byte, bool) {
var override map[string]any
if json.Unmarshal(raw, &override) != nil {
return nil, false
}
services, _ := override["services"].(map[string]any)
web, _ := services["web"].(map[string]any)
volumes, _ := web["volumes"].([]any)
kept := make([]any, 0, len(volumes))
for _, volume := range volumes {
if entry, ok := volume.(map[string]any); !ok || entry["target"] != "/node-payload" {
kept = append(kept, volume)
changed := false
for name, target := range map[string]string{"web": "/node-payload", "core": "/opt/oac/native-installers"} {
service, _ := services[name].(map[string]any)
volumes, _ := service["volumes"].([]any)
kept := make([]any, 0, len(volumes))
for _, volume := range volumes {
if entry, ok := volume.(map[string]any); !ok || entry["target"] != target {
kept = append(kept, volume)
}
}
if len(kept) != len(volumes) {
service["volumes"], changed = kept, true
}
}
if len(kept) == len(volumes) {
if !changed {
return nil, false
}
web["volumes"] = kept
cleaned, err := json.MarshalIndent(override, "", " ")
return append(cleaned, '\n'), err == nil
}
Expand Down
15 changes: 9 additions & 6 deletions services/core/cmd/oac/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,16 +298,17 @@ func TestInstallUpgradesAnotherReleaseOnlyWhenRequested(t *testing.T) {
}
}

func TestUpgradeDropsTheOfflineNodePayloadBind(t *testing.T) {
func TestUpgradeDropsTheOfflinePayloadBinds(t *testing.T) {
f := newInstallFixture(t)
setBuildRevision(t, "a1b2c3d4e5f6a7b8c9d0a1b2c3d4e5f6a7b8c9d0")
if err := f.run(); err != nil {
t.Fatal(err)
}
payload := filepath.Join(f.options.dir, "node-payload")
payload, native := filepath.Join(f.options.dir, "node-payload"), filepath.Join(f.options.dir, "native-installers")
os.Mkdir(payload, 0o755)
os.Mkdir(native, 0o755)
source, _ := json.Marshal(payload)
override := `{"services":{"web":{"volumes":[{"type":"bind","source":` + string(source) + `,"target":"/node-payload","read_only":true},{"type":"bind","source":"/srv/ca","target":"/ca"}]}}}`
override := `{"services":{"web":{"volumes":[{"type":"bind","source":` + string(source) + `,"target":"/node-payload","read_only":true},{"type":"bind","source":"/srv/ca","target":"/ca"}]},"core":{"volumes":[{"type":"bind","source":"/srv/native","target":"/opt/oac/native-installers","read_only":true}]}}}`
path := filepath.Join(f.options.dir, "compose.override.yaml")
os.WriteFile(path, []byte(override), 0o600)
setBuildRevision(t, "development")
Expand All @@ -327,11 +328,13 @@ func TestUpgradeDropsTheOfflineNodePayloadBind(t *testing.T) {
t.Fatal(err)
}
raw, _ := os.ReadFile(path)
if strings.Contains(string(raw), "/node-payload") || !strings.Contains(string(raw), "/srv/ca") {
if strings.Contains(string(raw), "/node-payload") || strings.Contains(string(raw), "/opt/oac/native-installers") || !strings.Contains(string(raw), "/srv/ca") {
t.Fatalf("override after upgrade: %s", raw)
}
if _, err := os.Stat(payload); !os.IsNotExist(err) {
t.Fatal("upgrade kept the node payload directory")
for _, directory := range []string{payload, native} {
if _, err := os.Stat(directory); !os.IsNotExist(err) {
t.Fatalf("upgrade kept %s", directory)
}
}
}

Expand Down
Loading