From 0fb9bc8453b6f47c24a6d04dfb4eaac89b82c621 Mon Sep 17 00:00:00 2001 From: OAC Core Date: Sat, 3 Oct 2026 14:10:11 +0800 Subject: [PATCH] fix(installer): check the configuration before starting a fresh install A fresh install ran compose pull, created the Core container to copy oac out, then started the stack with up -d --wait. Core exits on an invalid configuration and restarts forever, yet --wait still returns 0, so the installer printed 'OpenAgentCore is running.' while Core was not listening. install.sh now runs the data initialization to completion and validates the settings with oac-core check-config before up -d --wait, matching oac apply. check-config reads the installation id that init writes, so init must run first; a rejected configuration exits non-zero, prints the Core message, and the cleanup trap removes the directory. Co-authored-by: multica-agent --- deploy/README.md | 2 +- deploy/install.sh | 13 ++++++++++ deploy/test_install.py | 55 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 7d254a21..8873f3c3 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -9,7 +9,7 @@ ## Installation -`install.sh` downloads its release's `compose.yaml` and port files, checks them against `compose-sha256sums.txt`, writes `.env`, and starts Compose. Core applies database migrations when it starts. The host needs Linux amd64 and Docker Compose 2.26 or newer. [Configuration](../docs/configuration.md) owns the installation layout and settings. +`install.sh` downloads its release's `compose.yaml` and port files, checks them against `compose-sha256sums.txt`, writes `.env`, initializes the data directory and validates the settings with `oac-core check-config`, then starts Compose. A rejected setting stops the install before any service starts. Core applies database migrations when it starts. The host needs Linux amd64 and Docker Compose 2.26 or newer. [Configuration](../docs/configuration.md) owns the installation layout and settings. `oac` is a Go command (`services/core/cmd/oac`) in the Core image and the ingress image. The host copy implements `apply`, `core-key` and `rotate-core-key`; `core-key --show` runs `oac-web core-key` in the Web container. Start, stop, logs and removal are `docker compose`. `apply` runs `oac-core check-config` before recreating services. The ingress image runs data initialization as `oac init` and contains no Python. No service receives a Docker socket. diff --git a/deploy/install.sh b/deploy/install.sh index cda44d87..39ad532e 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -109,6 +109,19 @@ umask 077 docker compose create core docker compose cp core:/usr/local/bin/oac ./oac chmod 755 ./oac + # check-config reads the installation id and secrets that init writes, so + # initialize the data directory before validating the settings. A rejected + # configuration must fail here, not after Compose reports a running stack. + if ! initialized="$(docker compose run --rm -T init 2>&1)"; then + printf '%s\n' "$initialized" >&2 + echo "Installation initialization failed; no service was started." >&2 + exit 1 + fi + if ! checked="$(docker compose run --rm -T --no-deps --entrypoint /usr/local/bin/oac-core core check-config 2>&1)"; then + printf '%s\n' "$checked" >&2 + echo "Configuration check failed; no service was started." >&2 + exit 1 + fi docker compose up -d --wait ) kept=1 diff --git a/deploy/test_install.py b/deploy/test_install.py index 1ac0319c..9ff0d304 100644 --- a/deploy/test_install.py +++ b/deploy/test_install.py @@ -13,7 +13,7 @@ class InstallScriptTests(unittest.TestCase): - def install(self, root, *args, compose_up=0): + def install(self, root, *args, compose_up=0, check_config=0, init=0): bin_dir = root / "bin" bin_dir.mkdir(exist_ok=True) log = root / "docker.log" @@ -22,6 +22,17 @@ def install(self, root, *args, compose_up=0): printf '%s\\n' "$*" >> {log} if [ "$1" = compose ] && [ "$2" = version ]; then printf 'v2.29.1\\n'; exit 0; fi if [ "$1" = compose ] && [ "$2" = cp ]; then printf '#!/bin/sh\\n' > ./oac; exit 0; fi + if [ "$1" = compose ] && [ "$2" = run ]; then + case "$*" in + *check-config*) + if [ {check_config} -ne 0 ]; then + printf '%s\\n' 'OAC_PUBLIC_URL must be a canonical HTTPS origin without path, credentials, query or fragment, such as https://core.example; plain HTTP is accepted only for a loopback host' >&2 + fi + exit {check_config} + ;; + esac + exit {init} + fi if [ "$1" = compose ] && [ "$2" = up ]; then exit {compose_up}; fi exit 0 """)) @@ -51,6 +62,48 @@ def test_a_failed_first_start_stops_and_removes_the_directory(self): self.assertIn("compose pull", recorded) self.assertIn("compose up -d --wait", recorded) + def test_initialization_and_configuration_check_run_before_the_stack_starts(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + completed, recorded = self.install(root, "--public-url", "https://core.example") + self.assertEqual(completed.returncode, 0, completed.stderr) + lines = recorded.splitlines() + initialize = next(index for index, line in enumerate(lines) if line == "compose run --rm -T init") + check = next(index for index, line in enumerate(lines) if line.endswith("core check-config")) + up = next(index for index, line in enumerate(lines) if line == "compose up -d --wait") + self.assertLess(initialize, check, recorded) + self.assertLess(check, up, recorded) + self.assertIn("--no-deps", lines[check]) + self.assertIn("--entrypoint /usr/local/bin/oac-core", lines[check]) + + def test_a_rejected_public_url_fails_without_reporting_success(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + completed, recorded = self.install(root, "--public-url", "http://10.0.0.5:8080", check_config=1) + self.assertNotEqual(completed.returncode, 0) + self.assertIn("plain HTTP is accepted only for a loopback host", completed.stderr) + self.assertNotIn("OpenAgentCore is running.", completed.stdout) + self.assertNotIn("compose up -d --wait", recorded) + self.assertFalse((root / "oac").exists(), "a rejected configuration must remove the directory") + + def test_the_insecure_origin_switch_keeps_the_install_succeeding(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + completed, _ = self.install(root, "--public-url", "http://10.0.0.5:8080", "--allow-insecure-origin") + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIn("OpenAgentCore is running.", completed.stdout) + self.assertIn("Core key:", completed.stdout) + + def test_a_failed_initialization_stops_before_the_stack_starts(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + completed, recorded = self.install(root, "--public-url", "https://core.example", init=1) + self.assertNotEqual(completed.returncode, 0) + self.assertNotIn("OpenAgentCore is running.", completed.stdout) + self.assertNotIn("check-config", recorded) + self.assertNotIn("compose up -d --wait", recorded) + self.assertFalse((root / "oac").exists()) + def test_env_holds_only_the_installation_choices(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary)