From e774a679f96252780ca43b7d573f2d9d98b77f5f Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 15 Oct 2026 10:33:22 +0000 Subject: [PATCH] Let install.py resume a failed first install A new installation is now assembled in a .staging-* directory inside the installation directory and moved into place after docker compose config accepts it, compose.yaml last. A failure before then removes the stage, and the directory when the run created it, so the same command can be rerun; the next run removes a stage a killed run left. A non-empty directory without compose.yaml is still refused. Failures exit with one line instead of a traceback: distribution and payload errors print their message, a failed command names its step and points at the output above, Ctrl-C asks to rerun, and permission errors name the path with the sudo chown command that returns it. A same-release resume check no longer says "Upgrade stopped". The bundle modules are imported from the script's own directory, so the unused --bundle-dir flag is removed. --- deploy/README.md | 2 + deploy/install-source.py | 108 +++++++++++++++++++++------------- deploy/test_install_source.py | 46 +++++++++++++-- docs/development.md | 2 +- docs/zh/development.md | 4 +- 5 files changed, 114 insertions(+), 48 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 9fdc3258f..40d20aae5 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -18,6 +18,8 @@ An existing directory runs the release named by `OAC_REVISION` in its `compose.y `install.py --upgrade` upgrades a source-bundle installation the same way, under the same lock. It publishes the bundle's node payload beside earlier releases, renders the new `compose.yaml` and rewrites only its own `compose.override.yaml` entries, the four service images and Web's `/node-payload` bind, keeping every other entry. After the same two checks it moves the payload's `active.json`, renames `oac`, `compose.override.yaml` and `compose.yaml` into place and starts the services. On its own release it resumes like `oac install`; another release is refused without `--upgrade`, and `--public-url` is required only for a new directory. +`install.py` prepares a new installation in a `.staging-*` directory inside the installation directory: the node payload, `.env`, `oac` and both Compose files. After `docker compose config` accepts them, it moves them into place, `compose.yaml` last. An earlier failure removes the stage, and the directory too when the run created it, so the same command can be rerun; the next run also removes a stage left by a killed run. A directory with other contents but no `compose.yaml` is refused. + The host `oac` command also implements `apply`, `core-key` and `rotate-core-key`. `apply` runs init, which finishes a first start that stopped before it and refuses a data volume committed to another release, then checks Core configuration before starting services and recreating those that changed. Reading the key executes `oac-web core-key` in Web. Rotation runs in the initialization container, where Linux file ownership is identical on every host, then restarts Core and Web. Start, stop, logs and removal use `docker compose`. The initialization image verifies and copies bundled node metadata without network access. Its receipt authenticates immutable metadata and credentials; the operator-rotatable Core key and its digest remain mutable. No service receives a Docker socket. Initialization emits structured step logs without credential values; view them with `docker compose logs --timestamps init`. Web serves the console and forwards `/v1` and `/api/v1` to Core, so it is the only published service. HTTPS is terminated by the operator's reverse proxy or hosting platform, which routes to `web:8080`; `OAC_PUBLIC_URL` records that origin. diff --git a/deploy/install-source.py b/deploy/install-source.py index cbe27bf47..65aecac33 100644 --- a/deploy/install-source.py +++ b/deploy/install-source.py @@ -13,9 +13,17 @@ from urllib.parse import urlsplit import uuid +# The bundle ships the node installer's distribution modules beside this file. +BUNDLE = Path(__file__).resolve().parent +sys.path.insert(0, str(BUNDLE)) +from distribution import DistributionError, ensure_docker_image, load_manifest +import node_payload -def run(*args, **kwargs): - return subprocess.run(args, check=True, text=True, **kwargs) + +def run(failure, *args, **kwargs): + """Run a command whose output the user sees; a nonzero exit raises failure.""" + if subprocess.run(args, **kwargs).returncode: + raise ValueError(failure) def compose_revision(text): @@ -24,11 +32,6 @@ def compose_revision(text): def install(bundle, destination, host, port, public_url, upgrade): - # Use the distribution's own node download and publication implementation. - sys.path.insert(0, str(bundle)) - from distribution import ensure_docker_image, load_manifest - import node_payload - manifest = load_manifest(offline_root=bundle) revision = manifest['source_commit'] for name in manifest['artifacts'].values(): @@ -37,6 +40,7 @@ def install(bundle, destination, host, port, public_url, upgrade): lock = Path(str(destination) + '.lock') lock.mkdir(parents=True, exist_ok=True, mode=0o700) descriptor = os.open(lock, os.O_RDONLY | os.O_DIRECTORY) + created = committed = False try: # The same lock as oac apply and the other mutating oac commands. try: @@ -46,7 +50,8 @@ def install(bundle, destination, host, port, public_url, upgrade): # A staging directory left by a killed run is never published. for leftover in destination.glob('.staging-*'): shutil.rmtree(leftover) - existing = destination.exists() and any(destination.iterdir()) + created = not destination.exists() + existing = not created and any(destination.iterdir()) if existing and not all((destination / name).is_file() for name in ('compose.yaml', '.env')): raise ValueError(f'Incomplete installation: preserve {destination} and choose another directory') previous = compose_revision((destination / 'compose.yaml').read_text()) if existing else '' @@ -60,18 +65,20 @@ def install(bundle, destination, host, port, public_url, upgrade): images = {name: ensure_docker_image(manifest, name, lambda name=name: bundle / 'images' / (name + '.tar')) for name in ('core', 'web', 'ingress', 'database')} destination.mkdir(parents=True, exist_ok=True, mode=0o700) - payload = destination / 'node-payload' - node_payload.publish(payload, bundle) - # These files contain only public distribution bytes and must be readable by Web. - for root, dirs, files in os.walk(payload): - os.chmod(root, 0o755) - for name in files: - os.chmod(Path(root) / name, 0o644) - if not existing: - (destination / '.env').write_text(f"COMPOSE_PROJECT_NAME=oac-{uuid.uuid4().hex[:10]}\nOAC_HOST='{host}'\nOAC_WEB_PORT='{port}'\nOAC_PUBLIC_URL='{public_url}'\n") - os.chmod(destination / '.env', 0o600) with tempfile.TemporaryDirectory(prefix='.staging-', dir=destination) as stage: stage = Path(stage) + # A new installation is assembled in the stage and moved in only after its check. + 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): + os.chmod(directory, 0o755) + for name in files: + os.chmod(Path(directory) / name, 0o644) + if not existing: + (stage / '.env').write_text(f"COMPOSE_PROJECT_NAME=oac-{uuid.uuid4().hex[:10]}\nOAC_HOST='{host}'\nOAC_WEB_PORT='{port}'\nOAC_PUBLIC_URL='{public_url}'\n") + os.chmod(stage / '.env', 0o600) template = (bundle / 'compose.yaml.template').read_text().replace('__OAC_REVISION__', revision).replace('__OAC_INIT_IMAGE__', images['ingress']) for name in ('core', 'web'): template = template.replace('__OAC_' + name.upper() + '_IMAGE__', images[name]) @@ -87,67 +94,88 @@ def install(bundle, destination, host, port, public_url, upgrade): services.setdefault(name, {})['image'] = images[image] 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(payload), 'target': '/node-payload', 'read_only': True}) + web['volumes'].append({'type': 'bind', 'source': str(destination / 'node-payload'), 'target': '/node-payload', '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') os.chmod(stage / 'oac', 0o700) - compose = ('docker', 'compose', '-f', str(stage / 'compose.yaml'), '-f', str(stage / 'compose.override.yaml'), '--project-directory', str(destination)) + compose = ('docker', 'compose', '-f', str(stage / 'compose.yaml'), '-f', str(stage / 'compose.override.yaml'), '--project-directory', str(root)) if existing: # Init upgrades the data volume or refuses; until then the running release is untouched. # On the same release init comes first: it finishes a first start that stopped before it, # so check-config finds the secrets. - checks = [('run', '--rm', '--no-deps', '--entrypoint', '/usr/local/bin/oac-core', 'core', 'check-config'), - ('run', '--rm', '--no-deps', 'init')] + checks = [('Configuration check', ('run', '--rm', '--no-deps', '--entrypoint', '/usr/local/bin/oac-core', 'core', 'check-config')), + ('Data volume check', ('run', '--rm', '--no-deps', 'init'))] if previous == revision: checks.reverse() - try: - for check in checks: - run(*compose, *check, cwd=destination) - except subprocess.CalledProcessError: - raise ValueError('Upgrade stopped; the running services are unchanged') from None + for step, check in checks: + failure = (f'{step} failed; no service was changed. See the output above.' if previous == revision + else f'Upgrade stopped; the running services are unchanged. {step} failed; see the output above.') + run(failure, *compose, *check, cwd=destination) else: - run(*compose, 'config', '--quiet', cwd=destination) + run('Compose configuration check failed; see the output above. Nothing was installed.', *compose, 'config', '--quiet', cwd=destination) node_payload.activate(payload, revision) - for name in ('oac', 'compose.override.yaml', 'compose.yaml'): + # 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) + committed = True # Installations from earlier releases kept a checksum that no longer describes compose.yaml. (destination / 'compose-sha256sums.txt').unlink(missing_ok=True) print(f'Configuration ready in {destination}. Starting services…', flush=True) - run('docker', 'compose', 'up', '-d', '--wait', '--wait-timeout', '180', '--pull', 'never', '--remove-orphans', cwd=destination) + run('Starting services failed; see the output above. Settings and data are kept: fix the cause and rerun the same command.', + 'docker', 'compose', 'up', '-d', '--wait', '--wait-timeout', '180', '--pull', 'never', '--remove-orphans', cwd=destination) if not existing: print(f'Console: {public_url}') 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.") print('Core key:', flush=True) - run('docker', 'compose', 'exec', '-T', 'web', '/usr/local/bin/oac-web', 'core-key', cwd=destination) + run(f'Reading the Core key failed; see the output above. Show it with {destination / "oac"} core-key --show.', + 'docker', 'compose', 'exec', '-T', 'web', '/usr/local/bin/oac-web', 'core-key', cwd=destination) finally: + # A new installation that failed before compose.yaml was in place leaves nothing behind. + if created and not committed: + shutil.rmtree(destination, ignore_errors=True) os.close(descriptor) def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--bundle-dir', type=Path, default=Path(__file__).resolve().parent) parser.add_argument('--install-dir', type=Path, default=Path.home() / '.oac' / 'local') parser.add_argument('--host', default='127.0.0.1') parser.add_argument('--web-port', type=int, default=8080) parser.add_argument('--public-url', help='HTTP(S) origin reachable by browsers and nodes; required for a new installation') parser.add_argument('--upgrade', action='store_true', help='upgrade an existing installation to this release') args = parser.parse_args() - if not args.install_dir.is_absolute() or args.install_dir == Path('/') or not args.bundle_dir.is_absolute(): - parser.error('bundle and installation directories must be absolute; installation cannot be /') - ipaddress.ip_address(args.host) + if not args.install_dir.is_absolute() or args.install_dir == Path('/'): + parser.error('installation directory must be absolute and cannot be /') + try: + ipaddress.ip_address(args.host) + except ValueError: + parser.error('host must be an IP address') if not 1 <= args.web_port <= 65535: parser.error('web port must be between 1 and 65535') if args.public_url is not None: url = urlsplit(args.public_url) if url.scheme not in ('http', 'https') or not url.hostname or url.username or url.password or url.path not in ('', '/') or url.query or url.fragment or any(c in args.public_url for c in "\r\n'\\$"): parser.error('public URL must be an HTTP(S) origin without credentials') - install(args.bundle_dir.resolve(), args.install_dir.resolve(), args.host, args.web_port, args.public_url, args.upgrade) + destination = args.install_dir.resolve() + try: + install(BUNDLE, destination, args.host, args.web_port, args.public_url, args.upgrade) + except KeyboardInterrupt: + sys.exit('Interrupted; rerun the same command to continue.') + except PermissionError as error: + # Typically left by an earlier run under sudo. A path that could not be created names its parent. + path = Path(error.filename or destination) + if destination in path.parents: + path = destination + elif not path.exists(): + path = path.parent + sys.exit(f'{path} is not writable by this account; if an earlier run used sudo, return it with: sudo chown -R "$(id -un)" {path}') + except OSError as error: + sys.exit(f'{error.strerror}: {error.filename}' if error.strerror and error.filename else str(error)) + except (ValueError, DistributionError, node_payload.PayloadError) as error: + sys.exit(str(error)) if __name__ == '__main__': - try: - main() - except (OSError, ValueError, subprocess.CalledProcessError) as error: - sys.exit(str(error)) + main() diff --git a/deploy/test_install_source.py b/deploy/test_install_source.py index 64d6dfe13..4bffb91d3 100644 --- a/deploy/test_install_source.py +++ b/deploy/test_install_source.py @@ -22,7 +22,7 @@ class SourceInstallTest(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) - self.root = Path(self.temp.name) + self.root = Path(self.temp.name).resolve() self.bundle, self.destination = self.root / 'bundle', self.root / 'installation' self.bundle.mkdir() self.revision = 'a' * 40 @@ -40,13 +40,20 @@ def write_sums(self): (self.bundle / 'SHA256SUMS').write_text(hashlib.sha256((self.bundle / 'manifest.json').read_bytes()).hexdigest() + ' manifest.json\n') def install(self, upgrade=False, image='sha256:' + '1' * 64, failure=None): - def run(*args, **kwargs): + def run(message, *args, **kwargs): self.calls.append((args, kwargs)) if failure and failure in args: - raise subprocess.CalledProcessError(1, args) - with patch('distribution.ensure_docker_image', return_value=image), patch.object(installer, 'run', side_effect=run): + raise ValueError(message) + with patch.object(installer, 'ensure_docker_image', return_value=image), patch.object(installer, 'run', side_effect=run): installer.install(self.bundle, self.destination, '127.0.0.1', 19873, None if upgrade else 'http://127.0.0.1:19873', upgrade) + def main(self): + """Run the command line and return its exit message.""" + argv = ['install.py', '--install-dir', str(self.destination), '--public-url', 'http://127.0.0.1:19873'] + with patch.object(sys, 'argv', argv), patch.object(installer, 'BUNDLE', self.bundle), self.assertRaises(SystemExit) as exit: + installer.main() + return exit.exception.code + def commands(self): """Compose subcommands, without the staged file arguments.""" return [args[args.index('--project-directory') + 2:] if '--project-directory' in args else args[2:] for args, _ in self.calls] @@ -82,12 +89,23 @@ def test_existing_installation_is_not_replaced(self): self.assertEqual((self.destination / 'keep').read_text(), 'existing') self.assertEqual(self.calls, []) + def test_failed_first_install_leaves_nothing_and_reruns(self): + with self.assertRaisesRegex(ValueError, '^Compose configuration check failed; see the output above. Nothing was installed.$'): + self.install(failure='config') + self.assertFalse(self.destination.exists()) + # A killed run leaves only its stage, which the rerun removes. + (self.destination / '.staging-killed/node-payload').mkdir(parents=True) + self.install() + self.assertEqual(sorted(path.name for path in self.destination.iterdir()), ['.env', 'compose.override.yaml', 'compose.yaml', 'node-payload', 'oac']) + def test_same_release_reruns_init_before_the_configuration_check(self): # A first start that stopped before init left no secrets for check-config. self.install() self.calls = [] self.install() self.assertEqual([command[-1] for command in self.commands()], ['init', 'check-config', '--remove-orphans', 'core-key']) + with self.assertRaisesRegex(ValueError, '^Data volume check failed; no service was changed. See the output above.$'): + self.install(failure='init') def test_upgrade_checks_the_new_release_before_replacing_it_and_keeps_operator_entries(self): self.install() @@ -106,7 +124,7 @@ def test_upgrade_checks_the_new_release_before_replacing_it_and_keeps_operator_e with self.assertRaisesRegex(ValueError, 'rerun with --upgrade'): self.install(image='sha256:' + '2' * 64) self.assertEqual(self.snapshot(), before) - with self.assertRaisesRegex(ValueError, 'Upgrade stopped; the running services are unchanged'): + with self.assertRaisesRegex(ValueError, '^Upgrade stopped; the running services are unchanged. Data volume check failed; see the output above.$'): self.install(upgrade=True, image='sha256:' + '2' * 64, failure='init') self.assertEqual(self.snapshot(), before) @@ -133,6 +151,24 @@ def test_thin_bundle_rejected_before_creating_installation(self): self.install() self.assertFalse(self.destination.exists()) + def test_failures_exit_with_one_line_and_the_next_step(self): + (self.bundle / 'SHA256SUMS').write_text('0' * 64 + ' manifest.json\n') + self.assertEqual(self.main(), 'Distribution manifest checksum mismatch') + self.write_sums() + + def interrupt(*args, **kwargs): + raise KeyboardInterrupt + with patch.object(installer, 'ensure_docker_image', return_value='sha256:' + '1' * 64), patch.object(installer, 'run', side_effect=interrupt): + self.assertEqual(self.main(), 'Interrupted; rerun the same command to continue.') + self.assertFalse(self.destination.exists()) + + if os.geteuid(): + # Root-owned after an earlier sudo run; root itself is never denied. + lock = Path(str(self.destination) + '.lock') + lock.chmod(0) + self.addCleanup(lock.chmod, 0o700) + self.assertEqual(self.main(), f'{lock} is not writable by this account; if an earlier run used sudo, return it with: sudo chown -R "$(id -un)" {lock}') + if __name__ == '__main__': unittest.main() diff --git a/docs/development.md b/docs/development.md index d9896a1bb..b494b019c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -86,7 +86,7 @@ docker compose up -d --wait --pull never ./oac core-key --show ``` -Rerunning the same bundle's `install.py` on its installation directory resumes the installation with the saved settings; `--public-url` is required only for a new directory. A bundle from another commit refuses an existing installation unless you pass `--upgrade`, which [upgrades it in place](./getting-started/operations.md#upgrade). If preparation fails before `compose.yaml` and `.env` exist, remove that incomplete directory and retry. Use the [operator commands](./getting-started/operations.md) for configuration, keys and stopping services. +Rerunning the same bundle's `install.py` on its installation directory resumes the installation with the saved settings; `--public-url` is required only for a new directory. A bundle from another commit refuses an existing installation unless you pass `--upgrade`, which [upgrades it in place](./getting-started/operations.md#upgrade). If a new installation fails before it starts the services, it leaves the installation directory as it found it; fix the cause and rerun the same command. Use the [operator commands](./getting-started/operations.md) for configuration, keys and stopping services. ## Build and run components diff --git a/docs/zh/development.md b/docs/zh/development.md index be6120276..57592666d 100644 --- a/docs/zh/development.md +++ b/docs/zh/development.md @@ -1,7 +1,7 @@ --- title: "开发 OpenAgentCore" source: docs/development.md -source_hash: b4d3c7f1700ac0d60038069a7cc62a5e8231e32cc8e33513814da841d260d112 +source_hash: 28bc363f9cd73938afef53f0ed7399721ae179c48a543cebd1111f3054ccf52e --- 准备工作副本,构建组件并验证修改。如需使用已安装的实例,从[入门指南](getting-started/index.md)开始。修改代码前阅读[贡献者规则](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md)。 @@ -88,7 +88,7 @@ docker compose up -d --wait --pull never ./oac core-key --show ``` -对安装目录重新运行同一源码包的 `install.py`,会以已保存的设置继续该安装;只有新目录才需要 `--public-url`。其他提交的源码包会拒绝已有安装,除非传入 `--upgrade`,此时会[原地升级](getting-started/operations.md#upgrade)该安装。若在 `compose.yaml` 和 `.env` 齐备之前准备失败,删除该未完成目录后重试。配置、密钥和停止服务参见[运维命令](getting-started/operations.md)。 +对安装目录重新运行同一源码包的 `install.py`,会以已保存的设置继续该安装;只有新目录才需要 `--public-url`。其他提交的源码包会拒绝已有安装,除非传入 `--upgrade`,此时会[原地升级](getting-started/operations.md#upgrade)该安装。新安装若在启动服务之前失败,安装目录会保持运行前的状态;修复原因后重新运行同一命令即可。配置、密钥和停止服务参见[运维命令](getting-started/operations.md)。 ## 构建与运行组件 {#build-and-run-components}