From 7534473e9825a0c3f104071e778091e5cdd1a71d Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 15 Oct 2026 14:46:39 +0000 Subject: [PATCH 1/2] Check the Web port before an offline install --- deploy/install-source.py | 60 +++++++++++++++++++++++++++++++++-- deploy/test_install_source.py | 43 +++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/deploy/install-source.py b/deploy/install-source.py index b4135deef..d4f0f1423 100644 --- a/deploy/install-source.py +++ b/deploy/install-source.py @@ -8,6 +8,7 @@ from pathlib import Path import re import shutil +import socket import subprocess import sys import tempfile @@ -34,14 +35,60 @@ def valid_public_origin(value): return False +def docker(*args, **kwargs): + """Run a command with its output captured. The installation's .env is the one home of OAC_* and COMPOSE_* settings, so the caller's shell cannot override them.""" + kwargs['env'] = {name: value for name, value in os.environ.items() if not name.upper().startswith(('OAC_', 'COMPOSE_'))} + return subprocess.run(args, capture_output=True, text=True, **kwargs) + + def run(failure, *args, **kwargs): """Run a command whose output the user sees; a nonzero exit raises failure.""" - # The installation's .env is the one home of OAC_* and COMPOSE_* settings, so the caller's shell cannot override them. kwargs['env'] = {name: value for name, value in os.environ.items() if not name.upper().startswith(('OAC_', 'COMPOSE_'))} if subprocess.run(args, **kwargs).returncode: raise ValueError(failure) +def check_docker(): + """Refuse early, with the same words as oac install, when Docker is missing or out of reach.""" + try: + result = docker('docker', 'info') + except FileNotFoundError: + raise ValueError('Docker is not installed; install Docker Engine 26 or newer with the Compose plugin, then rerun the installer') from None + if 'permission denied while trying to connect to' in result.stderr: + raise ValueError('This account cannot use Docker: add it to the docker group and log in again, or use rootless Docker. Do not run the installer with sudo.') + if result.returncode: + raise ValueError('Docker is not running or not reachable; start it and rerun the installer') + + +def port_holder_hint(port): + """Name the OpenAgentCore Compose project that publishes the port, as oac install does.""" + rows = docker('docker', 'ps', '--filter', f'publish={port}', '--format', '{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.project.working_dir"}}').stdout + for row in rows.splitlines(): + name, _, directory = row.strip().partition('\t') + if not name.startswith('oac-'): + continue + if not directory: + return f'\nThe Compose project {name} holds it.' + if Path(directory).exists(): + return f'\nThe installation in {directory} holds it; upgrade or manage it with --install-dir {directory}.' + return f'\nThe Compose project {name} holds it, and its installation directory {directory} no longer exists. Remove it with: docker compose -p {name} down --volumes --remove-orphans (this deletes its data)' + return '' + + +def check_port(host, port): + with socket.socket(socket.AF_INET6 if ':' in host else socket.AF_INET) as probe: + try: + probe.bind((host, port)) + except OSError: + raise ValueError(f'Web port {port} is unavailable; choose --web-port.{port_holder_hint(port)}') from None + + +def read_env(path): + """The installation's saved settings.""" + pairs = (line.partition('=') for line in path.read_text().splitlines()) + return {name: value.strip("'") for name, _, value in pairs} + + def compose_revision(text): """Return the release a Compose file starts, from init's OAC_REVISION.""" return next((line.split(':', 1)[1].strip() for line in text.splitlines() if line.strip().startswith('OAC_REVISION:')), '') @@ -77,6 +124,9 @@ def install(bundle, destination, host, port, public_url, upgrade): raise ValueError(f'This installation runs release {previous[:12]}; this bundle is {revision[:12]}. Back up the installation (Upgrade in the operations guide), then rerun with --upgrade.') if not existing and public_url is None: raise ValueError('--public-url is required for a new installation') + check_docker() + if not existing: + check_port(host, port) # Docker validates both daemon access and the production template's required features. images = {name: ensure_docker_image(manifest, name, lambda name=name: bundle / 'images' / (name + '.tar')) for name in ('core', 'web', 'ingress', 'database')} @@ -141,8 +191,12 @@ def install(bundle, destination, host, port, public_url, upgrade): print(f'Configuration ready in {destination}. Starting services…', flush=True) 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}') + # A rerun of a start that lost the port must not report success: the console would be another installation's. + settings = read_env(destination / '.env') + 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 {settings['OAC_WEB_PORT']}; something else holds it.{port_holder_hint(settings['OAC_WEB_PORT'])}\nFree the port, then rerun the same command.") + print(f"Console: {settings['OAC_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.") if existing: diff --git a/deploy/test_install_source.py b/deploy/test_install_source.py index 587f8f657..e72afd2d2 100644 --- a/deploy/test_install_source.py +++ b/deploy/test_install_source.py @@ -5,6 +5,7 @@ import os from pathlib import Path import shutil +import socket import subprocess import sys import tempfile @@ -35,17 +36,25 @@ def setUp(self): shutil.copyfile(ROOT / 'deploy/compose/compose.yaml', self.bundle / 'compose.yaml.template') (self.bundle / 'oac').write_bytes(b'operator') self.calls = [] + self.replies = {} 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 docker(self, *args, **kwargs): + """A reachable Docker whose Web publishes its port; tests override replies by command.""" + key = 'port' if args[1:3] == ('compose', 'port') else args[1] + reply = self.replies.get(key, (0, '127.0.0.1:19873' if key == 'port' else '', '')) + return subprocess.CompletedProcess(args, reply[0], reply[1], reply[2]) + + def install(self, upgrade=False, image='sha256:' + '1' * 64, failure=None, port=19873): def run(message, *args, **kwargs): self.calls.append((args, kwargs)) if failure and failure in args: 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) + with patch.object(installer, 'ensure_docker_image', return_value=image) as load, patch.object(installer, 'run', side_effect=run), patch.object(installer, 'docker', side_effect=self.docker): + self.load = load + installer.install(self.bundle, self.destination, '127.0.0.1', port, None if upgrade else f'http://127.0.0.1:{port}', upgrade) def main(self): """Run the command line and return its exit message.""" @@ -98,6 +107,34 @@ def test_failed_first_install_leaves_nothing_and_reruns(self): self.install() self.assertEqual(sorted(path.name for path in self.destination.iterdir()), ['.env', 'compose.override.yaml', 'compose.yaml', 'node-payload', 'oac']) + def test_taken_web_port_is_refused_before_images_load(self): + with socket.socket() as holder: + holder.bind(('127.0.0.1', 0)) + port = holder.getsockname()[1] + self.replies['ps'] = (0, 'oac-abc\t/gone/installation\n', '') + with self.assertRaisesRegex(ValueError, f'^Web port {port} is unavailable; choose --web-port.\nThe Compose project oac-abc holds it, and its installation directory /gone/installation no longer exists. Remove it with: docker compose -p oac-abc down --volumes --remove-orphans'): + self.install(port=port) + self.load.assert_not_called() + self.assertFalse(self.destination.exists()) + + def test_missing_docker_access_names_the_docker_group(self): + self.replies['info'] = (1, '', 'permission denied while trying to connect to the docker API at unix:///var/run/docker.sock') + with self.assertRaisesRegex(ValueError, '^This account cannot use Docker: add it to the docker group and log in again'): + self.install() + self.load.assert_not_called() + + def test_rerun_fails_when_web_lost_its_port_and_prints_the_console_otherwise(self): + self.install() + self.calls = [] + self.replies['port'] = (1, '', 'no port 8080/tcp') + self.replies['ps'] = (0, f'oac-other\t{self.root}\n', '') + with self.assertRaisesRegex(ValueError, f'Web is running without its published port 19873; something else holds it.\nThe installation in {self.root} holds it'): + self.install() + del self.replies['port'] + with patch('builtins.print') as output: + self.install() + output.assert_any_call('Console: http://127.0.0.1:19873') + 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() From 18f88720539e2a2492df8938f62c1d0bc8fd102e Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 15 Oct 2026 14:51:00 +0000 Subject: [PATCH 2/2] Fall back to Compose defaults for empty settings --- deploy/install-source.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/deploy/install-source.py b/deploy/install-source.py index d4f0f1423..c14c1f27e 100644 --- a/deploy/install-source.py +++ b/deploy/install-source.py @@ -193,10 +193,12 @@ def install(bundle, destination, host, port, public_url, upgrade): 'docker', 'compose', 'up', '-d', '--wait', '--wait-timeout', '180', '--pull', 'never', '--remove-orphans', cwd=destination) # A rerun of a start that lost the port must not report success: the console would be another installation's. settings = read_env(destination / '.env') + # compose.yaml's defaults apply when .env leaves a setting empty. + web_port = settings.get('OAC_WEB_PORT') or '8080' 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 {settings['OAC_WEB_PORT']}; something else holds it.{port_holder_hint(settings['OAC_WEB_PORT'])}\nFree the port, then rerun the same command.") - print(f"Console: {settings['OAC_PUBLIC_URL']}") + 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.") + 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.") if existing: