diff --git a/deploy/install-source.py b/deploy/install-source.py index c14c1f27e..8c8ee6652 100644 --- a/deploy/install-source.py +++ b/deploy/install-source.py @@ -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) @@ -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 {} @@ -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') @@ -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) @@ -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.") diff --git a/deploy/test_install_source.py b/deploy/test_install_source.py index e72afd2d2..0f494eebf 100644 --- a/deploy/test_install_source.py +++ b/deploy/test_install_source.py @@ -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)) diff --git a/docs/maintainers.md b/docs/maintainers.md index a71d5075e..ad53ac0b2 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -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--.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--.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 diff --git a/docs/zh/maintainers.md b/docs/zh/maintainers.md index c9623927c..8c25a491e 100644 --- a/docs/zh/maintainers.md +++ b/docs/zh/maintainers.md @@ -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)。 @@ -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--.tar.gz` 发布资源,离线归档则会在 Core 镜像之外为每个平台保留一份副本。如果没有目录,Sessions 会将安装命令报告为不可用,并且发布工作流会拒绝发布。 +该目录会记录提交、Runtime 协议版本、每个归档的 SHA-256,并在提供发布基址时记录其带版本号的 URL。控制归档和 Core 镜像仅携带 `native-installers/catalog.json`;这些归档会成为独立的 `oac-native--.tar.gz` 发布资源,离线归档则会在 Core 镜像之外为每个平台保留一份副本,由其 `install.py` 复制到安装目录中供 Core 提供下载。如果没有目录,Sessions 会将安装命令报告为不可用,并且发布工作流会拒绝发布。 ### Runtime 镜像和辅助程序 {#runtime-images-and-helpers} diff --git a/services/core/cmd/oac/install.go b/services/core/cmd/oac/install.go index 9324170bf..13c5a7e20 100644 --- a/services/core/cmd/oac/install.go +++ b/services/core/cmd/oac/install.go @@ -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 } @@ -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 { @@ -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 } diff --git a/services/core/cmd/oac/install_test.go b/services/core/cmd/oac/install_test.go index 8174e43ee..fae32ccd5 100644 --- a/services/core/cmd/oac/install_test.go +++ b/services/core/cmd/oac/install_test.go @@ -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") @@ -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) + } } }