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
62 changes: 59 additions & 3 deletions deploy/install-source.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pathlib import Path
import re
import shutil
import socket
import subprocess
import sys
import tempfile
Expand All @@ -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:')), '')
Expand Down Expand Up @@ -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')}
Expand Down Expand Up @@ -141,8 +191,14 @@ 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')
# 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 {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:
Expand Down
43 changes: 40 additions & 3 deletions deploy/test_install_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import tempfile
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
Expand Down
Loading