diff --git a/apps/web/src/features/sandbox/standard-sizes.md b/apps/web/src/features/sandbox/standard-sizes.md index 24f1d1f8..04328c5e 100644 --- a/apps/web/src/features/sandbox/standard-sizes.md +++ b/apps/web/src/features/sandbox/standard-sizes.md @@ -22,7 +22,7 @@ The values must stay within the bounds that Core and `validSandboxResources` acc - The Web setup wizard, through `defaultSandboxResources` in `deployment-specification.ts`. - The release bundle: `scripts/build-core-distribution.sh` copies this file to `/standard-sizes.json`. -- The Core installer: `deploy/install/sandbox_setup.py` reads the bundled copy when `install.sh` saves the initial Docker or microsandbox deployment (`--sandbox`, microsandbox by default). +- The Core installer: `deploy/install/sandbox_setup.py` reads the bundled copy when `install.sh` saves the initial microsandbox deployment at the Standard size. ## Contract diff --git a/deploy/install/README.md b/deploy/install/README.md index 83125969..48f2301c 100644 --- a/deploy/install/README.md +++ b/deploy/install/README.md @@ -43,15 +43,15 @@ This directory holds the Core/Web installer, the `oac` command and the node inst - Install only into an empty directory or over an [incomplete installation](#new-installations), or repair a complete installation of the same source revision. Require the current state format before changing installation files or services, and a matching source revision when repairing a complete installation. An incomplete installation of that state format is removed before installing the selected release, even when its source revision differs. Preserve data when those checks fail. - The packaged `oac.pyz` embeds its build revision and refuses a `state.json` whose `source_commit` differs. -- The installer and every mutating `oac` command share `.oac.lock`. The installer holds it across creation, payload, native service and launcher repair, and apply, calling the already-locked apply implementation without locking again. Never replace the lock file; its inode must stay stable. Only the cleanup of a new installation, with the directory the installer created, and `oac uninstall` unlink it, last and while holding it. `locked` refuses a lock whose path no longer names the file it locked. +- The installer and every mutating `oac` command share `.oac.lock`. The installer holds it across creation, payload and launcher repair, and apply, calling the already-locked apply implementation without locking again. Never replace the lock file; its inode must stay stable. Only the cleanup of a new installation, with the directory the installer created, and `oac uninstall` unlink it, last and while holding it. `locked` refuses a lock whose path no longer names the file it locked. ## New installations -- An installation is complete after its first start: apply converged, the services are healthy and, for Web-only, its Core accepts the Core key. `create()` writes `state.json` before anything else, atomically, with the project name and `"complete": false`, and the installer sets it to `true` at that moment. A later failure, such as a refused sandbox selection, keeps the installation. +- An installation is complete after its first start: apply converged and the services are healthy. `create()` writes `state.json` before anything else, atomically, with the project name and `"complete": false`, and the installer sets it to `true` at that moment. A later failure, such as a refused sandbox selection, keeps the installation. - Until then, `oac apply`, `start`, `domain` and `rotate-core-key` and Web's domain setup refuse the installation and point to the installer and `oac uninstall`; `oac status` reports it. Only the installer starts an incomplete installation; the installer or `oac uninstall` removes it. -- Any failure or interrupt (Ctrl-C, SIGTERM, SIGHUP) of an installation that is not complete runs `oac_cli.remove` before anything is printed, so a closed terminal can't stop it. The original error then goes on, and the installer prints it followed by `Nothing was kept; fix the problem and rerun the same command.` `remove` disables native Core's unit, runs `docker compose -p down --volumes --remove-orphans` from `/` without `COMPOSE_*` variables and with its output discarded, and deletes every file in the installation directory. The directory goes too when the installer created it; otherwise it stays with its lock. Loaded images stay. +- Any failure or interrupt (Ctrl-C, SIGTERM, SIGHUP) of an installation that is not complete runs `oac_cli.remove` before anything is printed, so a closed terminal can't stop it. The original error then goes on, and the installer prints it followed by `Nothing was kept; fix the problem and rerun the same command.` `remove` runs `docker compose -p down --volumes --remove-orphans` from `/` without `COMPOSE_*` variables and with its output discarded, and deletes every file in the installation directory. The directory goes too when the installer created it; otherwise it stays with its lock. Loaded images stay. - The release downloader waits for the bundled installer in a separate process group. It forwards the first Ctrl-C, SIGTERM or SIGHUP and waits for cleanup before removing the temporary bundle; repeated signals do not interrupt that wait. The child inherits the download lock, so killing the downloader alone cannot expose its active files to another download. -- `remove` touches only the `oac-<10 hex digits>` project and its `-core.service` named in `state.json`, and never follows a link. `state.json`, then the `oac` command, then `.oac.lock` are removed last, and the files stay when a service can't be removed, so the next run still recognizes the installation and no other command locks it afresh mid-removal. Those final unlinks ignore SIGINT, SIGTERM and SIGHUP; if the command cannot be unlinked, cleanup restores `state.json` for a retry. SIGKILL or power loss during those final unlinks can leave files that need manual removal. The error lists what is left and the commands that remove it; the printed Compose command uses the same directory and environment isolation as automatic cleanup. +- `remove` touches only the `oac-<10 hex digits>` project named in `state.json`, and never follows a link. `state.json`, then the `oac` command, then `.oac.lock` are removed last, and the files stay when a service can't be removed, so the next run still recognizes the installation and no other command locks it afresh mid-removal. Those final unlinks ignore SIGINT, SIGTERM and SIGHUP; if the command cannot be unlinked, cleanup restores `state.json` for a retry. SIGKILL or power loss during those final unlinks can leave files that need manual removal. The error lists what is left and the commands that remove it; the printed Compose command uses the same directory and environment isolation as automatic cleanup. - A rerun over an incomplete installation, a `state.json` without `"complete": true`, removes it the same way under the lock and then checks the settings and ports and installs with the flags given now. Only that explicit marker proves completion. A directory without `state.json` is refused whatever it holds, and nothing in it is removed. ## Uninstall @@ -62,23 +62,20 @@ This directory holds the Core/Web installer, the `oac` command and the node inst ## Install-time sandbox selection -`--sandbox docker|microsandbox|e2b|none` (default `microsandbox`; only `none` with `--web-only`) is a one-time action. After the services are healthy, the installer posts `/core/v1/sandbox/deployment` once, as Web's setup would, and never on a repair. The choice is not written to `config.json`; PostgreSQL owns it, and an existing database selection is never overwritten. +After the services are healthy, the installer posts `/core/v1/sandbox/deployment` once for microsandbox at Web's Standard size, as Web's setup would, and never on a repair. The choice is not written to `config.json`; PostgreSQL owns it, and an existing database selection is never overwritten. -- Docker and microsandbox use Web's Standard size from `apps/web/src/features/sandbox/standard-sizes.json`, which the distribution build copies into the bundle. Keep no other copy of those values. -- `docker` prints its weaker isolation and needs a y/N confirmation or `--accept-docker-risks` before anything is created. -- `e2b` needs a non-loopback HTTPS `public_url`, `--e2b-api-key-file` and `--e2b-template`; otherwise the installer refuses before installing anything. -- A Docker or microsandbox selection with a loopback `public_url` is saved, but no node can serve it until `public_url` is guest-reachable HTTPS. +- microsandbox uses Web's Standard size from `apps/web/src/features/sandbox/standard-sizes.json`, which the distribution build copies into the bundle. Keep no other copy of those values. +- A selection with a loopback `public_url` is saved, but no node can serve it until `public_url` is guest-reachable HTTPS. ## Accounts and permissions - The Core/Web installer runs as the launching account, root included, in a writable installation directory. It never invokes sudo, switches accounts or changes Docker permissions. Check the actual platform, Docker and directory prerequisites; root alone is no reason to refuse. -- `--native-core` runs Core as a systemd user service of that account, with lingering, and keeps PostgreSQL and Web in Compose with a private loopback database port. Native Core needs no KVM or node assets. - Installation state and secrets are private under `~/.oac/`. No credential enters build arguments, image layers, browser bundles or diagnostic output. The Compose file is confidential. - The distribution build uses umask 022 so non-root service users can read the payload; installation credentials and state keep their private modes. ## Managed HTTPS -A default combined Docker installation adds two Compose services from one pinned image: `gateway` runs Caddy, and `installation` runs the packaged `oac domain-server`. The latter runs with the installing account's UID and its Docker socket access and calls the same locked apply implementation. Its only request surface is the private `ingress/api/api.sock`, with Core-key authentication and one typed domain action. Core and Web get no Docker socket, host process authority or writable installation configuration. Web gets only the private API socket directory, never Caddy's admin socket. +A default installation adds two Compose services from one pinned image: `gateway` runs Caddy, and `installation` runs the packaged `oac domain-server`. The latter runs with the installing account's UID and its Docker socket access and calls the same locked apply implementation. Its only request surface is the private `ingress/api/api.sock`, with Core-key authentication and one typed domain action. Core and Web get no Docker socket, host process authority or writable installation configuration. Web gets only the private API socket directory, never Caddy's admin socket. - The gateway publishes the initial Web port, and ports 80 and 443 only for HTTPS (`ingress_config.published`). Caddy issues and renews certificates and keeps its private data in `ingress/data`. `generated/Caddyfile` is derived from `config.json`, and apply reloads it through the private Caddy socket even when container inputs already match. - A domain change first checks that the hostname resolves and that no other program holds port 80 or 443; ports the gateway publishes while it runs as written are its own. It confirms a public URL change from the applied address, because it returns Core there before switching. It keeps the old entry point while the common apply renders the candidate address: the gateway publishes 80 and 443 and serves the candidate, and the verification needs a trusted certificate and an installation-specific response over HTTPS. Only then does it set `public_url` and apply again. Failure restores the previous configuration, including the gateway without 80 and 443 when HTTPS was off, and reports incomplete recovery; failed retries restore through the common apply even after a partial change. @@ -140,7 +137,7 @@ The Core and node installers share one resolver for these identities. It confirm - Interactive selection and CLI-only installation share one options and validation path. There is no installation-options file. The saved installation state and explicitly supplied credential and tool-variable files serve runtime operation, not a second configuration language. - Each release bundles pinned Node.js and npm, the native Harnesses and their adapter assets. Registration lives in the CLI, and native activation and readiness in each adapter's optional `agent.Installation` descriptor. Core never selects native paths or OS-specific steps. - Bootstrap scripts only download and extract the current platform's archive, after verifying the checksum Core provides. Installation, startup, connection verification and execution stay common. Native bundles must match Core's source revision and Runtime wire version. -- Neither Core installation nor repair downloads native payloads. The Core installer keeps the catalog and any offline archives in the installation's private `native-installers/` directory, mounts it read-only into container Core and points native Core at the same files. Core serves the local offline archives or redirects to the catalog URL without proxying or caching; it verifies local archives when it starts, and a corrupt local archive stops Core from starting. +- Neither Core installation nor repair downloads native payloads. The Core installer keeps the catalog and any offline archives in the installation's private `native-installers/` directory and mounts it read-only into Core. Core serves the local offline archives or redirects to the catalog URL without proxying or caching; it verifies local archives when it starts, and a corrupt local archive stops Core from starting. - Every mutation holds the installation directory lock. Publish complete, checksum-verified components from staging, then commit the configuration after native readiness passes. A rerun with the same connection settings adds the selected Harnesses and validates existing contents. Never overwrite, upgrade, repair or migrate installed components; missing, modified, wrong-platform or incompatible content is an explicit error. A partial addition keeps the old configuration and reusable complete components and removes nothing. - Serialize background PID inspection and publication so concurrent starts cannot create two daemons. An installed daemon registers only the adapter kinds its verified installation manifest names; other Harness executables on `PATH` cannot extend it. Direct `connect` refuses an installed Runtime and points to `start`. - The installer runs as the current user in writable directories and never elevates. Subprocess diagnostics never expose sensitive parameters or environment values. Readiness checks take the installer's cancellation context and reap their processes before returning. diff --git a/deploy/install/config.schema.json b/deploy/install/config.schema.json index 192221c6..0afac916 100644 --- a/deploy/install/config.schema.json +++ b/deploy/install/config.schema.json @@ -4,7 +4,7 @@ "description": "Process settings of one installation. Edit config.json, then run oac apply.", "type": "object", "additionalProperties": false, - "required": ["format", "mode"], + "required": ["format"], "properties": { "$schema": { "type": "string", @@ -16,18 +16,6 @@ "description": "Configuration format for this release. Fixed after installation.", "x-oac": {"setting": false, "changeable": false} }, - "mode": { - "enum": ["all", "core-only", "web-only"], - "default": "all", - "description": "Which services this installation runs.", - "x-oac": {"changeable": false, "install_flag": "--core-only or --web-only"} - }, - "native_core": { - "type": "boolean", - "default": false, - "description": "Run Core as a systemd user service instead of a container.", - "x-oac": {"changeable": false, "modes": ["all", "core-only"], "install_flag": "--native-core"} - }, "public_url": { "type": ["string", "null"], "default": null, @@ -42,7 +30,7 @@ "host": { "type": "string", "default": "127.0.0.1", - "description": "Listener IP. With managed ingress only the gateway is public; Core stays on loopback. The default combined installer listens on all IPv4 interfaces.", + "description": "Listener IP. With managed ingress only the gateway is public; Core stays on loopback. The default installer listens on all IPv4 interfaces.", "x-oac": { "check": "listen_host", "restarts": [ @@ -64,12 +52,10 @@ "minimum": 1024, "maximum": 65535, "default": 8091, - "description": "Host port of the Core API. With native Core, Web follows it.", + "description": "Host port of the Core API.", "x-oac": { - "modes": ["all", "core-only"], "restarts": ["core"], - "native_restarts": ["core", "web"], - "derives": ["Core port mapping or OAC_ADDR"], + "derives": ["Core port mapping"], "install_flag": "--core-port" } }, @@ -80,39 +66,10 @@ "default": 8080, "description": "Host port of Web.", "x-oac": { - "modes": ["all", "web-only"], "restarts": ["web"], - "derives": ["Web port mapping or OAC_WEB_ADDR"], + "derives": ["Web or gateway port mapping"], "install_flag": "--web-port" } - }, - "database": { - "type": "integer", - "minimum": 1024, - "maximum": 65535, - "description": "Loopback port of PostgreSQL. Present exactly when native_core is true; the installer picks a free port.", - "x-oac": { - "modes": ["all", "core-only"], - "restarts": ["database", "core"], - "derives": ["database port mapping", "OAC_DATABASE_URL"] - } - } - } - }, - "web": { - "type": "object", - "additionalProperties": false, - "x-oac": {"modes": ["web-only"]}, - "properties": { - "core_url": { - "type": "string", - "description": "Origin of the Core that this Web connects to: HTTPS, or HTTP on a loopback host.", - "x-oac": { - "check": "origin", - "restarts": ["web"], - "derives": ["OAC_WEB_UPSTREAM"], - "install_flag": "--core-url" - } } } }, @@ -143,7 +100,6 @@ "core": { "type": "object", "additionalProperties": false, - "x-oac": {"modes": ["all", "core-only"]}, "properties": { "execution_concurrency": { "type": "integer", @@ -270,7 +226,7 @@ "ingress": { "enum": ["managed", "external"], "default": "external", - "description": "managed provides automatic HTTPS and Web domain setup for a combined Docker installation; install.sh selects it by default. external uses your existing proxy. Fixed after installation.", + "description": "managed provides automatic HTTPS and Web domain setup; install.sh selects it by default. external uses your existing proxy. Fixed after installation.", "x-oac": {"changeable": false, "install_flag": "--ingress"} } } diff --git a/deploy/install/config_model.py b/deploy/install/config_model.py index ca48fd5e..9ff9fc73 100644 --- a/deploy/install/config_model.py +++ b/deploy/install/config_model.py @@ -8,13 +8,11 @@ import os import re -MODES = ("all", "core-only", "web-only") -SERVICES = {"all": ("core", "web", "database"), "core-only": ("core", "database"), "web-only": ("web",)} +SERVICES = ("core", "web", "database") KEYWORDS = {"$schema", "title", "type", "enum", "const", "default", "description", "minimum", "maximum", "pattern", "items", "minItems", "uniqueItems", "properties", "required", "additionalProperties", "x-oac"} -ANNOTATIONS = {"changeable", "modes", "restarts", "native_restarts", "sensitive", "derives", "install_flag", "check", - "setting"} +ANNOTATIONS = {"changeable", "restarts", "sensitive", "derives", "install_flag", "check", "setting"} def _schema_text(): @@ -39,16 +37,15 @@ def annotation(node, name, default=None): return node.get("x-oac", {}).get(name, default) -def leaves(node=None, prefix="", modes=MODES): - """Yield (dotted key, schema node, modes) for every leaf, in schema order.""" +def leaves(node=None, prefix=""): + """Yield (dotted key, schema node) for every leaf, in schema order.""" node = SCHEMA if node is None else node for name, child in node["properties"].items(): key = prefix + name - child_modes = tuple(annotation(child, "modes", modes)) if "properties" in child: - yield from leaves(child, key + ".", child_modes) + yield from leaves(child, key + ".") else: - yield key, child, child_modes + yield key, child def lookup(config, key): @@ -111,7 +108,7 @@ def _type_ok(value, name): "null": type(None)}[name]) -def _validate(node, value, key, mode, problems): +def _validate(node, value, key, problems): label = key or "config.json" types = node.get("type") if types is not None: @@ -141,7 +138,7 @@ def _validate(node, value, key, mode, problems): if node.get("uniqueItems") and len({json.dumps(item, sort_keys=True) for item in value}) != len(value): problems.append(f"{label}: lists an item twice") for index, item in enumerate(value): - _validate(node.get("items", {}), item, f"{label}[{index}]", mode, problems) + _validate(node.get("items", {}), item, f"{label}[{index}]", problems) if isinstance(value, dict): properties = node.get("properties", {}) for name in node.get("required", []): @@ -150,19 +147,16 @@ def _validate(node, value, key, mode, problems): for name, item in value.items(): child = f"{key}.{name}" if key else name if name in properties: - if mode not in annotation(properties[name], "modes", MODES): - problems.append(f'{child}: does not apply when mode is "{mode}"; remove it') - else: - _validate(properties[name], item, child, mode, problems) + _validate(properties[name], item, child, problems) elif isinstance(node.get("additionalProperties"), dict): - _validate(node["additionalProperties"], item, child, mode, problems) + _validate(node["additionalProperties"], item, child, problems) else: problems.append(f"{child}: unknown key") -def _complete(node, value, mode): +def _complete(node, value): for name, child in node.get("properties", {}).items(): - if mode not in annotation(child, "modes", MODES) or not annotation(child, "setting", True): + if not annotation(child, "setting", True): continue if name not in value: if "default" in child: @@ -172,36 +166,25 @@ def _complete(node, value, mode): else: continue if isinstance(value[name], dict) and "properties" in child: - _complete(child, value[name], mode) + _complete(child, value[name]) def validate(config): - """Return config with defaults filled in for every applicable key, or raise ConfigError.""" + """Return config with defaults filled in for every key, or raise ConfigError.""" if not isinstance(config, dict): raise ConfigError(["config.json: must be a JSON object"]) - mode = config.get("mode") - if mode not in MODES: - raise ConfigError([f"mode: must be one of {', '.join(MODES)}"]) problems = [] - _validate(SCHEMA, config, "", mode, problems) + _validate(SCHEMA, config, "", problems) if problems: raise ConfigError(problems) full = copy.deepcopy(config) - _complete(SCHEMA, full, mode) - native = full.get("native_core", False) - ports = full.get("ports", {}) - if mode != "web-only" and native != ("database" in ports): - problems.append("ports.database: required exactly when native_core is true" - if native else "ports.database: applies only with native_core; remove it") - if mode == "web-only" and "core_url" not in full.get("web", {}): - problems.append("web.core_url: required for a web-only installation") + _complete(SCHEMA, full) + ports = full["ports"] if len(set(ports.values())) != len(ports): problems.append("ports: " + ", ".join(sorted(ports)) + " need different ports") from configuration import loopback_listener import ingress_config managed = ingress_config.enabled(full) - if managed and (mode != "all" or native): - problems.append("ingress: managed requires a combined Docker installation; select external") if managed and full["public_url"]: try: from urllib.parse import urlsplit @@ -212,8 +195,8 @@ def validate(config): problems.append("public_url: managed HTTPS requires https:// followed by a DNS hostname, without a port") if not managed and not loopback_listener(full["host"]) and not (full["public_url"] or "").startswith("https://"): problems.append("public_url: an HTTPS origin is required when host is not loopback") - core = full.get("core") - if core and core["default_harness"] not in core["harnesses"]: + core = full["core"] + if core["default_harness"] not in core["harnesses"]: problems.append("core.default_harness: must be listed in core.harnesses") if problems: raise ConfigError(problems) @@ -234,11 +217,9 @@ def ordered(config, node=None): return result -def initial(mode, native_core=False, **values): - """Every applicable field for a new installation, seeded from installer flags.""" - config = {"$schema": "generated/config.schema.json", "format": 1, "mode": mode} - if mode != "web-only": - config["native_core"] = native_core +def initial(**values): + """Every field for a new installation, seeded from installer flags.""" + config = {"$schema": "generated/config.schema.json", "format": 1} for key, value in values.items(): if value is None: continue @@ -250,37 +231,28 @@ def initial(mode, native_core=False, **values): return validate(config) -def is_setting(key, node, modes, config): - return (config["mode"] in modes and annotation(node, "setting", True) - and (key != "ports.database" or config.get("native_core", False))) - - def values(config): - """Every applicable setting of a validated config, by dotted key.""" - return {key: lookup(config, key) for key, node, modes in leaves() if is_setting(key, node, modes, config)} + """Every setting of a validated config, by dotted key.""" + return {key: lookup(config, key) for key, node in leaves() if annotation(node, "setting", True)} def settings(config): """The non-secret snapshot Core serves at GET /core/v1/installation.""" - mode = config["mode"] items = [] - for key, node, modes in leaves(): - if not is_setting(key, node, modes, config): + for key, node in leaves(): + if not annotation(node, "setting", True): continue value = lookup(config, key) sensitive = annotation(node, "sensitive", False) item = {"key": key, "value": None if sensitive else value} if sensitive: item["configured"] = bool(value) - # With native Core some settings restart more; native_restarts names them all. - restarts = annotation(node, "native_restarts" if config.get("native_core") and - annotation(node, "native_restarts") else "restarts", []) item.update({"default": node.get("default"), "changeable": annotation(node, "changeable", True), "sensitive": sensitive, - "restarts": [name for name in restarts if name in SERVICES[mode]]}) + "restarts": [name for name in annotation(node, "restarts", []) if name in SERVICES]}) items.append(item) return items def sensitive_keys(): - return [key for key, node, _ in leaves() if annotation(node, "sensitive", False)] + return [key for key, node in leaves() if annotation(node, "sensitive", False)] diff --git a/deploy/install/configuration.py b/deploy/install/configuration.py index aa746ea2..a09a3813 100644 --- a/deploy/install/configuration.py +++ b/deploy/install/configuration.py @@ -14,12 +14,9 @@ import config_model import ingress_config -import native_service # Where Core and Web containers see secrets and generated inputs. RUN = "/run/oac" -# With native Core, PostgreSQL publishes its port here. -DATABASE_HOST = "127.0.0.1" POOL = (("max_conns", "pool_max_conns"), ("min_conns", "pool_min_conns"), ("max_conn_lifetime", "pool_max_conn_lifetime"), ("max_conn_idle_time", "pool_max_conn_idle_time"), ("health_check_period", "pool_health_check_period")) @@ -64,7 +61,7 @@ def valid_core_origin(value): def environment_text(values, header): - """The shared Compose/systemd subset: quoted, single-line literal values.""" + """The Compose env_file subset: quoted, single-line literal values.""" lines = ["# " + header + "\n", "# Values are literal. Escape backslash, double quote and dollar with backslash.\n"] for key, value in values.items(): @@ -109,9 +106,9 @@ def read_core_key(root): return key -def secret_digests(root, mode): - names = ["core.key"] if mode == "web-only" else ["core.key", "credential.key", "database.password"] - return {name: sha256((Path(root) / "secrets" / name).read_bytes()) for name in names} +def secret_digests(root): + return {name: sha256((Path(root) / "secrets" / name).read_bytes()) + for name in ("core.key", "credential.key", "database.password")} def valid_listen_host(value): @@ -152,18 +149,15 @@ def listeners(config, candidate=None): setting is the config.json key that owns the port. candidate is the address domain setup verifies, as in render. """ - mode, ports, result = config["mode"], config["ports"], [] + ports, result = config["ports"], [] if ingress_config.enabled(config): # The gateway publishes Web's port and, for HTTPS, 80 and 443; Web itself publishes none. result += [Listener("Web", config["host"], port, "ports.web") if port == ports["web"] else Listener("HTTPS", config["host"], port, "public_url") for port, _ in ingress_config.published(config, candidate)] - elif mode != "core-only": + else: result.append(Listener("Web", service_host(config, "web"), ports["web"], "ports.web")) - if mode != "web-only": - result.append(Listener("Core", service_host(config, "core"), ports["core"], "ports.core")) - if config.get("native_core"): - result.append(Listener("PostgreSQL", DATABASE_HOST, ports["database"], "ports.database")) + result.append(Listener("Core", service_host(config, "core"), ports["core"], "ports.core")) return result @@ -191,35 +185,31 @@ def log_environment(log): def core_environment(root, config, state): root = Path(root) - native = config["native_core"] - generated = str(root / "generated") if native else RUN - secrets = str(root / "secrets") if native else RUN - ports, core = config["ports"], config["core"] - database = f'{DATABASE_HOST}:{ports["database"]}' if native else "database:5432" + core = config["core"] query = [("sslmode", "disable")] + [(name, str(core["database_pool"][key])) for key, name in POOL if core["database_pool"][key] is not None] result = { - "OAC_ADDR": service_address(config, "core") if native else ":8091", + "OAC_ADDR": ":8091", "OAC_PUBLIC_URL": local_public_url(config), - "OAC_DATABASE_URL": f"postgres://agents_api@{database}/agents_api?" + urlencode(query), - "OAC_DATABASE_PASSWORD_FILE": secrets + "/database.password", - "OAC_CREDENTIAL_KEY_FILE": secrets + "/credential.key", - "OAC_CORE_KEY_DIGESTS_FILE": generated + "/core-key-digests.json", + "OAC_DATABASE_URL": "postgres://agents_api@database:5432/agents_api?" + urlencode(query), + "OAC_DATABASE_PASSWORD_FILE": RUN + "/database.password", + "OAC_CREDENTIAL_KEY_FILE": RUN + "/credential.key", + "OAC_CORE_KEY_DIGESTS_FILE": RUN + "/core-key-digests.json", "OAC_INSTALLATION_ID": state["installation_id"], - "OAC_SETTINGS_FILE": generated + "/settings.json", - "OAC_PROVIDER_ROOT": str(root / "native") if native else "/opt/oac", - "OAC_PROVIDER_STATE_ROOT": str(root / "state") if native else "/state", + "OAC_SETTINGS_FILE": RUN + "/settings.json", + "OAC_PROVIDER_ROOT": "/opt/oac", + "OAC_PROVIDER_STATE_ROOT": "/state", "OAC_DEFAULT_HARNESS": core["default_harness"], "OAC_HARNESSES": ",".join(core["harnesses"]), "OAC_EXECUTION_CONCURRENCY": str(core["execution_concurrency"]), "OAC_WRITE_AUDIT_RETENTION": core["write_audit_retention"], } if (root / "native-installers/catalog.json").is_file(): - result["OAC_NATIVE_INSTALLER_DIR"] = str(root / "native-installers") if native else "/opt/oac/native-installers" + result["OAC_NATIVE_INSTALLER_DIR"] = "/opt/oac/native-installers" if core["oauth_trusted_origins"]: result["OAC_OAUTH_TRUSTED_ORIGINS"] = ",".join(core["oauth_trusted_origins"]) if core["runtime_history"] is not None: - result["OAC_HISTORY_SETTINGS_FILE"] = generated + "/runtime-history.json" + result["OAC_HISTORY_SETTINGS_FILE"] = RUN + "/runtime-history.json" result.update(log_environment(config["log"])) return result @@ -232,71 +222,56 @@ def settings_document(root, config, applied_at): def compose_config(root, config, state, candidate=None): root = Path(root) - mode, native = config["mode"], config.get("native_core", False) identity = f'{state["uid"]}:{state["gid"]}' images = state["images"] doc = {"name": state["project"], # Compose interpolates every string, extension fields included. "x-oac": {"generated_from": str(root / "config.json").replace("$", "$$"), "edit": "config.json, then oac apply"}, - "services": {}} + "services": {}, "volumes": {"database": {}}} services = doc["services"] - if mode != "web-only": - services["database"] = { - "image": images["database"], "restart": "unless-stopped", - "environment": {"POSTGRES_USER": "agents_api", "POSTGRES_DB": "agents_api", - "POSTGRES_PASSWORD_FILE": "/run/secrets/database.password"}, - "volumes": ["database:/var/lib/postgresql/data", - bind(root / "secrets/database.password", "/run/secrets/database.password")], - "healthcheck": {"test": ["CMD-SHELL", "pg_isready -U agents_api -d agents_api"], - "interval": "2s", "timeout": "5s", "retries": 30}, - } - doc["volumes"] = {"database": {}} - if native: - services["database"]["ports"] = [f'{DATABASE_HOST}:{config["ports"]["database"]}:5432'] - else: - mounts = [bind(root / "secrets" / name, f"{RUN}/{name}") for name in ("credential.key", "database.password")] - if (root / "native-installers/catalog.json").is_file(): - mounts.append(bind(root / "native-installers", "/opt/oac/native-installers")) - mounts += [bind(root / "generated" / name, f"{RUN}/{name}") for name in ("core-key-digests.json", "settings.json")] - if config["core"]["runtime_history"] is not None: - mounts.append(bind(root / "generated/runtime-history.json", f"{RUN}/runtime-history.json")) - shared = {"image": images["core"], "user": identity, - "env_file": [str(root / "generated/core.env").replace("$", "$$")], "volumes": mounts, - "read_only": True, "tmpfs": ["/tmp:mode=1777"], "init": True, - "security_opt": ["no-new-privileges:true"]} - services["migrate"] = dict(shared, command=["/usr/local/bin/oac-core-migrate"], - depends_on={"database": {"condition": "service_healthy"}}) - services["core"] = dict(shared, restart="unless-stopped", ports=[service_address(config, "core") + ":8091"], - depends_on={"migrate": {"condition": "service_completed_successfully"}}, - volumes=mounts + [bind(root / "state/e2b", "/state/e2b", False)]) - if mode != "core-only": - if mode == "web-only": - upstream = config["web"]["core_url"] - else: - upstream = service_origin(config, "core") if native else "http://core:8091" - environment = { - "OAC_WEB_ORIGIN": web_origin(config), - "OAC_WEB_UPSTREAM": upstream, - "OAC_WEB_CORE_KEY_FILE": f"{RUN}/core.key", - "OAC_WEB_NODE_PAYLOAD_DIR": "/node-payload", - } - environment.update(log_environment(config["log"])) - web = {"image": images["web"], "user": identity, "restart": "unless-stopped", - "ports": [service_address(config, "web") + ":8080"], "read_only": True, - "security_opt": ["no-new-privileges:true"], - "volumes": [bind(root / "secrets/core.key", f"{RUN}/core.key"), bind(root / "node-payload", "/node-payload")], - "environment": environment} - if mode == "web-only" or native: - web.pop("ports") - web["network_mode"] = "host" - environment["OAC_WEB_ADDR"] = service_address(config, "web") - if ingress_config.enabled(config): - web.pop("ports") - environment["OAC_WEB_INSTALLATION_SOCKET"] = "/installation/api.sock" - environment["OAC_WEB_BOOTSTRAP"] = "1" if not config["public_url"] else "0" - web["volumes"].append(bind(root / "ingress/api", "/installation")) - services["web"] = web + services["database"] = { + "image": images["database"], "restart": "unless-stopped", + "environment": {"POSTGRES_USER": "agents_api", "POSTGRES_DB": "agents_api", + "POSTGRES_PASSWORD_FILE": "/run/secrets/database.password"}, + "volumes": ["database:/var/lib/postgresql/data", + bind(root / "secrets/database.password", "/run/secrets/database.password")], + "healthcheck": {"test": ["CMD-SHELL", "pg_isready -U agents_api -d agents_api"], + "interval": "2s", "timeout": "5s", "retries": 30}, + } + mounts = [bind(root / "secrets" / name, f"{RUN}/{name}") for name in ("credential.key", "database.password")] + if (root / "native-installers/catalog.json").is_file(): + mounts.append(bind(root / "native-installers", "/opt/oac/native-installers")) + mounts += [bind(root / "generated" / name, f"{RUN}/{name}") for name in ("core-key-digests.json", "settings.json")] + if config["core"]["runtime_history"] is not None: + mounts.append(bind(root / "generated/runtime-history.json", f"{RUN}/runtime-history.json")) + shared = {"image": images["core"], "user": identity, + "env_file": [str(root / "generated/core.env").replace("$", "$$")], "volumes": mounts, + "read_only": True, "tmpfs": ["/tmp:mode=1777"], "init": True, + "security_opt": ["no-new-privileges:true"]} + services["migrate"] = dict(shared, command=["/usr/local/bin/oac-core-migrate"], + depends_on={"database": {"condition": "service_healthy"}}) + services["core"] = dict(shared, restart="unless-stopped", ports=[service_address(config, "core") + ":8091"], + depends_on={"migrate": {"condition": "service_completed_successfully"}}, + volumes=mounts + [bind(root / "state/e2b", "/state/e2b", False)]) + environment = { + "OAC_WEB_ORIGIN": web_origin(config), + "OAC_WEB_UPSTREAM": "http://core:8091", + "OAC_WEB_CORE_KEY_FILE": f"{RUN}/core.key", + "OAC_WEB_NODE_PAYLOAD_DIR": "/node-payload", + } + environment.update(log_environment(config["log"])) + web = {"image": images["web"], "user": identity, "restart": "unless-stopped", + "ports": [service_address(config, "web") + ":8080"], "read_only": True, + "security_opt": ["no-new-privileges:true"], + "volumes": [bind(root / "secrets/core.key", f"{RUN}/core.key"), bind(root / "node-payload", "/node-payload")], + "environment": environment} + if ingress_config.enabled(config): + web.pop("ports") + environment["OAC_WEB_INSTALLATION_SOCKET"] = "/installation/api.sock" + environment["OAC_WEB_BOOTSTRAP"] = "1" if not config["public_url"] else "0" + web["volumes"].append(bind(root / "ingress/api", "/installation")) + services["web"] = web if ingress_config.enabled(config): services.update(ingress_config.services(root, config, state, bind, candidate)) return doc @@ -310,12 +285,12 @@ class Rendered: The digest covers everything a service reads: its Compose definition, the env_file content and the files and secrets it mounts. It is the service's - `io.oac.inputs` label, or OAC_INPUTS in the native unit, so the running - services can be compared with a render at any time. + `io.oac.inputs` label, so the running services can be compared with a + render at any time. """ - def __init__(self, files, services, unit): - self.files, self.services, self.unit = files, services, unit + def __init__(self, files, services): + self.files, self.services = files, services def native_installer_inputs(root): @@ -332,31 +307,29 @@ def render(root, config, state, applied_at, candidate=None): """candidate is an HTTPS address domain setup verifies before public_url changes: the gateway publishes 80 and 443 and serves it too.""" root = Path(root) - mode, native = config["mode"], config.get("native_core", False) - secrets = secret_digests(root, mode) + secrets = secret_digests(root) settings = settings_document(root, config, applied_at) files = {"config.schema.json": config_model.SCHEMA_TEXT, - "settings.json": json.dumps(settings, indent=2) + "\n"} - external, core_env, unit = {}, "", None - if mode != "web-only": - files["core-key-digests.json"] = json.dumps([sha256(read_core_key(root))]) + "\n" - history = config["core"]["runtime_history"] - if history is not None: - files["runtime-history.json"] = json.dumps(history, indent=2) + "\n" - core_env = environment_text(core_environment(root, config, state), edit_hint(root)) - files["core.env"] = core_env - # Only settings Core itself restarts for enter its inputs, so a Web-only change - # leaves Core running. Its snapshot then refreshes on Core's next restart. - core_settings = [item for item in settings["settings"] if "core" in item["restarts"]] - external["core"] = json.dumps({ + "settings.json": json.dumps(settings, indent=2) + "\n", + "core-key-digests.json": json.dumps([sha256(read_core_key(root))]) + "\n"} + history = config["core"]["runtime_history"] + if history is not None: + files["runtime-history.json"] = json.dumps(history, indent=2) + "\n" + core_env = environment_text(core_environment(root, config, state), edit_hint(root)) + files["core.env"] = core_env + # Only settings Core itself restarts for enter its inputs, so a Web setting change + # leaves Core running. Its snapshot then refreshes on Core's next restart. + core_settings = [item for item in settings["settings"] if "core" in item["restarts"]] + external = { + "core": json.dumps({ "core-key-digests.json": sha256(files["core-key-digests.json"]), "native-installers": native_installer_inputs(root), "settings": sha256(json.dumps([settings["path"], settings["apply_command"], core_settings], sort_keys=True)), "runtime-history.json": sha256(files.get("runtime-history.json", "")), "credential.key": secrets["credential.key"], "database.password": secrets["database.password"], - }, sort_keys=True) - if mode != "core-only": - external["web"] = json.dumps({"core.key": secrets["core.key"]}) + }, sort_keys=True), + "web": json.dumps({"core.key": secrets["core.key"]}), + } compose = compose_config(root, config, state, candidate) services = {} for name, service in compose["services"].items(): @@ -367,14 +340,10 @@ def render(root, config, state, applied_at, candidate=None): if ingress_config.enabled(config): files["Caddyfile"] = ingress_config.caddyfile(config, state, candidate) files["compose.json"] = json.dumps(compose, indent=2) + "\n" - if native: - unit = native_service.unit_name(state) - services["core"] = sha256(core_env + native_service.unit_text(root, edit_hint(root)) + external["core"]) - files[unit] = native_service.unit_text(root, edit_hint(root), services["core"]) - return Rendered(files, services, unit) + return Rendered(files, services) -def rendered_inputs(files, unit): +def rendered_inputs(files): """The inputs digest per service that a set of generated files asks for.""" result = {} if files.get("compose.json"): @@ -383,8 +352,4 @@ def rendered_inputs(files, unit): result[name] = service.get("labels", {}).get(LABEL) except (ValueError, KeyError, AttributeError): return {} - if unit and files.get(unit): - text = files[unit].decode() if isinstance(files[unit], bytes) else files[unit] - match = re.search(r"^Environment=OAC_INPUTS=([0-9a-f]{64})$", text, re.M) - result["core"] = match[1] if match else None return result diff --git a/deploy/install/install.py b/deploy/install/install.py index a2405984..e579ad6f 100644 --- a/deploy/install/install.py +++ b/deploy/install/install.py @@ -19,8 +19,6 @@ import secrets import shutil import signal -import socket -import stat import subprocess import sys import tempfile @@ -33,29 +31,20 @@ import ingress_config from configuration import valid_core_origin import native_installers -import native_service import oac_cli import sandbox_setup import install_output import install_display -from install_output import choose_where from install_display import step from distribution import DistributionError, artifact, image_identities, ensure_docker_image SETTING_ARGUMENTS = { config_model.annotation(node, "install_flag"): (key, node) - for key, node, _ in config_model.leaves() - if config_model.annotation(node, "install_flag") and key not in ("mode", "native_core") + for key, node in config_model.leaves() + if config_model.annotation(node, "install_flag") } -SETTING_FLAGS = ("core_only", "web_only", "native_core", *( - flag.removeprefix("--").replace("-", "_") for flag in SETTING_ARGUMENTS)) +SETTING_FLAGS = tuple(flag.removeprefix("--").replace("-", "_") for flag in SETTING_ARGUMENTS) AVOID = 20 # An omitted Core or Web port moves at most this far above its default. -DOCKER_RISKS = """Docker sandboxes isolate less than microsandbox, the default: -- Containers share the node's kernel, so a container escape reaches the host; - microsandbox runs each sandbox in its own microVM. -- Each node's service account is in the docker group, which is root-equivalent - on that host. -- Choose Docker only for trusted workloads or for node hosts without KVM.""" class InstallError(Exception): @@ -112,12 +101,10 @@ def verify_bundle(bundle): if digest(path) != expected: raise InstallError("Distribution checksum mismatch: " + name) required = {"manifest.json", "install.sh", "install.py", "configuration.py", "config_model.py", "ingress_config.py", "ingress.py", - "config.schema.json", "oac_cli.py", "oac.pyz", "native_service.py", + "config.schema.json", "oac_cli.py", "oac.pyz", "sandbox_setup.py", "install_output.py", "install_display.py", "standard-sizes.json", "node_spec.py", "node-install.pyz", "distribution.py", "runtime/seccomp.json"} required.update(f"images/{name}.tar" for name in ("core", "web", "database", "ingress")) - required.update("native/bin/" + name for name in ("oac-core", "oac-core-migrate")) - required.add("native/e2b/oac-e2b-provider") if not required.issubset(covered): raise InstallError("Distribution checksum list is incomplete") manifest = json.loads((bundle / "manifest.json").read_text()) @@ -130,12 +117,6 @@ def verify_bundle(bundle): return manifest -def database_port(): - with socket.socket() as sock: - sock.bind(("127.0.0.1", 0)) - return sock.getsockname()[1] - - def public_origin(value): # Normalize case and a trailing slash, then apply Core's exact origin rule. try: @@ -153,24 +134,10 @@ def public_origin(value): def arguments(argv=None): parser = argparse.ArgumentParser(description=__doc__) - modes = parser.add_mutually_exclusive_group() - modes.add_argument("--core-only", action="store_true", default=None) - modes.add_argument("--web-only", action="store_true", default=None) - parser.add_argument("--native-core", action="store_true", default=None, help="Run Core as a systemd user service") - parser.add_argument("--sandbox", choices=sandbox_setup.CHOICES, - help="Sandbox backend Core starts with, at Web's Standard size (default: microsandbox; " - "none with --web-only). Add nodes afterwards on the Nodes page in Web") - parser.add_argument("--accept-docker-risks", action="store_true", - help="With --sandbox docker: accept its weaker isolation without asking") - parser.add_argument("--e2b-api-key-file", type=Path, help="With --sandbox e2b: private file containing the E2B API key") - parser.add_argument("--e2b-template", help="With --sandbox e2b: the ready template build, template-id:build-uuid") - parser.add_argument("--e2b-api-url", help="With --sandbox e2b: compatible service HTTPS API origin") - parser.add_argument("--e2b-domain", help="With --sandbox e2b: compatible service data-plane domain") parser.add_argument("--install-dir", type=Path) for flag, (_, node) in SETTING_ARGUMENTS.items(): value_type = int if node.get("type") == "integer" else public_origin if config_model.annotation(node, "check") == "origin" else str parser.add_argument(flag, type=value_type, help=node["description"]) - parser.add_argument("--core-key-file", type=Path, help="Web-only: private file containing the existing Core's Core key") parser.add_argument("--config", type=Path, help="Seed a new installation's config.json from this file") args = parser.parse_args(argv) args.install_dir = args.install_dir or Path.home() / ".oac/core" @@ -196,50 +163,17 @@ def seed_document(args): return document -def check_flags(args, document): - """Flag combinations for a new installation, before config.json is seeded.""" - if document is None: - mode, native = ("core-only" if args.core_only else "web-only" if args.web_only else "all"), args.native_core - else: - mode, native = document.get("mode"), document.get("native_core") - if mode == "web-only" and args.sandbox not in (None, "none"): - raise InstallError("--web-only has no Core; choose the sandbox backend on the Core host") - choice = args.sandbox or ("none" if mode == "web-only" else "microsandbox") - if args.accept_docker_risks and choice != "docker": - raise InstallError("--accept-docker-risks requires --sandbox docker") - if choice == "e2b" and not (args.e2b_api_key_file and args.e2b_template): - raise InstallError("--sandbox e2b requires --e2b-api-key-file and --e2b-template") - if choice != "e2b" and (args.e2b_api_key_file or args.e2b_template or args.e2b_api_url or args.e2b_domain): - raise InstallError("E2B flags require --sandbox e2b") - if choice == "e2b" and not sandbox_setup.e2b_template(args.e2b_template): - raise InstallError("--e2b-template must name a template build as template-id:build-uuid") - if choice == "e2b" and not sandbox_setup.e2b_endpoint(args.e2b_api_url, args.e2b_domain): - raise InstallError("--e2b-api-url and --e2b-domain must both name public HTTPS compatible-service endpoints") - if mode == "web-only" and native: - raise InstallError("--web-only cannot install native Core") - if mode == "web-only" and not args.core_key_file: - raise InstallError("--web-only requires --core-key-file (and --core-url, or web.core_url in --config)") - if mode != "web-only" and args.core_key_file: - raise InstallError("--core-key-file requires --web-only") - return choice - - def seed_config(args, document): """config.json for a new installation, from flags or from --config.""" if document is not None: document.setdefault("$schema", "generated/config.schema.json") - if document.get("native_core"): - document.setdefault("ports", {}).setdefault("database", database_port()) return config_model.validate(document) - mode = "core-only" if args.core_only else "web-only" if args.web_only else "all" - native = bool(args.native_core) values = {key: getattr(args, flag.removeprefix("--").replace("-", "_")) for flag, (key, _) in SETTING_ARGUMENTS.items()} - values["ingress"] = values["ingress"] or ("managed" if mode == "all" and not native else "external") + values["ingress"] = values["ingress"] or "managed" if values["ingress"] == "managed" and values["host"] is None: values["host"] = "0.0.0.0" - values["ports.database"] = database_port() if native else None - return config_model.initial(mode, native, **values) + return config_model.initial(**values) def check_listeners(args, document, config): @@ -304,58 +238,6 @@ def nodes_reach(public_url): return urlsplit(public_url or "").scheme == "https" and not loopback_origin(public_url) -def confirm_docker(accepted): - """Docker sandboxes need an explicit yes to their weaker isolation, before anything is created.""" - print(DOCKER_RISKS, flush=True) - if accepted: - return - try: - answer = input("Use Docker sandboxes anyway? [y/N] ").strip().lower() if sys.stdin.isatty() else "" - except EOFError: - answer = "" - if answer not in ("y", "yes"): - raise InstallError("Docker sandboxes were not confirmed; nothing was installed. Rerun with " - "--accept-docker-risks, or without --sandbox for microsandbox") - - -def check_public_url(config, choice): - # E2B's sandboxes reach Core from E2B's cloud; Core would refuse the selection. - if choice == "e2b" and not nodes_reach(config["public_url"]): - raise InstallError("E2B needs an HTTPS public_url that is not loopback (--public-url, or public_url " - "in the --config file)") - - -def read_private_file(source, name, limit=4096): - """The one token in an absolute, private regular file of at most 4 KiB, never through a symlink.""" - refused = InstallError(f"{name} must be an absolute, private regular file of at most 4 KiB") - if not source.is_absolute(): - raise refused - try: - # O_NONBLOCK: a FIFO in its place must not hang the installer. - descriptor = os.open(source, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) - except OSError: - raise refused from None - with os.fdopen(descriptor, "rb") as stream: - info = os.fstat(descriptor) - if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) & 0o077 or info.st_size > limit: - raise refused - raw = stream.read(limit + 1) - try: - token = raw.decode().strip() - except UnicodeDecodeError: - token = "" - if len(raw) > limit or not token or any(c.isspace() for c in token) or "\x00" in token: - raise InstallError("Invalid " + name) - return token - - -def read_core_key_file(source): - token = read_private_file(source, "Core key file") - if len(token) < 32: - raise InstallError("The Core key must have at least 32 characters") - return token - - def check_compose(): try: version = run(["docker", "compose", "version", "--short"], capture_output=True, text=True, @@ -378,11 +260,8 @@ def check_host(): "account's Docker access; the installer does not require root or invoke sudo") from None -def image_names(mode, native, managed=False): - if mode == "web-only": - return ["web"] - names = ["database"] if native else ["core", "database"] - return names + (["web"] if mode == "all" else []) + (["ingress"] if managed else []) +def image_names(managed=False): + return ["core", "database", "web"] + (["ingress"] if managed else []) def image_loader(manifest, bundle): @@ -398,8 +277,6 @@ def load(names): def prepare_node_payload(root, state, bundle): - if state["mode"] == "core-only": - return destination = root / "node-payload" # Each release remains immutable and addressable while old nodes retain it. # The only mutable publication is a small, atomically replaced active pointer. @@ -568,32 +445,30 @@ def remove_created(root, state, created): return NOTHING_KEPT -def create(root, args, config, manifest, images): +def create(root, config, manifest, images): """Write the new installation's state.json, secrets and config.json, in that order.""" - mode = config["mode"] - token = read_core_key_file(args.core_key_file) if mode == "web-only" else secrets.token_hex(32) + token = secrets.token_hex(32) if root.parent == Path.home() / ".oac": oac_cli.private_parent(root) root.mkdir(mode=0o700, parents=True, exist_ok=True) os.chmod(root, 0o700) state = {"format": 2, "installation_id": str(uuid.uuid4()), "project": "oac-" + secrets.token_hex(5), - "uid": os.getuid(), "gid": os.getgid(), "mode": mode, "native_core": config.get("native_core", False), + "uid": os.getuid(), "gid": os.getgid(), "source_commit": manifest["source_commit"], "images": images, "secrets_sha256": {}, - "core_installation_id": None, "generated": {}, "complete": False} + "generated": {}, "complete": False} if ingress_config.enabled(config): state["ingress"] = ingress_config.preflight() # state.json first, written whole: it marks everything after it as this installation's. oac_cli.save_state(root, state) - for name in ["secrets", "generated"] + ([] if mode == "web-only" else ["state", "state/e2b"]): + for name in ("secrets", "generated", "state", "state/e2b"): (root / name).mkdir(mode=0o700) write = oac_cli.create_private write(root / "secrets/core.key", token) - if mode != "web-only": - write(root / "secrets/credential.key", base64.b64encode(secrets.token_bytes(32)).decode()) - write(root / "secrets/database.password", secrets.token_hex(32)) + write(root / "secrets/credential.key", base64.b64encode(secrets.token_bytes(32)).decode()) + write(root / "secrets/database.password", secrets.token_hex(32)) if ingress_config.enabled(config): ingress_config.prepare(root) - digests = configuration.secret_digests(root, mode) + digests = configuration.secret_digests(root) digests.pop("core.key") oac_cli.save_state(root, dict(state, secrets_sha256=digests)) # config.json last: whenever it exists, the installation can be repaired. @@ -605,12 +480,11 @@ def finish(root, bundle, manifest, fresh=False, selection=None, moved=()): state = oac_cli.load_state(root) step("Preparing service files") prepare_node_payload(root, state, bundle) - native_service.prepare(root, state, bundle) native_installers.prepare(root, state, bundle) install_oac(root, bundle) if ingress_config.enabled(oac_cli.load_config(root)): ingress_config.prepare(root) - args = argparse.Namespace(dry_run=False, yes=False, confirm_public_url_change=None) + args = argparse.Namespace(dry_run=False, confirm_public_url_change=None) step("Applying settings and starting services as needed") try: oac_cli._apply(root, args, False, True, sys.stdin.isatty(), @@ -621,14 +495,8 @@ def finish(root, bundle, manifest, fresh=False, selection=None, moved=()): # The installer removes what it created and says how to retry. raise InstallError(f"The services did not start: {error.cause}") from None config = oac_cli.load_config(root) - mode = config["mode"] - if mode == "web-only": - step("Checking Core connection and authentication") - if mode == "web-only" and oac_cli.paired_core(root, config)[0] != 200: - where = "--core-key-file and the Core URL" if fresh else "secrets/core.key and web.core_url" - raise InstallError(f"Core key authentication failed. Inspect {where}; no model was called") if fresh: - # The first start finished, Web-only reaching its Core with the key: from now on the installation is kept. + # The first start finished: from now on the installation is kept. oac_cli.save_state(root, dict(oac_cli.load_state(root), complete=True)) deployment = failure = None if selection: @@ -640,26 +508,24 @@ def finish(root, bundle, manifest, fresh=False, selection=None, moved=()): summary(root, config, fresh, selection, deployment, incomplete=failure is not None, moved=moved) if failure: raise InstallError(f"{str(failure).rstrip('.')}. Services are installed and running; " - f"choose the sandbox backend {choose_where(mode)}") + "choose the sandbox backend on the Nodes page in Web") def summary(root, config, fresh, selection=None, deployment=None, incomplete=False, moved=()): - mode, public_url, ports = config["mode"], config["public_url"], config["ports"] + public_url, ports = config["public_url"], config["ports"] addresses = [] - if mode != "core-only": - # Web accepts only its configured origin. - console = ingress_config.console_origin(config) if ingress_config.enabled(config) else configuration.web_origin(config) - addresses.append("Console: " + console + (" (local only)" if loopback_origin(console) else "")) - if mode != "web-only": - api = configuration.service_origin(config, "core") + "/v1" - if public_url and not loopback_origin(public_url): - label = "Local-only API on this host: " if configuration.loopback_listener(config["host"]) else "Direct API on this host: " - addresses += ["API base URL: " + public_url + "/v1", label + api] - elif public_url and origin_port(public_url) != ports.get("web"): - addresses.append("API base URL: " + public_url + "/v1 (local only)") - else: - # The loopback Web port does not serve the public API. - addresses.append("API base URL: " + api + " (local only)") + # Web accepts only its configured origin. + console = ingress_config.console_origin(config) if ingress_config.enabled(config) else configuration.web_origin(config) + addresses.append("Console: " + console + (" (local only)" if loopback_origin(console) else "")) + api = configuration.service_origin(config, "core") + "/v1" + if public_url and not loopback_origin(public_url): + label = "Local-only API on this host: " if configuration.loopback_listener(config["host"]) else "Direct API on this host: " + addresses += ["API base URL: " + public_url + "/v1", label + api] + elif public_url and origin_port(public_url) != ports.get("web"): + addresses.append("API base URL: " + public_url + "/v1 (local only)") + else: + # The loopback Web port does not serve the public API. + addresses.append("API base URL: " + api + " (local only)") install_output.summary(root, config, addresses, fresh, selection, deployment, nodes_reach(public_url), incomplete, moved) @@ -699,21 +565,9 @@ def check_release(root, manifest): def prepare_fresh(args): document = seed_document(args) - choice = check_flags(args, document) config = seed_config(args, document) - check_public_url(config, choice) config, moved = check_listeners(args, document, config) - if config["mode"] == "web-only": - key = read_core_key_file(args.core_key_file) - if oac_cli.core_installation(config["web"]["core_url"], key)[0] == 404: - raise InstallError("The paired Core version is not supported; preserve its data and reinstall " - "the current release separately. Nothing was changed.") - e2b = ({"api_key": read_private_file(args.e2b_api_key_file, "E2B API key file"), "template": args.e2b_template, - **({"api_url": args.e2b_api_url, "domain": args.e2b_domain} if args.e2b_api_url else {})} - if choice == "e2b" else None) - if choice == "docker": - confirm_docker(args.accept_docker_risks) - return config, choice, e2b, moved + return config, moved def install_locked(args, root, bundle, manifest, prepared, created): @@ -723,13 +577,8 @@ def install_locked(args, root, bundle, manifest, prepared, created): raise InstallError(f"This installation is configured by {root / 'config.json'}. Edit it and run " f"{root / 'oac'} apply; install.sh accepts only --install-dir to repair it") state = oac_cli.load_state(root) - if state["mode"] == "web-only" and oac_cli.paired_core(root, oac_cli.load_config(root))[0] == 404: - raise InstallError("The paired Core version is not supported; preserve its data and reinstall " - "the current release separately. Nothing was changed.") step("Checking host requirements for repair") check_host() - if native_service.is_native(state): - native_service.preflight(bundle, root) images = image_loader(manifest, bundle)(list(state["images"])) if images != state["images"]: oac_cli.save_state(root, dict(state, images=images)) @@ -763,17 +612,15 @@ def install_locked(args, root, bundle, manifest, prepared, created): def install_fresh(args, root, bundle, manifest, prepared): """Check the host, load images, create the installation and start it for the first time.""" - config, choice, e2b, moved = prepared + config, moved = prepared step("Checking host requirements") check_host() - if config.get("native_core"): - native_service.preflight(bundle, root) if ingress_config.enabled(config): ingress_config.preflight() - selection = None if choice == "none" else sandbox_setup.selection(bundle, manifest, choice, e2b) - images = image_loader(manifest, bundle)(image_names(config["mode"], config.get("native_core", False), ingress_config.enabled(config))) + selection = sandbox_setup.selection(bundle, manifest, "microsandbox") + images = image_loader(manifest, bundle)(image_names(ingress_config.enabled(config))) step("Creating installation settings and credentials") - create(root, args, config, manifest, images) + create(root, config, manifest, images) finish(root, bundle, manifest, fresh=True, selection=selection, moved=moved) diff --git a/deploy/install/install_output.py b/deploy/install/install_output.py index 6f192d5c..40bb30e2 100644 --- a/deploy/install/install_output.py +++ b/deploy/install/install_output.py @@ -2,14 +2,12 @@ import shlex import sandbox_setup -import configuration import ingress_config from install_display import color, heading, paragraph -def choose_where(mode): - return ("on the Nodes page in Web" if mode == "all" else - "through the Core management API (POST /core/v1/sandbox/deployment)") +def choose_where(): + return "on the Nodes page in Web" def size(resources): @@ -19,7 +17,7 @@ def size(resources): def sandbox_lines(config, selection, deployment, reachable): if selection is None: - return [f"Sandboxes: none chosen. Choose a sandbox backend {choose_where(config['mode'])}."] + return [f"Sandboxes: none chosen. Choose a sandbox backend {choose_where()}."] if deployment is None: return [] if selection["provider"] == "e2b": @@ -33,13 +31,12 @@ def sandbox_lines(config, selection, deployment, reachable): lines.append("Before adding nodes, configure a reachable HTTPS address" + (" in Web under System → Domain and HTTPS." if ingress_config.enabled(config) else " with your reverse proxy, set public_url in config.json, then run the Apply command below.")) - add = "in Web, open Nodes and choose Add node" if config["mode"] == "all" else "in a Web console paired with this Core, open Nodes and choose Add node" + add = "in Web, open Nodes and choose Add node" lines.append(f"Add nodes: {add}, then run the command on each execution host.") return lines def summary(root, config, addresses, fresh, selection, deployment, reachable, incomplete, moved=()): - mode = config["mode"] status = ("Services are running; sandbox setup needs attention." if incomplete else "Installation complete." if fresh else "Installation settings checked. Use Status below to inspect service health.") print("\n" + color(status, "33" if incomplete else "32")) @@ -48,22 +45,14 @@ def summary(root, config, addresses, fresh, selection, deployment, reachable, in print(" " + address) for purpose, taken, port in moved: print(f" Port {taken} was in use; {purpose} uses {port}.") - heading("Sign in" if mode != "core-only" else "Authentication") + heading("Sign in") print(f" Core key file: {root / 'secrets/core.key'}") - if mode != "core-only": - paragraph("Use this key to sign in to Web. Keep it private.") - else: - paragraph("Use this key for the Core management API. Keep it private.") + paragraph("Use this key to sign in to Web. Keep it private.") heading("Next") if ingress_config.enabled(config) and not config["public_url"]: paragraph("Open Web at the server IP and sign in. In System → Domain and HTTPS, enter your DNS hostname; the installation requests and renews its certificate. HTTPS then uses ports 80 and 443: DNS must point to this server, no other program on it may use those ports, and they must be reachable from the internet. Web checks DNS and the ports before it starts.") - if mode != "core-only": - paragraph("Create a Project and its API key on the Projects and keys page.") - else: - paragraph("Create a Project and its API key through the Core management API:") - local = " (local only)" if configuration.loopback_listener(config["host"]) else "" - print(f' {configuration.service_origin(config, "core")}/core/v1{local}') - if fresh and mode != "web-only": + paragraph("Create a Project and its API key on the Projects and keys page.") + if fresh: for line in sandbox_lines(config, selection, deployment, reachable): paragraph(line) heading("Manage") diff --git a/deploy/install/installer_fakes.py b/deploy/install/installer_fakes.py index ff53ff45..a79471f7 100644 --- a/deploy/install/installer_fakes.py +++ b/deploy/install/installer_fakes.py @@ -16,7 +16,6 @@ from types import SimpleNamespace from unittest import mock -import native_service import oac_cli import sandbox_setup @@ -58,7 +57,6 @@ def __init__(self, test): (oac_cli, "tcp_listeners", lambda: [(ipaddress.ip_address(host), port) for host, port in self.listening()]), (oac_cli, "time", SimpleNamespace(sleep=lambda seconds: None)), - (native_service, "_process_environment", self.process_environment), (sandbox_setup, "send", self.sandbox_send)): patcher = mock.patch.object(target, name, value) patcher.start() @@ -320,7 +318,7 @@ def sandbox_send(self, req): "microsandbox": {"runtime_sha256": "5" * 64, "firmware_sha256": "6" * 64}, } MODULES = ("install.py", "install_output.py", "install_display.py", "configuration.py", "config_model.py", "ingress.py", "ingress_config.py", "config.schema.json", "oac_cli.py", - "native_service.py", "sandbox_setup.py", "node_spec.py", "distribution.py", "install.sh") + "sandbox_setup.py", "node_spec.py", "distribution.py", "install.sh") def write_checksums(bundle): @@ -352,9 +350,8 @@ def make_bundle(directory, manifest, commit=None): (bundle / "manifest.json").write_text(json.dumps(manifest)) for name in manifest["images"]: (bundle / "images" / (name + ".tar")).write_bytes(("synthetic " + name).encode()) - for name in ("bin/oac-core", "bin/oac-core-migrate", "bin/oac-microsandbox-provider", - "bin/oac-node", "microsandbox/msb", "microsandbox/libkrunfw.so.5.6.1", - "e2b/oac-e2b-provider"): + for name in ("bin/oac-microsandbox-provider", + "bin/oac-node", "microsandbox/msb", "microsandbox/libkrunfw.so.5.6.1"): path = bundle / "native" / name path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(b"\x7fELFsynthetic native file " + manifest["source_commit"].encode()) @@ -366,6 +363,5 @@ def run_installer(install, bundle, argv): """install.main from the bundle on a Linux amd64 host.""" with mock.patch.object(install, "__file__", str(bundle / "install.py")), \ mock.patch.object(install.platform, "system", return_value="Linux"), \ - mock.patch.object(install.platform, "machine", return_value="x86_64"), \ - mock.patch.object(install.native_service.platform, "system", return_value="Linux"): + mock.patch.object(install.platform, "machine", return_value="x86_64"): return install.main([str(item) for item in argv]) diff --git a/deploy/install/model_provider_sessions.py b/deploy/install/model_provider_sessions.py deleted file mode 100644 index 0bca6177..00000000 --- a/deploy/install/model_provider_sessions.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python3 -"""Count hosted and self-hosted Sessions that have no frozen model provider. - -Read-only. Run it against an existing installation before upgrading to a Core that -stores deployment model providers itself: these Sessions cannot start new work -afterwards and must be recreated with x_agents_core.model_provider or an Agent that -has one saved. Cancelling their work and reading their history keep working. -Historical none Sessions are not counted: without a frozen provider they run with -their device's own environment. -""" -import argparse -import json -from pathlib import Path -import subprocess -import sys - -ENVIRONMENTS = ("openai_hosted", "self_hosted") -# A Session froze a provider when Core marked its configuration at creation. -QUERY = """SELECT configuration->'environment'->>'type', count(*) -FROM sessions -WHERE deleted_at IS NULL - AND configuration->'environment'->>'type' IN ('openai_hosted', 'self_hosted') - AND COALESCE(configuration->>'model_provider_configured', '') <> 'true' -GROUP BY 1 ORDER BY 1""" - - -class CheckError(Exception): - pass - - -def count(root): - """Return {environment: count} for Sessions without a frozen provider.""" - # An installation made with config.json keeps its Compose file under generated/. - compose = next((path for path in (Path(root) / "generated/compose.json", Path(root) / "compose.json") - if path.exists()), Path(root) / "compose.json") - try: - services = json.loads(compose.read_text())["services"] - except (OSError, ValueError, KeyError, TypeError): - raise CheckError("Cannot read " + str(compose) + "; pass the installation directory with --install-dir") from None - if "database" not in services: - raise CheckError("This installation has no Core database (Web-only); run the check on the Core installation") - command = ["docker", "compose", "-f", str(compose), "exec", "-T", - "-e", "PGOPTIONS=-c default_transaction_read_only=on", "database", - "psql", "-X", "-q", "-A", "-t", "-F", "|", "-v", "ON_ERROR_STOP=1", - "-U", "agents_api", "-d", "agents_api", "-c", QUERY] - try: - result = subprocess.run(command, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=60, check=False) - except (OSError, subprocess.SubprocessError): - raise CheckError("Cannot run the database query; is Docker available?") from None - if result.returncode: - raise CheckError("The database query failed; start the installation and retry") - counts = dict.fromkeys(ENVIRONMENTS, 0) - for line in result.stdout.splitlines(): - if not line.strip(): - continue - environment, _, value = line.partition("|") - if environment not in counts or not value.isdigit(): - raise CheckError("Unexpected database output") - counts[environment] = int(value) - return counts - - -def main(argv=None): - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--install-dir", type=Path, default=Path.home() / ".oac/core") - parser.add_argument("--json", action="store_true", help="print {environment: count} as JSON") - args = parser.parse_args(argv) - counts = count(args.install_dir) - if args.json: - print(json.dumps(counts, sort_keys=True)) - return - if not any(counts.values()): - print("Every hosted and self-hosted Session has a frozen model provider.") - return - print("Sessions without a frozen model provider (they cannot start new work after the upgrade):") - for environment in ENVIRONMENTS: - print(f" {environment}: {counts[environment]}") - print("Recreate them with x_agents_core.model_provider or an Agent that has one saved. " - "Cancelling their work and reading their history keep working.") - - -if __name__ == "__main__": - try: - main() - except CheckError as error: - raise SystemExit(str(error)) from None diff --git a/deploy/install/native_installers.py b/deploy/install/native_installers.py index bbc01820..5a7194ef 100644 --- a/deploy/install/native_installers.py +++ b/deploy/install/native_installers.py @@ -12,8 +12,6 @@ def prepare(root, state, bundle): - if state["mode"] == "web-only": - return source, target = Path(bundle) / "native-installers", Path(root) / "native-installers" if not source.exists(): return diff --git a/deploy/install/native_service.py b/deploy/install/native_service.py deleted file mode 100644 index 2d10b246..00000000 --- a/deploy/install/native_service.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Install the native Core user service independently of execution nodes.""" - -import hashlib -import os -from pathlib import Path -import platform -import re -import shutil -import subprocess -import tempfile - - -REQUIRED = ("bin/oac-core", "bin/oac-core-migrate", "e2b/oac-e2b-provider") - - -def is_native(state): - return state["mode"] != "web-only" and state["native_core"] - - -def unit_name(state): - project = state.get("project", "") - if not isinstance(project, str) or not re.fullmatch(r"oac-[0-9a-f]{10}", project): - raise RuntimeError("Native Core requires its installation's generated project name") - return project + "-core.service" - - -def _path(value): - path = Path(value) - text = str(path) - # EnvironmentFile accepts glob patterns; systemd executable paths reject - # quote characters even when ExecStart quotes/escapes the entire word. - if (not path.is_absolute() or path.resolve() != path or text != text.strip() - or any(ord(char) < 32 or ord(char) == 127 for char in text) - or any(char in text for char in "\\*?[]\"'")): - raise RuntimeError("Native Core paths must be canonical absolute paths without control characters, quotes, backslashes or wildcards") - return path - - -def _run(arguments, failure): - try: - return subprocess.run(arguments, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, text=True, timeout=30, check=False) - except (OSError, subprocess.SubprocessError): - raise RuntimeError(failure) from None - - -def _checked(arguments, failure): - result = _run(arguments, failure) - if result.returncode: - raise RuntimeError(failure) - return result.stdout.strip() - - -def _files(native): - if native.is_symlink() or not native.is_dir(): - raise RuntimeError("The distribution is missing its native Core payload") - files = {} - for path in native.rglob("*"): - if path.is_symlink() or not (path.is_dir() or path.is_file()): - raise RuntimeError("The distribution requires regular native Core executables") - name = str(path.relative_to(native)) - if name in REQUIRED and path.is_file(): - files[name] = path - if not set(REQUIRED).issubset(files): - raise RuntimeError("The distribution is missing a required native Core executable") - return files - - -def preflight(bundle, root): - """Check host prerequisites without launching a helper operation or VM.""" - _path(root) - if platform.system() != "Linux": - raise RuntimeError("Native Core installation requires Linux") - _checked(["systemctl", "--user", "show", "--property=Version", "--value"], - "The systemd user manager is unavailable; establish a user session before installation") - linger = _checked(["loginctl", "show-user", str(os.getuid()), "--property=Linger", "--value"], - "Cannot check user lingering; ask the host administrator to configure it") - if linger != "yes": - raise RuntimeError("User lingering must be enabled by the host administrator before native Core installation") - try: - native = _path(bundle) / "native" - files = _files(native) - for name in REQUIRED: - with files[name].open("rb") as stream: - if stream.read(4) != b"\x7fELF": - raise RuntimeError("Native Core payload must contain Linux ELF binaries") - result = _run(["ldd", str(files[name])], "Cannot check native Core shared libraries") - diagnostic = result.stdout + result.stderr - static = "statically linked" in diagnostic or "not a dynamic executable" in diagnostic - if "not found" in diagnostic or (result.returncode and not static): - raise RuntimeError("Native Core shared libraries cannot load on this host; install the required host libraries") - except OSError: - raise RuntimeError("Cannot inspect the native Core distribution") from None - - -def _digest(path): - digest = hashlib.sha256() - with path.open("rb") as stream: - for block in iter(lambda: stream.read(1024 * 1024), b""): - digest.update(block) - return digest.digest() - - -def prepare(root, state, bundle): - """Install or repair matching native Core binaries without replacing another version.""" - if not is_native(state): - return - try: - root, bundle = _path(root), _path(bundle) - unit_name(state) - source, target = bundle / "native", root / "native" - incoming = _files(source) - if target.exists() or target.is_symlink(): - installed = _files(target) - if incoming.keys() != installed.keys() or any(_digest(path) != _digest(installed[name]) for name, path in incoming.items()): - raise RuntimeError("Installed native Core files differ; preserve the installation and follow the upgrade guide") - if not target.exists(): - root.mkdir(parents=True, mode=0o700, exist_ok=True) - with tempfile.TemporaryDirectory(prefix=".native-", dir=root) as temporary: - staged = Path(temporary) / "native" - for name, path in incoming.items(): - destination = staged / name - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(path, destination) - os.replace(staged, target) - for path in [target, target / "bin", target / "e2b", *(target / name for name in REQUIRED)]: - os.chmod(path, 0o700) - except (OSError, UnicodeError): - raise RuntimeError("Cannot prepare private native Core files; existing state was not removed") from None - - -def unit_text(root, header, inputs=None): - """The unit. OAC_INPUTS carries the inputs digest the running Core started with.""" - root = _path(root) - # ':' disables command-line environment substitution. The executable is - # still Core itself; no shell, wrapper or provider shutdown hook is used. - executable = str(root / "native/bin/oac-core").replace("%", "%%").replace('"', '\\"') - return ("# " + header + "\n" - + "[Unit]\nDescription=OpenAgentCore\n\n[Service]\nType=exec\n" - + 'ExecStart=:"' + executable + '"\n' - + "WorkingDirectory=" + str(root).replace("%", "%%") + "\n" - + "EnvironmentFile=" + str(root / "generated/core.env").replace("%", "%%") + "\n" - + ("Environment=OAC_INPUTS=" + inputs + "\n" if inputs else "") - + "Restart=on-failure\nKillMode=process\nUMask=0077\n\n[Install]\nWantedBy=default.target\n") - - -def daemon_reload(): - _checked(["systemctl", "--user", "daemon-reload"], "Cannot reload the systemd user manager") - - -def start(root, state): - """Enable the generated unit by path and start it.""" - if not is_native(state): - return - unit = _path(root) / "generated" / unit_name(state) - if unit.is_symlink() or not unit.is_file(): - raise RuntimeError("Native Core service must be generated before starting it; run oac apply") - daemon_reload() - _checked(["systemctl", "--user", "enable", "--now", str(unit)], "Cannot enable or start this installation's native Core service") - - -def restart(state): - if is_native(state): - _checked(["systemctl", "--user", "restart", unit_name(state)], "Cannot restart this installation's native Core service") - - -def stop(root, state): - if is_native(state): - _path(root) - _checked(["systemctl", "--user", "stop", unit_name(state)], "Cannot stop this installation's native Core service") - - -def remove(state): - """Stop and disable the unit, which removes the links enable made; a unit never enabled is already gone.""" - if not is_native(state): - return - unit = unit_name(state) - # disable fails for a unit that was never enabled; its load state below decides. - _run(["systemctl", "--user", "disable", "--now", unit], "Cannot remove this installation's native Core service") - daemon_reload() - loaded = _checked(["systemctl", "--user", "show", "--property=LoadState", "--value", unit], - "Cannot query this installation's native Core service") - if loaded != "not-found" or active(state): - raise RuntimeError("Cannot remove this installation's native Core service") - - -def _process_environment(pid): - try: - raw = Path(f"/proc/{pid}/environ").read_bytes() - except OSError: - return {} - return dict(item.split("=", 1) for item in raw.decode(errors="replace").split("\0") if "=" in item) - - -def running_inputs(state): - """OAC_INPUTS of the running Core process, or None when it is not running.""" - if not is_native(state): - return None - result = _run(["systemctl", "--user", "show", "--property=MainPID", "--value", unit_name(state)], - "Cannot query this installation's native Core service") - pid = result.stdout.strip() - if result.returncode or not pid.isdigit() or pid == "0": - return None - return _process_environment(int(pid)).get("OAC_INPUTS") - - -def active(state): - if not is_native(state): - return False - result = _run(["systemctl", "--user", "is-active", "--quiet", unit_name(state)], - "Cannot query this installation's native Core service") - return result.returncode == 0 diff --git a/deploy/install/oac_cli.py b/deploy/install/oac_cli.py index 238f0840..0c892330 100644 --- a/deploy/install/oac_cli.py +++ b/deploy/install/oac_cli.py @@ -4,8 +4,8 @@ was installed from is never needed. config.json is the only file an operator edits; apply renders generated/ from it and converges the running services on that render. What runs is the truth: each Compose container carries the inputs digest it was -created with (label io.oac.inputs) and native Core carries it in OAC_INPUTS, -so an interrupted apply, rotation or rollback is finished by the next apply. +created with (label io.oac.inputs), so an interrupted apply, rotation or +rollback is finished by the next apply. """ import argparse import contextlib @@ -35,7 +35,6 @@ import config_model import ingress_config import configuration -import native_service SOURCE_COMMIT = None # Set by the packaged entrypoint from its build revision. @@ -195,7 +194,7 @@ def compose(root, *args, **kwargs): def observe(state): - """{service: {running, inputs, health}} of this installation's containers and native Core.""" + """{service: {running, inputs, health}} of this installation's containers.""" result = {} ids = run(["docker", "ps", "-aq", "--filter", f'label=com.docker.compose.project={state["project"]}', "--filter", "label=com.docker.compose.oneoff=False"], capture_output=True, text=True).stdout.split() @@ -204,9 +203,6 @@ def observe(state): service, inputs, status, health = (line.split("\t") + ["", "", "", ""])[:4] if service: result[service] = {"running": status == "running", "inputs": inputs or None, "health": health} - if native_service.is_native(state): - inputs = native_service.running_inputs(state) - result["core"] = {"running": inputs is not None or native_service.active(state), "inputs": inputs, "health": ""} return result @@ -283,57 +279,35 @@ def stale(actual, desired, will_run): not actual.get(name, {}).get("running") or actual[name]["inputs"] != desired.get(name))} -def migrate_native(root): - environment = configuration.read_environment((root / "generated/core.env").read_text()) - run([str(root / "native/bin/oac-core-migrate")], env=dict(os.environ, **environment), - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - - def converge(root, state, desired, will_run, force=()): """Bring every service in will_run to the desired inputs; Core first, then the rest. Compose recreates exactly the containers whose configuration (and so label) - differs; native Core restarts when its running OAC_INPUTS differ. + differs. """ - native = native_service.is_native(state) actual = observe(state) todo = stale(actual, desired, will_run) | set(force) if not todo: return set() up = ["up", "--detach", "--wait", "--wait-timeout", "300"] if "core" in todo: - if native: - if "database" in will_run and "database" in stale(actual, desired, {"database"}): - compose(root, *up, "database") - native_service.daemon_reload() - if actual.get("core", {}).get("running"): - native_service.restart(state) - else: - migrate_native(root) - native_service.start(root, state) - elif "core" in force and not stale(actual, desired, {"core"}): + if "core" in force and not stale(actual, desired, {"core"}): compose(root, "restart", "core") else: compose(root, *up, "core") - containers = will_run - ({"core"} if native else set()) - if todo - {"core"} or ("core" in todo and not native): - if containers: - compose(root, *up) - for name in sorted(set(force) & containers - {"core"}): - if not stale(actual, desired, {name}): - compose(root, "restart", name) + if will_run: + compose(root, *up) + for name in sorted(set(force) & will_run - {"core"}): + if not stale(actual, desired, {name}): + compose(root, "restart", name) return todo -def core_error_line(root, state): +def core_error_line(root): """Core's single startup failure line. Core logs no environment values.""" try: - if native_service.is_native(state): - output = run(["journalctl", "--user", "--unit", native_service.unit_name(state), "--lines", "200", - "--no-pager", "--output", "cat"], capture_output=True, text=True).stdout - else: - output = compose(root, "logs", "--no-log-prefix", "--tail", "200", "core", - capture_output=True, text=True).stdout + output = compose(root, "logs", "--no-log-prefix", "--tail", "200", "core", + capture_output=True, text=True).stdout except (subprocess.CalledProcessError, OSError): return None lines = [line.strip() for line in output.splitlines() if "oac-core startup failed" in line] @@ -407,24 +381,19 @@ def health(root, config, expected): # Files ------------------------------------------------------------------------- def check_fixed(config, state): - """state.json records mode and native_core at installation; it wins over config.json.""" + """state.json records the ingress images at installation; it wins over config.json.""" if ingress_config.enabled(config) != ("ingress" in state["images"]): raise OacError("ingress is fixed after installation; install separately to change it") - for key in ("mode", "native_core"): - if config.get(key, False) != state[key]: - raise OacError(f"{key} is fixed after installation ({json.dumps(state[key])}). " - "Install into a new directory to change it; nothing was applied.") -def check_secrets(root, config, state): +def check_secrets(root, state): reasons = {"credential.key": "Stored credentials can only be read with the original key.", "database.password": "PostgreSQL keeps the password it was initialized with."} - if config["mode"] != "web-only": - for name, reason in reasons.items(): - data = read_private(root / "secrets" / name, "secrets/" + name) - if configuration.sha256(data) != state["secrets_sha256"][name]: - raise OacError(f"secrets/{name} changed since installation. {reason} " - "Restore the original file; nothing was applied.") + for name, reason in reasons.items(): + data = read_private(root / "secrets" / name, "secrets/" + name) + if configuration.sha256(data) != state["secrets_sha256"][name]: + raise OacError(f"secrets/{name} changed since installation. {reason} " + "Restore the original file; nothing was applied.") check_private(root / "secrets/core.key", "secrets/core.key") configuration.read_core_key(root) @@ -512,7 +481,7 @@ def render_now(root, config, state, candidate=None): def old_public_url(root, config, previous, disk, actual): """The public URL things are bound to: Core's own answer, else the written core.env.""" - old_config = applied_view(config, previous) if previous else config + old_config = applied_view(previous) if previous else config base = core_base(old_config) if actual.get("core", {}).get("running"): status, body = http(base + "/core/v1/installation", bearer(configuration.read_core_key(root))) @@ -572,49 +541,13 @@ def confirm_public_url(root, config, old, base, core_answered, args, interactive raise OacError(f"Confirm with --confirm-public-url-change {new}; nothing was applied") -def paired_core(root, config): - """(HTTP status, installation ID) of the Core that web.core_url reaches.""" - return core_installation(config["web"]["core_url"], configuration.read_core_key(root)) - - -def core_installation(origin, key): - """Read Core identity with an already validated private key, before or after install.""" - status, body = http(origin + "/core/v1/installation", bearer(key)) - return status, (json.loads(body).get("installation_id") if status == 200 else None) - - -def check_paired_core(root, config, state, previous, args, interactive, out): - """Web-only: detect a web.core_url that now reaches a different Core.""" - if args.dry_run and previous is not None and previous.get("web.core_url") != config["web"]["core_url"]: - # The Core key goes only to a Core that apply is asked to use. - out("web.core_url changes; apply checks which Core it reaches.") - return state.get("core_installation_id") - status, installation = paired_core(root, config) - if status == 401 and state.get("complete") is True: - out("Warning: Core rejects this Web host's Core key; the key is out of date. " - "Copy secrets/core.key from the Core host, then run oac apply.") - elif status == 404: - raise OacError("The paired Core version is not supported; preserve its data and reinstall " - "the current release separately. Nothing was applied.") - if status != 200: - return state.get("core_installation_id") - recorded = state.get("core_installation_id") - if recorded and installation != recorded: - out(f"web.core_url reaches Core installation {installation}, not the paired installation {recorded}.") - if args.dry_run: - out("Applying this change needs confirmation.") - elif not (args.yes or (interactive and input("Type yes to pair Web with this Core: ").strip() == "yes")): - raise OacError("Pairing Web with a different Core was not confirmed; nothing was applied") - return installation - - def own_listeners(config, previous, disk, actual): """(address, port) of this installation's listeners: those of the settings last written, and those of the gateway, which domain setup widens before public_url changes, while it runs as written.""" - applied = applied_view(config, previous) + applied = applied_view(previous) own = {(ipaddress.ip_address(listener.host), listener.port) for listener in configuration.listeners(applied)} gateway = actual.get("gateway", {}) - if gateway.get("running") and gateway.get("inputs") == configuration.rendered_inputs(disk, None).get("gateway"): + if gateway.get("running") and gateway.get("inputs") == configuration.rendered_inputs(disk).get("gateway"): own |= ingress_config.written_listeners(disk.get("compose.json")) return own @@ -630,7 +563,7 @@ def check_new_listeners(config, previous, disk, actual, candidate=None): """Each listener this change adds must be free; this installation's own listeners do not count.""" if previous is None: return - applied = applied_view(config, previous) + applied = applied_view(previous) if config["host"] != applied["host"] and not address_available(config["host"]): raise OacError(f"{config['host']} (host) is not an address of this machine; use one of its addresses. " "Nothing was applied.") @@ -655,10 +588,10 @@ def finish_apply(root, config, state, gateway_document, will_run, candidate=None "public_url": config["public_url"], "target_url": config["public_url"], "message": None}) -def apply(root, dry_run=False, yes=False, discard_edits=False, confirm_public_url_change=None, +def apply(root, dry_run=False, discard_edits=False, confirm_public_url_change=None, start=False, interactive=None, out=print, retry=None): root = Path(root) - args = argparse.Namespace(dry_run=dry_run, yes=yes, confirm_public_url_change=confirm_public_url_change) + args = argparse.Namespace(dry_run=dry_run, confirm_public_url_change=confirm_public_url_change) interactive = sys.stdin.isatty() if interactive is None else interactive with locked(root): check_complete(load_state(root)) @@ -672,7 +605,7 @@ def _apply(root, args, discard_edits, start, interactive, out, rollback=True, re config = load_config(root) state = load_state(root) check_fixed(config, state) - check_secrets(root, config, state) + check_secrets(root, state) rendered, disk, previous = render_now(root, config, state, candidate) edited = edited_files(state, disk, rendered) if edited and not discard_edits: @@ -688,18 +621,15 @@ def _apply(root, args, discard_edits, start, interactive, out, rollback=True, re will_run = (set(rendered.services) - {"migrate"}) if (start or running) else set() todo = stale(actual, rendered.services, will_run) force = set() - if "core" in will_run and "core" not in todo and config["mode"] != "web-only": + if "core" in will_run and "core" not in todo: # The digest file follows secrets/core.key; a Core that rejects the key restarts, then Web. if http(core_base(config) + "/core/v1/installation", bearer(configuration.read_core_key(root)))[0] == 401: force = {"core"} | ({"web"} & will_run) - written_inputs = configuration.rendered_inputs(disk, rendered.unit) + written_inputs = configuration.rendered_inputs(disk) in_sync = bool(running) and all(actual.get(name, {}).get("running") and actual[name]["inputs"] == written_inputs.get(name) for name in set(written_inputs) - {"migrate"}) - core_installation_id = state.get("core_installation_id") - if config["mode"] == "web-only": - core_installation_id = check_paired_core(root, config, state, previous, args, interactive, out) - elif state.get("generated"): + if state.get("generated"): old, base, answered = old_public_url(root, config, previous, disk, actual) confirm_public_url(root, config, old, base, answered, args, interactive, out) @@ -726,7 +656,6 @@ def _apply(root, args, discard_edits, start, interactive, out, rollback=True, re if not (changed or removed or restarts or edited): if ingress_config.enabled(config): finish_apply(root, config, state, rendered.files.get("Caddyfile"), will_run, candidate) - state = dict(state, core_installation_id=core_installation_id) if record_digests(state, rendered.files) != load_state(root): save_state(root, record_digests(state, rendered.files)) out("Nothing to apply.") @@ -736,7 +665,7 @@ def _apply(root, args, discard_edits, start, interactive, out, rollback=True, re for name in edited: create_private(root / "generated" / f"{name}.edited-{stamp}", disk[name]) # Digests first: a file written from here on is recognized as oac's. - save_state(root, record_digests(dict(state, core_installation_id=core_installation_id), rendered.files)) + save_state(root, record_digests(state, rendered.files)) for name in sorted(set(changed) | set(edited)): write_private(root / "generated" / name, rendered.files[name]) for name in removed: @@ -745,7 +674,7 @@ def _apply(root, args, discard_edits, start, interactive, out, rollback=True, re converge(root, state, rendered.services, will_run, force) finish_apply(root, config, state, rendered.files.get("Caddyfile"), will_run, candidate) except (OacError, RuntimeError, subprocess.CalledProcessError) as error: - line = core_error_line(root, state) + line = core_error_line(root) if line: out(line) if not (rollback and in_sync): @@ -802,10 +731,10 @@ def _remove_last_files(root, state, keep_root): def remove(root, state, keep_root=False, images=False): - """Remove one installation: native Core's unit, its Compose project with its volumes, then its files. + """Remove one installation: its Compose project with its volumes, then its files. - state is the loaded state.json. Only this installation's own project and unit are - touched. Loaded images are kept unless images is set; see remove_images. keep_root keeps + state is the loaded state.json. Only this installation's own project is touched. + Loaded images are kept unless images is set; see remove_images. keep_root keeps the directory and its .oac.lock, which the caller holds, and removes everything else in it. The files stay while a service is left or an image removal fails, so state.json still names them. Nothing is printed, so a closed terminal can't stop the removal. Returns a @@ -819,12 +748,6 @@ def remove(root, state, keep_root=False, images=False): if not isinstance(project, str) or not re.fullmatch(r"oac-[0-9a-f]{10}", project): raise OacError("state.json names no Compose project of this installation; nothing was removed") left, kept = [], [] - if native_service.is_native(state): - unit = native_service.unit_name(state) - try: - native_service.remove(state) - except (RuntimeError, KeyboardInterrupt): - left.append((f"native Core unit {unit}", f"systemctl --user disable --now {unit}")) # -p without -f, outside any project directory and without COMPOSE_* settings: Compose # reads no project file and acts on this project's labels alone. down = ["docker", "compose", "-p", project, "down", "--volumes", "--remove-orphans"] @@ -903,15 +826,13 @@ def written_view(root, state, config): if config is None: raise OacError("Neither config.json nor generated/settings.json can be read") return config - return applied_view(state, values) + return applied_view(values) -def applied_view(fixed, values): - """The config the settings last written describe; fixed supplies mode and native_core, which never change.""" - return {"mode": fixed["mode"], "native_core": fixed.get("native_core", False), "ingress": values.get("ingress"), - "public_url": values.get("public_url"), "host": values["host"], - "ports": {name: values[f"ports.{name}"] for name in ("core", "web", "database") if f"ports.{name}" in values}, - "web": {"core_url": values.get("web.core_url")}} +def applied_view(values): + """The config the settings last written describe.""" + return {"ingress": values.get("ingress"), "public_url": values.get("public_url"), "host": values["host"], + "ports": {name: values[f"ports.{name}"] for name in ("core", "web") if f"ports.{name}" in values}} def load_config_or_report(root, out): @@ -928,75 +849,44 @@ def status(root, out=print): loaded = load_config_or_report(root, out) state = load_state(root) config = written_view(root, state, loaded) - mode = config["mode"] actual = observe(state) rendered = None - if loaded is not None and loaded.get("mode") == state["mode"] and loaded.get("native_core", False) == state["native_core"]: + if loaded is not None: rendered, disk, _ = render_now(root, loaded, state) for name, item in sorted(actual.items()): line = f'{name}: {"running" if item["running"] else "stopped"} {item["health"]}'.rstrip() if rendered and item["running"] and name != "migrate" and item["inputs"] != rendered.services.get(name): line += " (runs with other inputs than config.json renders; run oac apply)" out(line) - required = set(config_model.SERVICES[mode]) + required = set(config_model.SERVICES) if ingress_config.enabled(config): required.update(("gateway", "installation")) healthy = all(actual.get(name, {}).get("running") and actual[name]["health"] in ("", "healthy") for name in required) - if mode != "web-only": - core_ok = http(core_base(config) + "/healthz")[0] == 200 - healthy = healthy and core_ok - out("Core API: " + ("healthy" if core_ok else "unavailable")) - key = configuration.read_core_key(root) - digests = root / "generated/core-key-digests.json" - if digests.is_file() and configuration.sha256(key) not in json.loads(digests.read_text()): - out("secrets/core.key does not match generated/core-key-digests.json; run oac apply") - if core_ok and http(core_base(config) + "/core/v1/installation", bearer(key))[0] == 401: - out("Core rejects secrets/core.key because it started with another key; run oac apply") - healthy = False - if mode != "core-only": - web_ok = http(configuration.service_origin(config, "web") + "/healthz")[0] == 200 - healthy = healthy and web_ok - out("Web: " + ("healthy" if web_ok else "unavailable")) + core_ok = http(core_base(config) + "/healthz")[0] == 200 + healthy = healthy and core_ok + out("Core API: " + ("healthy" if core_ok else "unavailable")) + key = configuration.read_core_key(root) + digests = root / "generated/core-key-digests.json" + if digests.is_file() and configuration.sha256(key) not in json.loads(digests.read_text()): + out("secrets/core.key does not match generated/core-key-digests.json; run oac apply") + if core_ok and http(core_base(config) + "/core/v1/installation", bearer(key))[0] == 401: + out("Core rejects secrets/core.key because it started with another key; run oac apply") + healthy = False + web_ok = http(configuration.service_origin(config, "web") + "/healthz")[0] == 200 + healthy = healthy and web_ok + out("Web: " + ("healthy" if web_ok else "unavailable")) out("Public URL: " + (config["public_url"] or ("not configured; set up HTTPS in Web" if ingress_config.enabled(config) else "none (local access only)"))) - if mode != "web-only": - out("API base URL: " + configuration.local_public_url(config) + "/v1") - if mode != "core-only": - out("Console: " + (ingress_config.console_origin(config) if ingress_config.enabled(config) else - config["public_url"] or configuration.service_origin(config, "web"))) + out("API base URL: " + configuration.local_public_url(config) + "/v1") + out("Console: " + (ingress_config.console_origin(config) if ingress_config.enabled(config) else + config["public_url"] or configuration.service_origin(config, "web"))) out("Source commit: " + state["source_commit"]) - if loaded is not None: - for key in ("mode", "native_core"): - if loaded.get(key, False) != state[key]: - out(f"config.json sets {key} to {json.dumps(loaded.get(key, False))}, but it is fixed at " - f"{json.dumps(state[key])} for this installation (state.json); restore it") if rendered is not None: if any(comparable(name, disk.get(name)) != comparable(name, text) for name, text in rendered.files.items()): out(f"config.json has changes that are not applied; run {root / 'oac'} apply") for name in edited_files(state, disk, rendered): out(f"generated/{name} was edited by hand; put the change in config.json and run oac apply --discard-edits") - if mode == "web-only": - out("Reverse proxy: /v1 and /api/v1 go to Core; everything else goes to " + - configuration.service_address(config, "web", connect=True)) - code, installation = paired_core(root, config) - if code == 401: - out("Paired Core: rejects this Web host's Core key; the key is out of date. Copy secrets/core.key " - "from the Core host, then run oac apply.") - healthy = False - elif code == 404: - out("Paired Core: runs an earlier release without /core/v1/installation; historical versions are unsupported; reinstall the Core separately") - elif code != 200: - out("Paired Core: unreachable at " + config["web"]["core_url"]) - healthy = False - elif state.get("core_installation_id") not in (None, installation): - out(f"Paired Core: web.core_url reaches installation {installation}, " - f'not the paired installation {state["core_installation_id"]}') - else: - out(f"Paired Core: installation {installation}") - elif mode == "core-only": - out(f'Reverse proxy: /v1 and /api/v1 go to {configuration.service_address(config, "core", connect=True)}; Web runs elsewhere') - else: - out(f'Reverse proxy: /v1 and /api/v1 go to {configuration.service_address(config, "core", connect=True)}; ' - f'everything else goes to {configuration.service_address(config, "web", connect=True)}') + out(f'Reverse proxy: /v1 and /api/v1 go to {configuration.service_address(config, "core", connect=True)}; ' + f'everything else goes to {configuration.service_address(config, "web", connect=True)}') out("Service health does not prove model execution. This check makes no model requests.") check_complete(state) if not healthy: @@ -1017,16 +907,12 @@ def start(root, out=print): for name in edited_files(state, disk, rendered): out(f"Warning: generated/{name} was edited by hand.") written = written_view(root, state, config) - if state["mode"] == "web-only" and paired_core(root, written)[0] == 404: - raise OacError("The paired Core version is not supported; preserve its data and reinstall " - "the current release separately. Nothing was started.") for name, image in state["images"].items(): if run(["docker", "image", "inspect", image], check=False, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode: raise OacError(f"The {name} image is missing; rerun install.sh from bundle " f'{state["source_commit"]} to reload images') - unit = native_service.unit_name(state) if native_service.is_native(state) else None - desired = configuration.rendered_inputs(read_generated(root, {"compose.json"} | ({unit} if unit else set())), unit) + desired = configuration.rendered_inputs(read_generated(root, {"compose.json"})) will_run = set(desired) - {"migrate"} converge(root, state, desired, will_run) gateway_document = (root / "generated/Caddyfile").read_text() if ingress_config.enabled(written) else None @@ -1037,8 +923,7 @@ def start(root, out=print): def stop(root, out=print): with locked(root): check_directories(root) - state = load_state(root) - native_service.stop(root, state) + load_state(root) if (root / "generated/compose.json").exists(): compose(root, "stop") out("Control-plane services stopped. Data, nodes and sandbox resources are kept; running sandbox work may continue.") @@ -1050,9 +935,6 @@ def rotate_core_key(root, yes=False, interactive=None, out=print): check_directories(root) state = load_state(root) check_complete(state) - if state["mode"] == "web-only": - raise OacError("Core owns the Core key. Copy secrets/core.key from the Core host into this " - "installation, then run oac apply.") config = load_config(root) rendered, disk, _ = render_now(root, config, state) if any(comparable(name, disk.get(name)) != comparable(name, text) for name, text in rendered.files.items()) \ @@ -1083,7 +965,6 @@ def rotate_core_key(root, yes=False, interactive=None, out=print): if http(url, new)[0] != 200 or http(url, bearer(old_key))[0] != 401: raise OacError("Core did not confirm the new key and reject the old one; run oac status") out(f"New Core key: {root / 'secrets/core.key'}. Sign in to Web again and update scripts that use the key.") - out("A separate Web-only installation keeps its own copy: copy secrets/core.key to that host and run its oac apply.") def core_sandboxes(root, state): @@ -1116,39 +997,33 @@ def uninstall(root, yes=False, interactive=None, out=print): return with locked(root): state = load_state(root) - project, core = state["project"], state["mode"] != "web-only" + project = state["project"] out(f"This removes the installation in {root} from this host:") - out(f" Compose project {project}: its containers and networks" - + (f", and the database volume {project}_database" if core else "")) - if native_service.is_native(state): - out(f" Native Core service {native_service.unit_name(state)}") + out(f" Compose project {project}: its containers and networks, and the database volume {project}_database") for name, image in sorted(state["images"].items()): out(f" The {name} image {image}, unless another container or a tag uses it") out(f" The directory {root}") nodes = [] - if not core: - out("The paired Core and its data are not touched.") + out("All data is deleted: the database with every Project, API key, Session and stored credential, and " + "the Core key. To keep it, back it up first: docs/getting-started/operations.md#back-up") + found = core_sandboxes(root, state) + release = ("While Core is still up, archive their Sessions, or choose Reset deployment in Web " + "(System → Manage sandbox configuration) and let it complete.") + if found is None: + nodes = None + out("Core did not answer, so its nodes and sandboxes can't be listed. Nodes stay on their hosts.") + out("Uninstall stops no sandbox: node sandboxes keep running on their nodes, and E2B keeps running, " + "and billing for, its sandboxes. " + release) else: - out("All data is deleted: the database with every Project, API key, Session and stored credential, and " - "the Core key. To keep it, back it up first: docs/getting-started/operations.md#back-up") - found = core_sandboxes(root, state) - release = ("While Core is still up, archive their Sessions, or choose Reset deployment in Web " - "(System → Manage sandbox configuration) and let it complete.") - if found is None: - nodes = None - out("Core did not answer, so its nodes and sandboxes can't be listed. Nodes stay on their hosts.") - out("Uninstall stops no sandbox: node sandboxes keep running on their nodes, and E2B keeps running, " - "and billing for, its sandboxes. " + release) - else: - nodes, deployment = found - if nodes: - out("Nodes registered with this Core, which stay on their hosts: " + ", ".join( - f'{node.get("name")} ({"online" if node.get("online") else "offline"})' for node in nodes)) - count = (deployment.get("resources") or {}).get("allocations") or 0 - if count: - where = ("E2B keeps running them, and billing for them" if deployment.get("provider") == "e2b" - else "they keep running on their nodes") - out(f"Core has {count} sandbox(es) in use. Uninstall does not stop them: {where}. {release}") + nodes, deployment = found + if nodes: + out("Nodes registered with this Core, which stay on their hosts: " + ", ".join( + f'{node.get("name")} ({"online" if node.get("online") else "offline"})' for node in nodes)) + count = (deployment.get("resources") or {}).get("allocations") or 0 + if count: + where = ("E2B keeps running them, and billing for them" if deployment.get("provider") == "e2b" + else "they keep running on their nodes") + out(f"Core has {count} sandbox(es) in use. Uninstall does not stop them: {where}. {release}") if not yes: if not interactive: raise OacError("Confirm the uninstall with --yes, or run it in a terminal; nothing was removed") @@ -1176,7 +1051,6 @@ def main(argv=None, root=None, out=print): commands.add_parser("stop", help="Stop the installed services; data is kept") apply_parser = commands.add_parser("apply", help="Apply config.json and restart what changed") apply_parser.add_argument("--dry-run", action="store_true", help="Show the plan without changing anything") - apply_parser.add_argument("--yes", action="store_true", help="Pair Web-only with a different Core without asking") apply_parser.add_argument("--discard-edits", action="store_true", help="Overwrite hand-edited generated files, keeping each edited copy") apply_parser.add_argument("--confirm-public-url-change", metavar="URL", @@ -1194,9 +1068,9 @@ def main(argv=None, root=None, out=print): return uninstall(root, yes=args.yes, out=out) if not (root / "state.json").exists(): raise OacError(f"{root} is not an installation directory; run the oac command inside it") - state = load_state(root) + load_state(root) if args.command == "apply": - apply(root, dry_run=args.dry_run, yes=args.yes, discard_edits=args.discard_edits, + apply(root, dry_run=args.dry_run, discard_edits=args.discard_edits, confirm_public_url_change=args.confirm_public_url_change, out=out) elif args.command == "domain": import ingress diff --git a/deploy/install/test_config_model.py b/deploy/install/test_config_model.py index 9b4376c2..f9239505 100644 --- a/deploy/install/test_config_model.py +++ b/deploy/install/test_config_model.py @@ -20,14 +20,10 @@ def schemas(node): class ConfigModelTests(unittest.TestCase): def test_provider_roots_follow_the_installed_layout(self): - root = Path("/installation") - for native, artifacts, state in ((False, "/opt/oac", "/state"), - (True, "/installation/native", "/installation/state")): - with self.subTest(native=native): - config = config_model.initial("all", native, **({"ports.database": 15432} if native else {})) - environment = configuration.core_environment(root, config, {"installation_id": "fixture"}) - self.assertEqual(environment["OAC_PROVIDER_ROOT"], artifacts) - self.assertEqual(environment["OAC_PROVIDER_STATE_ROOT"], state) + environment = configuration.core_environment(Path("/installation"), config_model.initial(), + {"installation_id": "fixture"}) + self.assertEqual(environment["OAC_PROVIDER_ROOT"], "/opt/oac") + self.assertEqual(environment["OAC_PROVIDER_STATE_ROOT"], "/state") def test_schema_uses_only_the_supported_keyword_subset(self): @@ -37,9 +33,8 @@ def test_schema_uses_only_the_supported_keyword_subset(self): if "properties" in node: self.assertIs(node.get("additionalProperties"), False) - def test_new_config_lists_every_applicable_setting(self): - expected = { - "all": ["mode", "native_core", "public_url", "host", "ports.core", "ports.web", "log.level", "log.format", + def test_new_config_lists_every_setting(self): + expected = ["public_url", "host", "ports.core", "ports.web", "log.level", "log.format", "log.add_source", "core.execution_concurrency", "core.harnesses", "core.default_harness", "core.write_audit_retention", "core.oauth_trusted_origins", "core.database_pool.max_conns", "core.database_pool.min_conns", "core.database_pool.max_conn_lifetime", @@ -47,23 +42,14 @@ def test_new_config_lists_every_applicable_setting(self): "core.runtime_history.transport", "core.runtime_history.endpoint", "core.runtime_history.insecure", "core.runtime_history.headers", "core.runtime_history.queue_capacity", "core.runtime_history.timeout_seconds", - "core.runtime_history.sample_interval_seconds"], - "web-only": ["mode", "public_url", "host", "ports.web", "web.core_url", "log.level", "log.format", "log.add_source"], - } - self.assertEqual(list(config_model.values(config_model.initial("all"))), expected["all"] + ["ingress"]) - web = config_model.initial("web-only", **{"web.core_url": "https://core.example"}) - self.assertEqual(list(config_model.values(web)), expected["web-only"] + ["ingress"]) - self.assertEqual(config_model.initial("core-only", True, **{"ports.database": 15432})["ports"], - {"core": 8091, "database": 15432}) - native = config_model.initial("all", True, **{"ports.database": 15432}) - self.assertEqual(native["ports"], {"core": 8091, "web": 8080, "database": 15432}) - restarts = {item["key"]: item["restarts"] for item in config_model.settings(native)} - self.assertEqual(restarts["ports.core"], ["core", "web"]) - self.assertEqual({item["key"]: item["restarts"] for item in config_model.settings(config_model.initial("all"))} - ["ports.core"], ["core"]) + "core.runtime_history.sample_interval_seconds", "ingress"] + self.assertEqual(list(config_model.values(config_model.initial())), expected) + self.assertEqual(config_model.initial()["ports"], {"core": 8091, "web": 8080}) + restarts = {item["key"]: item["restarts"] for item in config_model.settings(config_model.initial())} + self.assertEqual(restarts["ports.core"], ["core"]) def test_invalid_settings_name_their_key_without_their_value(self): - base = {"format": 1, "mode": "all"} + base = {"format": 1} cases = [ ({"public_url": "https://core.example/"}, "public_url: must be a canonical origin"), ({"public_url": "https://Core.example"}, "public_url: must be a canonical origin"), @@ -72,14 +58,11 @@ def test_invalid_settings_name_their_key_without_their_value(self): "https://a_b.example", "https://core.example.", "https://b\u00fccher.example", "https://core.example:0443", "https://core.example:", "http://[2001:db8::1]")), ({"surprise": 1}, "surprise: unknown key"), - ({"web": {"core_url": "https://core.example"}}, "web: does not apply when mode is \"all\""), ({"ports": {"core": 8080}}, "ports: core, web need different ports"), ({"ports": {"web": 80}}, "ports.web: must be at least 1024"), - ({"native_core": True}, "ports.database: required exactly when native_core is true"), ({"core": {"harnesses": ["mcode"]}}, "core.default_harness: must be listed in core.harnesses"), ({"core": {"write_audit_retention": "59m"}}, "core.write_audit_retention: must be a Go duration of at least 1h"), ({"core": {"runtime_history": {"headers": {"Authorization": 7}}}}, "core.runtime_history.headers.Authorization: must be string"), - ({"mode": "web-only"}, "web.core_url: required for a web-only installation"), ({"format": True}, "format: must be 1"), ({"format": 1.0}, "format: must be 1"), ] @@ -102,27 +85,26 @@ def test_generated_files_hold_no_secret_and_the_snapshot_hides_sensitive_values( for name, value in secrets.items(): (root / "secrets" / name).write_text(value) state = {"installation_id": "5b7c0f3e-0000-4000-8000-000000000000", "project": "oac-0123456789", - "uid": 1000, "gid": 1000, "mode": "all", "native_core": False, + "uid": 1000, "gid": 1000, "images": {name: "sha256:" + "1" * 64 for name in ("core", "web", "database")}} headers = {"Authorization": "Bearer export-secret"} - for native in (False, True): - with self.subTest(native=native): - config = config_model.initial("all", native, **{"ports.database": 15432 if native else None}) - config["core"]["runtime_history"] = {"endpoint": "collector.example:4317", "headers": headers} - rendered = configuration.render(root, config, dict(state, native_core=native), "2026-09-25T00:00:00Z") - environment = configuration.read_environment(rendered.files["core.env"]) - self.assertRegex(environment["OAC_DATABASE_URL"], r"^postgres://agents_api@[^:/]+:\d+/agents_api\?sslmode=disable$") - for name, text in rendered.files.items(): - for secret in [*secrets.values(), "export-secret"]: - if name != "runtime-history.json": - self.assertNotIn(secret, text, name) - self.assertEqual(json.loads(rendered.files["runtime-history.json"])["headers"], headers) - snapshot = json.loads(rendered.files["settings.json"]) - item = next(item for item in snapshot["settings"] if item["key"] == "core.runtime_history.headers") - self.assertEqual((item["value"], item["configured"], item["sensitive"]), (None, True, True)) - self.assertEqual(snapshot["path"], str(root / "config.json")) - self.assertEqual(snapshot["apply_command"], f"{root / 'oac'} apply") - self.assertEqual("oac-0123456789-core.service" in rendered.files, native) + config = config_model.initial() + config["core"]["runtime_history"] = {"endpoint": "collector.example:4317", "headers": headers} + rendered = configuration.render(root, config, state, "2026-09-25T00:00:00Z") + environment = configuration.read_environment(rendered.files["core.env"]) + self.assertEqual(environment["OAC_DATABASE_URL"], + "postgres://agents_api@database:5432/agents_api?sslmode=disable") + for name, text in rendered.files.items(): + for secret in [*secrets.values(), "export-secret"]: + if name != "runtime-history.json": + self.assertNotIn(secret, text, name) + self.assertEqual(json.loads(rendered.files["runtime-history.json"])["headers"], headers) + snapshot = json.loads(rendered.files["settings.json"]) + item = next(item for item in snapshot["settings"] if item["key"] == "core.runtime_history.headers") + self.assertEqual((item["value"], item["configured"], item["sensitive"]), (None, True, True)) + self.assertEqual(snapshot["path"], str(root / "config.json")) + self.assertEqual(snapshot["apply_command"], f"{root / 'oac'} apply") + self.assertFalse(any(name.endswith(".service") for name in rendered.files)) if __name__ == "__main__": diff --git a/deploy/install/test_ingress.py b/deploy/install/test_ingress.py index d26a3c41..15f4a084 100644 --- a/deploy/install/test_ingress.py +++ b/deploy/install/test_ingress.py @@ -35,7 +35,7 @@ def setUp(self): patch.start() self.addCleanup(patch.stop) with contextlib.redirect_stdout(io.StringIO()): - run_installer(install, self.bundle, ["--install-dir", self.root, "--sandbox", "none"]) + run_installer(install, self.bundle, ["--install-dir", self.root]) self.host.recreated.clear() def gateway_ports(self): diff --git a/deploy/install/test_install.py b/deploy/install/test_install.py index 6d4e0265..72a1b9f7 100644 --- a/deploy/install/test_install.py +++ b/deploy/install/test_install.py @@ -26,8 +26,6 @@ REAL_RUN = subprocess.run -BUILD = "base:0f6c1e8e-7d3a-4b8e-9a51-2b7f7f0c9d11" - class InstallerTests(unittest.TestCase): def setUp(self): @@ -64,7 +62,7 @@ def key_file(self, contents="synthetic-existing-core-key-0123456789", mode=0o600 path.chmod(mode) return path - def test_catalog_installed_for_container_and_native_core(self): + def test_catalog_is_mounted_into_core(self): import hashlib native = self.bundle / "native-installers" native.mkdir() @@ -91,11 +89,8 @@ def test_catalog_installed_for_container_and_native_core(self): mount = next(m for m in compose["services"]["core"]["volumes"] if m["target"] == "/opt/oac/native-installers") self.assertTrue(mount["read_only"]) self.assertEqual(mount["source"], str(installed)) - config = self.document("config.json") - config["native_core"] = True - config["ports"]["database"] = 5432 - environment = install.configuration.core_environment(self.root, config, self.document("state.json")) - self.assertEqual(environment["OAC_NATIVE_INSTALLER_DIR"], str(installed)) + environment = install.configuration.core_environment(self.root, self.document("config.json"), self.document("state.json")) + self.assertEqual(environment["OAC_NATIVE_INSTALLER_DIR"], "/opt/oac/native-installers") def test_host_check_accepts_current_account_including_root(self): for uid in (0, 1000): @@ -150,7 +145,7 @@ def test_new_release_cleans_an_incomplete_installation_before_starting_again(sel def test_root_identity_is_preserved_in_service_configuration(self): with mock.patch.object(install.os, "getuid", return_value=0), \ mock.patch.object(install.os, "getgid", return_value=0): - self.install("--sandbox", "none") + self.install() state = self.document("state.json") self.assertEqual((state["uid"], state["gid"]), (0, 0)) services = self.document("generated/compose.json")["services"] @@ -214,7 +209,7 @@ def test_an_interrupted_first_start_removes_what_it_created(self): self.assertEqual(self.host.containers, {}) def test_a_rerun_replaces_an_incomplete_installation(self): - self.install("--sandbox", "none") + self.install() for marker in (False, None): with self.subTest(marker=marker): old = self.host.project @@ -232,17 +227,16 @@ def test_a_rerun_replaces_an_incomplete_installation(self): remove = install.oac_cli.remove with mock.patch.object(install.oac_cli, "remove", side_effect=lambda *args, **kwargs: (remove(*args, **kwargs), self.host.busy.clear())): - self.install("--core-only", "--sandbox", "none") + self.install("--ingress", "external") self.assertIn(["docker", "compose", "-p", old, "down", "--volumes", "--remove-orphans"], self.host.commands) - self.assertEqual((self.document("config.json")["mode"], self.document("config.json")["ports"]["core"]), - ("core-only", 8091)) + self.assertEqual(self.document("config.json")["ports"]["core"], 8091) state = self.document("state.json") self.assertNotEqual(state["project"], old) self.assertTrue(state["complete"]) - self.assertEqual(self.host.running(), {"database", "core"}) + self.assertEqual(self.host.running(), {"database", "core", "web"}) def test_removal_keeps_recovery_state_on_failure_and_finishes_despite_signals(self): - self.install("--sandbox", "none") + self.install() state = dict(self.document("state.json"), complete=False) install.oac_cli.save_state(self.root, state) command = (self.root / "oac").read_bytes() @@ -282,7 +276,7 @@ def interrupt_command(path, *args, **kwargs): self.assertIs(signal.getsignal(signum), install.interrupted) def test_manual_removal_command_keeps_automatic_compose_isolation(self): - self.install("--sandbox", "none") + self.install() state = self.document("state.json") foreign = self.work / "other-project" foreign.mkdir() @@ -318,15 +312,14 @@ def test_a_directory_without_state_json_is_refused_and_untouched(self): key.chmod(0o600) before = self.snapshot() with self.assertRaisesRegex(install.InstallError, "not empty"): - self.install("--sandbox", "e2b", "--e2b-api-key-file", key, "--e2b-template", BUILD, - "--public-url", "https://core.example") + self.install("--public-url", "https://core.example") self.assertEqual(self.snapshot(), before) def test_a_complete_installation_is_never_removed(self): - self.install("--sandbox", "none") + self.install() before = self.snapshot() with self.assertRaisesRegex(install.InstallError, "configured by"): - self.install("--core-only") + self.install("--web-port", "8081") self.host.containers.clear() self.host.core["fails"] = True with self.assertRaisesRegex(install.oac_cli.OacError, "config.json not applied"): @@ -334,31 +327,6 @@ def test_a_complete_installation_is_never_removed(self): self.assertEqual(self.snapshot(), before) self.assertFalse(any("down" in command for command in self.host.commands)) - def test_fresh_web_refuses_old_core_before_creating_installation(self): - self.host.remote_core["https://core.example"] = (404, None) - with mock.patch.object(install, "check_host", side_effect=AssertionError("host touched")), \ - mock.patch.object(install, "create", side_effect=AssertionError("installation created")), \ - self.assertRaisesRegex(install.InstallError, "not supported;.*reinstall"): - self.install("--web-only", "--core-url", "https://core.example", "--core-key-file", self.key_file()) - self.assertFalse(self.root.exists()) - - def test_fresh_web_accepts_current_core_and_records_its_identity(self): - self.host.remote_core["https://core.example"] = (200, self.host.core_installation_id) - key = self.key_file() - self.install("--web-only", "--core-url", "https://core.example", "--core-key-file", key) - self.assertEqual(self.document("state.json")["core_installation_id"], self.host.core_installation_id) - self.assertEqual((self.root / "secrets/core.key").read_text(), key.read_text()) - self.assertEqual(self.host.running(), {"web"}) - - def test_web_repair_refuses_old_paired_core_before_payload_or_state_writes(self): - self.host.remote_core["https://core.example"] = (200, self.host.core_installation_id) - self.install("--web-only", "--core-url", "https://core.example", "--core-key-file", self.key_file()) - before = self.snapshot() - self.host.remote_core["https://core.example"] = (404, None) - with self.assertRaisesRegex(install.InstallError, "not supported;.*reinstall"): - self.install() - self.assertEqual(before, self.snapshot()) - def test_different_revision_refuses_before_mutation(self): self.install() state = self.document("state.json") @@ -376,9 +344,11 @@ def test_fresh_install_writes_config_json_and_the_layout(self): finally: os.umask(previous) config = self.document("config.json") - self.assertEqual(config, config_model.initial("all", public_url="https://core.example", **{"ports.web": 8181})) + self.assertEqual(config, config_model.initial(public_url="https://core.example", **{"ports.web": 8181}, ingress="external")) state = self.document("state.json") - self.assertEqual((state["mode"], state["native_core"], state["source_commit"]), ("all", False, "a" * 40)) + self.assertEqual(state["source_commit"], "a" * 40) + self.assertNotIn("mode", state) + self.assertNotIn("native_core", state) self.assertEqual(set(state["images"]), {"core", "database", "web"}) self.assertEqual(set(state["secrets_sha256"]), {"credential.key", "database.password"}) names = {str(path.relative_to(self.root)) for path in self.root.rglob("*") if "node-payload" not in path.relative_to(self.root).parts[:-1] @@ -425,7 +395,7 @@ def test_managed_ingress_without_https_leaves_ports_80_and_443_alone(self): self.host.busy.add(("0.0.0.0", 80)) with mock.patch.object(ingress_config, "preflight", return_value={"docker_socket": "/var/run/docker.sock", "docker_gid": 999}), \ mock.patch.object(ingress_config, "reload"), contextlib.redirect_stdout(self.output): - run_installer(install, self.bundle, ["--install-dir", self.root, "--sandbox", "none"]) + run_installer(install, self.bundle, ["--install-dir", self.root]) self.assertEqual(self.document("generated/compose.json")["services"]["gateway"]["ports"], ["0.0.0.0:8080:8080"]) def test_managed_https_needs_ports_80_and_443(self): @@ -444,9 +414,6 @@ def test_output_labels_public_and_local_addresses(self): "Use this key to sign in to Web.", "Before adding nodes, configure a reachable HTTPS address", "Add nodes: in Web, open Nodes and choose Add node"]), - "core-only": (["--core-only"], ["API base URL: http://127.0.0.1:8091/v1 (local only)", - "Create a Project and its API key through the Core management API:", - "http://127.0.0.1:8091/core/v1 (local only)"]), "loopback": (["--public-url", "http://localhost:8080"], ["Console: http://localhost:8080 (local only)", "API base URL: http://127.0.0.1:8091/v1 (local only)"]), } @@ -462,7 +429,7 @@ def test_output_labels_public_and_local_addresses(self): def test_no_change_repair_does_not_claim_service_health(self): - self.install("--sandbox", "none") + self.install() self.output = io.StringIO() original_http = install.oac_cli.http def unhealthy(url, *args, **kwargs): @@ -516,48 +483,6 @@ def test_a_live_installation_missing_config_json_is_never_told_to_start_over(sel self.install() self.assertEqual(self.snapshot(), before) - def test_web_only_uses_the_existing_core_key_and_records_its_core(self): - source = self.key_file() - self.host.remote_core["http://127.0.0.1:9091"] = (200, self.host.core_installation_id) - self.install("--web-only", "--core-url", "http://127.0.0.1:9091", "--core-key-file", source) - self.assertEqual((self.root / "secrets/core.key").read_bytes(), source.read_bytes()) - self.assertEqual(sorted(path.name for path in (self.root / "secrets").iterdir()), ["core.key"]) - self.assertFalse((self.root / "state").exists()) - self.assertEqual(self.document("state.json")["core_installation_id"], self.host.core_installation_id) - web = self.document("generated/compose.json")["services"]["web"] - self.assertEqual(set(self.document("generated/compose.json")["services"]), {"web"}) - self.assertEqual((web["network_mode"], web["environment"]["OAC_WEB_UPSTREAM"]), ("host", "http://127.0.0.1:9091")) - self.assertNotIn(source.read_text(), self.output.getvalue()) - self.assertIn("Console: http://127.0.0.1:8080 (local only)\n", self.output.getvalue()) - self.assertIn("Core key file: " + str(self.root / "secrets/core.key"), self.output.getvalue()) - self.assertIn("Create a Project and its API key", self.output.getvalue()) - self.assertNotIn("API base URL", self.output.getvalue()) - self.assertFalse(any(command[:2] == ["docker", "load"] and not command[-1].endswith("web.tar") - for command in self.host.commands)) - self.assertEqual(self.host.deployment_posts, []) - - def test_web_only_rejects_exposed_or_malformed_core_key_files(self): - link = self.work / "linked.key" - link.symlink_to(self.key_file()) - for source in [self.key_file(contents, mode) for contents, mode in ( - ("synthetic-token-0123456789abcdefghij", 0o644), ("", 0o600), ("two tokens", 0o600), - ("x" * 4097, 0o600), ("x" * 31, 0o600))] + [link]: - with self.subTest(source=source), self.assertRaises(install.InstallError): - self.install("--web-only", "--core-url", "http://localhost:8091", "--core-key-file", source) - self.assertFalse(self.root.exists()) - - def test_native_core_migrates_before_enabling_its_generated_unit(self): - self.install("--native-core") - config = self.document("config.json") - self.assertTrue(config["native_core"]) - self.assertIn("database", config["ports"]) - commands = self.host.commands - migrate = commands.index([str(self.root / "native/bin/oac-core-migrate")]) - unit = self.root / ("generated/" + self.document("state.json")["project"] + "-core.service") - self.assertLess(migrate, commands.index(["systemctl", "--user", "enable", "--now", str(unit)])) - self.assertEqual(set(self.document("generated/compose.json")["services"]), {"database", "web"}) - self.assertTrue(self.host.native["active"]) - def test_a_new_installation_selects_microsandbox_at_web_standard_size(self): self.install("--public-url", "https://core.example") standard = json.loads(STANDARD_SIZES.read_text()) @@ -568,117 +493,33 @@ def test_a_new_installation_selects_microsandbox_at_web_standard_size(self): "Execution nodes need KVM (/dev/kvm). This host needs KVM only if you add it as a node.", "Add nodes: in Web, open Nodes and choose Add node, then run the command on each execution host."): self.assertIn(message, output) - self.assertNotIn(install.DOCKER_RISKS, self.output.getvalue()) self.assertNotIn("sandbox", json.dumps(self.document("config.json"))) # A repair never selects again. self.host.deployment = {"provider": ""} self.install() self.assertEqual(len(self.host.deployment_posts), 1) - self.root, self.output = self.work / "none", io.StringIO() - self.install("--sandbox", "none") - self.assertEqual(len(self.host.deployment_posts), 1) - self.assertIn("Sandboxes: none chosen. Choose a sandbox backend on the Nodes page in Web.", self.output.getvalue()) - - def install_docker(self, *flags, answer=None): - """--sandbox docker; answer is what an interactive operator types, None without a terminal.""" - with mock.patch.object(install.sys, "stdin", mock.Mock(isatty=lambda: answer is not None)), \ - mock.patch("builtins.input", return_value=answer) as prompt: - self.install("--sandbox", "docker", *flags) - return prompt - - def test_docker_needs_confirmation_before_anything_is_created(self): - for answer in (None, "", "n"): - with self.subTest(answer=answer), self.assertRaisesRegex(install.InstallError, "Docker sandboxes were not " - "confirmed; nothing was installed. Rerun with " - "--accept-docker-risks"): - self.install_docker("--public-url", "https://core.example", answer=answer) - self.assertFalse(self.root.exists()) - self.assertEqual((self.host.commands, self.host.deployment_posts), ([], [])) - output = self.output.getvalue() - for risk in ("share the node's kernel", "own microVM", "root-equivalent", "trusted workloads or for node hosts " - "without KVM"): - self.assertIn(risk, output) - standard = json.loads(STANDARD_SIZES.read_text()) - docker = {"provider": "docker", "expected_generation": 0, "resources": standard["docker"], "runtime": node_spec.release(self.manifest)} - prompt = self.install_docker("--core-only", "--accept-docker-risks") - prompt.assert_not_called() - self.assertEqual(self.host.deployment_posts, [docker]) - self.root, self.host.deployment = self.work / "confirmed", {"provider": "", "generation": 0, "reset": None} - prompt = self.install_docker(answer="y") - prompt.assert_called_once_with("Use Docker sandboxes anyway? [y/N] ") - self.assertEqual(self.host.deployment_posts, [docker, docker]) - self.assertIn("\n Sandboxes: Docker, Standard (2 CPUs, 2 GiB).\n", self.output.getvalue()) - - def test_e2b_needs_a_public_address_a_private_key_file_and_an_exact_build(self): - secret = "synthetic-e2b-key-0123456789" - key = self.key_file(secret) - for flags, message in (((), "E2B needs an HTTPS public_url that is not loopback"), - (("--public-url", "http://localhost:8080"), "E2B needs an HTTPS public_url"), - (("--public-url", "https://core.example", "--e2b-template", "base"), "template-id:build-uuid")): - with self.subTest(flags=flags), self.assertRaisesRegex(install.InstallError, message): - self.install("--sandbox", "e2b", "--e2b-api-key-file", key, "--e2b-template", BUILD, *flags) - self.assertFalse(self.root.exists()) - link = self.work / "linked-e2b-key" - link.symlink_to(key) - large = self.work / "large-e2b-key" - large.write_text("x" * 5000) - large.chmod(0o600) - key.chmod(0o644) - for source in (key, link, large): - with self.subTest(source=source), \ - self.assertRaisesRegex(install.InstallError, "E2B API key file must be .* private regular file"): - self.install("--sandbox", "e2b", "--e2b-api-key-file", source, "--e2b-template", BUILD, - "--public-url", "https://core.example") - self.assertFalse(self.root.exists()) - key.chmod(0o600) - self.install("--sandbox", "e2b", "--e2b-api-key-file", key, "--e2b-template", BUILD, "--public-url", "https://core.example") - self.assertEqual(self.host.deployment_posts, [{"provider": "e2b", "expected_generation": 0, "e2b": {"api_key": secret, "template": BUILD}}]) - self.assertIn(f"Sandboxes: E2B template {BUILD} (2 CPUs, 2 GiB). E2B runs them; no nodes are needed.", - " ".join(self.output.getvalue().split())) - self.assertNotIn(secret, self.output.getvalue() + (self.root / "config.json").read_text() - + (self.root / "state.json").read_text()) - - def test_sandbox_flag_errors_create_nothing(self): - for flags, message in ((("--web-only", "--core-key-file", self.key_file(), "--sandbox", "docker"), - "--web-only has no Core; choose the sandbox backend on the Core host"), - (("--sandbox", "e2b"), "requires --e2b-api-key-file and --e2b-template"), - (("--e2b-template", BUILD), "require --sandbox e2b"), - (("--accept-docker-risks",), "--accept-docker-risks requires --sandbox docker")): - with self.subTest(flags=flags), self.assertRaisesRegex(install.InstallError, message): - self.install(*flags) - self.assertFalse(self.root.exists()) def test_a_failed_first_start_removes_what_it_created(self): - for flags, cause in (((), "`docker compose up` failed"), (("--native-core",), "Core did not become healthy")): - with self.subTest(flags=flags): - self.root = self.host.native_root = self.work / ("native" if flags else "compose") - self.host.containers, self.output = {}, io.StringIO() - self.host.core["fails"] = True - with self.assertRaises(install.InstallError) as raised: - self.install("--sandbox", "microsandbox", *flags) - self.assertEqual(install.error_text(raised.exception), - f"The services did not start: {cause}\n{install.NOTHING_KEPT}") - self.assertNotIn("Installation complete.", self.output.getvalue()) - self.assertFalse(self.root.exists()) - self.assertIn(["docker", "compose", "-p", self.host.project, "down", "--volumes", "--remove-orphans"], - self.host.commands) - self.assertEqual(self.host.containers, {}) - if flags: - self.assertIn(["systemctl", "--user", "disable", "--now", self.host.project + "-core.service"], - self.host.commands) - self.assertFalse(self.host.native["enabled"] or self.host.native["active"]) - # The same command installs once the cause is fixed, sandbox backend included. - self.host.core["fails"] = False - self.install("--sandbox", "microsandbox", *flags) - self.assertEqual(self.host.deployment_posts[-1]["provider"], "microsandbox") + self.host.core["fails"] = True + with self.assertRaises(install.InstallError) as raised: + self.install() + self.assertEqual(install.error_text(raised.exception), + f"The services did not start: `docker compose up` failed\n{install.NOTHING_KEPT}") + self.assertNotIn("Installation complete.", self.output.getvalue()) + self.assertFalse(self.root.exists()) + self.assertIn(["docker", "compose", "-p", self.host.project, "down", "--volumes", "--remove-orphans"], + self.host.commands) + self.assertEqual(self.host.containers, {}) + # The same command installs once the cause is fixed, sandbox backend included. + self.host.core["fails"] = False + self.install() + self.assertEqual(self.host.deployment_posts[-1]["provider"], "microsandbox") def test_a_refused_selection_leaves_the_services_running(self): - secret = "synthetic-e2b-key-0123456789" - self.host.deployment_refusal = f"E2B rejected the API key {secret}." + self.host.deployment_refusal = "microsandbox is unavailable." with self.assertRaises(install.InstallError) as raised: - self.install("--sandbox", "e2b", "--e2b-api-key-file", self.key_file(secret), "--e2b-template", BUILD, - "--public-url", "https://core.example") - self.assertEqual(str(raised.exception), "Core refused the sandbox setup: E2B rejected the API key [E2B API key]. " + self.install("--public-url", "https://core.example") + self.assertEqual(str(raised.exception), "Core refused the sandbox setup: microsandbox is unavailable. " "Services are installed and running; choose the sandbox backend on the Nodes page in Web") self.assertEqual(self.host.running(), {"database", "core", "web"}) self.assertIn("Console: https://core.example\n", self.output.getvalue()) @@ -744,13 +585,12 @@ def test_cli_failure_does_not_print_external_command_secrets(self): self.assertFalse(self.root.exists()) def test_origins_must_be_canonical_and_https_unless_loopback(self): - for flag in ("--public-url", "--core-url"): - for url in ("http://remote.example:8091", "https://user:synthetic-secret@core.example", - "https://core.example/?token=synthetic-secret", "https://core_example"): - output = io.StringIO() - with self.subTest(flag=flag, url=url), contextlib.redirect_stderr(output), self.assertRaises(SystemExit): - install.arguments([flag, url]) - self.assertNotIn("synthetic-secret", output.getvalue()) + for url in ("http://remote.example:8091", "https://user:synthetic-secret@core.example", + "https://core.example/?token=synthetic-secret", "https://core_example"): + output = io.StringIO() + with self.subTest(url=url), contextlib.redirect_stderr(output), self.assertRaises(SystemExit): + install.arguments(["--public-url", url]) + self.assertNotIn("synthetic-secret", output.getvalue()) def test_public_url_flag_is_made_canonical_for_core(self): diff --git a/deploy/install/test_listeners.py b/deploy/install/test_listeners.py index 9393056e..cab6ab70 100644 --- a/deploy/install/test_listeners.py +++ b/deploy/install/test_listeners.py @@ -32,7 +32,7 @@ def setUp(self): def install(self, *flags): with contextlib.redirect_stdout(self.output): - run_installer(install, self.bundle, ["--install-dir", self.root, "--sandbox", "none", "--ingress", "external", *flags]) + run_installer(install, self.bundle, ["--install-dir", self.root, "--ingress", "external", *flags]) def document(self, name): return json.loads((self.root / name).read_text()) @@ -54,42 +54,24 @@ def test_custom_ipv4_listener_drives_services_and_operator_requests(self): self.assertTrue(all(request.startswith("http://127.0.0.2:") for request in self.host.requests)) self.assertIn("Console: http://127.0.0.2:18080", self.output.getvalue()) - def test_core_only_completion_reports_configured_address(self): - self.install("--core-only", "--host", "127.0.0.2", "--core-port", "18091") - self.assertIn("http://127.0.0.2:18091/core/v1 (local only)", self.output.getvalue()) - self.assertNotIn("http://127.0.0.1:", self.output.getvalue()) - - def test_ipv6_native_and_container_listeners(self): - for native in (False, True): - config = config_model.initial("all", native, host="::1", **{"ports.database": 15432 if native else None}) - state = dict(MANIFEST, mode="all", native_core=native, uid=1000, gid=1000, - images={name: "sha256:" + "a" * 64 for name in ("core", "web", "database")}, - project="oac-test", installation_id="fixture") - services = configuration.compose_config(self.root, config, state)["services"] - self.assertEqual(configuration.service_origin(config, "core"), "http://[::1]:8091") - if native: - self.assertEqual(configuration.core_environment(self.root, config, state)["OAC_ADDR"], "[::1]:8091") - self.assertEqual(services["web"]["environment"]["OAC_WEB_ADDR"], "[::1]:8080") - self.assertEqual(services["web"]["environment"]["OAC_WEB_UPSTREAM"], "http://[::1]:8091") - self.assertEqual(services["database"]["ports"], ["127.0.0.1:15432:5432"]) - else: - self.assertEqual(services["core"]["ports"], ["[::1]:8091:8091"]) - self.assertEqual(services["web"]["ports"], ["[::1]:8080:8080"]) + def test_ipv6_listeners(self): + config = config_model.initial(host="::1") + state = dict(MANIFEST, uid=1000, gid=1000, + images={name: "sha256:" + "a" * 64 for name in ("core", "web", "database")}, + project="oac-test", installation_id="fixture") + services = configuration.compose_config(self.root, config, state)["services"] + self.assertEqual(configuration.service_origin(config, "core"), "http://[::1]:8091") + self.assertEqual(configuration.core_environment(self.root, config, state)["OAC_ADDR"], ":8091") + self.assertEqual(services["core"]["ports"], ["[::1]:8091:8091"]) + self.assertEqual(services["web"]["ports"], ["[::1]:8080:8080"]) + self.assertEqual(services["web"]["environment"]["OAC_WEB_UPSTREAM"], "http://core:8091") def test_wildcards_use_loopback_for_operator_connections(self): for host, address in (("0.0.0.0", "127.0.0.1"), ("::", "[::1]")): - config = config_model.initial("all", host=host, public_url="https://core.example") + config = config_model.initial(host=host, public_url="https://core.example") self.assertEqual(configuration.service_origin(config, "core"), f"http://{address}:8091") self.assertEqual(configuration.web_origin(config), "https://core.example") - def test_web_only_listener_does_not_change_upstream(self): - config = config_model.initial("web-only", host="127.0.0.2", **{"ports.web": 18080, "web.core_url": "https://core.example"}) - services = configuration.compose_config(self.root, config, dict(mode="web-only", uid=1000, gid=1000, - project="oac-test", images={"web": "sha256:" + "a" * 64}))["services"] - self.assertEqual(set(services), {"web"}) - self.assertEqual(services["web"]["environment"]["OAC_WEB_ADDR"], "127.0.0.2:18080") - self.assertEqual(services["web"]["environment"]["OAC_WEB_UPSTREAM"], "https://core.example") - def test_apply_contacts_previous_host_before_switching_and_status_uses_applied_host(self): self.install("--host", "127.0.0.2", "--public-url", "https://core.example") config = self.document("config.json") @@ -132,8 +114,7 @@ def health(root, applied, expected): def test_invalid_listener_and_port_fail_before_creating_installation(self): for flags in (("--host", "localhost"), ("--host", "https://core.example"), ("--host", "[::1]:8080"), ("--host", "fe80::1%eth0"), - ("--host", "0.0.0.0"), ("--web-port", "65536"), ("--web-port", "8091"), - ("--core-only", "--web-port", "8088")): + ("--host", "0.0.0.0"), ("--web-port", "65536"), ("--core-port", "8080")): with self.subTest(flags=flags), self.assertRaises(config_model.ConfigError): self.install(*flags) self.assertFalse((self.root / "config.json").exists()) @@ -143,7 +124,7 @@ def test_invalid_listener_and_port_fail_before_creating_installation(self): def test_config_seed_rejects_listener_overrides(self): path = self.work / "seed.json" - path.write_text(json.dumps(config_model.initial("all"))) + path.write_text(json.dumps(config_model.initial())) for flags in (("--host", "127.0.0.2"), ("--web-port", "8088")): with self.assertRaisesRegex(install.InstallError, "--config replaces"): self.install("--config", path, *flags) diff --git a/deploy/install/test_model_provider_sessions.py b/deploy/install/test_model_provider_sessions.py deleted file mode 100644 index 84fb08a2..00000000 --- a/deploy/install/test_model_provider_sessions.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Verify the pre-upgrade check is read-only and reports each environment.""" -import json -from pathlib import Path -import subprocess -import tempfile -import unittest -from unittest import mock - -import model_provider_sessions - - -class ModelProviderSessionsTests(unittest.TestCase): - def setUp(self): - temporary = tempfile.TemporaryDirectory() - self.addCleanup(temporary.cleanup) - self.root = Path(temporary.name) - (self.root / "compose.json").write_text(json.dumps({"services": {"database": {}, "core": {}}})) - - def test_counts_through_a_read_only_query(self): - result = subprocess.CompletedProcess([], 0, stdout="self_hosted|2\n", stderr="") - with mock.patch.object(model_provider_sessions.subprocess, "run", return_value=result) as run: - self.assertEqual(model_provider_sessions.count(self.root), {"openai_hosted": 0, "self_hosted": 2}) - command = run.call_args.args[0] - self.assertIn("PGOPTIONS=-c default_transaction_read_only=on", command) - (self.root / "generated").mkdir() - (self.root / "generated/compose.json").write_text(json.dumps({"services": {"database": {}}})) - with mock.patch.object(model_provider_sessions.subprocess, "run", return_value=result) as run: - model_provider_sessions.count(self.root) - self.assertIn(str(self.root / "generated/compose.json"), run.call_args.args[0]) - self.assertTrue(command[-1].lstrip().startswith("SELECT")) - - def test_refuses_web_only_and_unexpected_output(self): - with mock.patch.object(model_provider_sessions.subprocess, "run", - return_value=subprocess.CompletedProcess([], 0, stdout="none|1\n", stderr="")): - with self.assertRaises(model_provider_sessions.CheckError): - model_provider_sessions.count(self.root) - (self.root / "compose.json").write_text(json.dumps({"services": {"web": {}}})) - with self.assertRaises(model_provider_sessions.CheckError): - model_provider_sessions.count(self.root) - - -if __name__ == "__main__": - unittest.main() diff --git a/deploy/install/test_native_service.py b/deploy/install/test_native_service.py deleted file mode 100644 index 2c21c2c0..00000000 --- a/deploy/install/test_native_service.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Verify native packaging without starting a service or Runtime.""" - -import configparser -from pathlib import Path -import stat -import subprocess -import tempfile -import unittest -from unittest import mock - -import native_service as service - - -class NativeServiceTests(unittest.TestCase): - def setUp(self): - temporary_root = Path.home() / ".oac/tests/install-native" - temporary_root.mkdir(parents=True, exist_ok=True) - temporary = tempfile.TemporaryDirectory(dir=temporary_root) - self.addCleanup(temporary.cleanup) - self.directory = Path(temporary.name).resolve() - self.root = self.directory / 'install space %n $HOME' - self.bundle = self.directory / "bundle" - self.state = {"mode": "all", "native_core": True, "project": "oac-0123456789", "installation_id": "fixture-installation"} - for name in service.REQUIRED: - path = self.bundle / "native" / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(b"\x7fELFfixture-" + name.encode()) - (self.root / "generated").mkdir(parents=True, mode=0o700) - self.commands = mock.patch.object(service.subprocess, "run") - self.run = self.commands.start() - self.addCleanup(self.commands.stop) - self.run.return_value = subprocess.CompletedProcess([], 0, "", "") - - def prepare(self): - service.prepare(self.root, self.state, self.bundle) - - def unit_path(self): - path = self.root / "generated" / "oac-0123456789-core.service" - if not path.exists(): - path.write_text(service.unit_text(self.root, "fixture header")) - return path - - def host_ready(self): - for patch in (mock.patch.object(service.platform, "system", return_value="Linux"), - mock.patch.object(service.os, "access", return_value=True)): - patch.start() - self.addCleanup(patch.stop) - - def result(arguments, **kwargs): - output = "yes\n" if arguments[0] == "loginctl" else "" - return subprocess.CompletedProcess(arguments, 0, output, "") - - self.run.side_effect = result - - def test_native_core_is_explicit_and_independent_of_execution(self): - for mode, native, expected in (("all", False, False), ("all", True, True), - ("core-only", True, True), ("web-only", True, False)): - with self.subTest(mode=mode, native=native): - state = dict(self.state, mode=mode, native_core=native) - self.assertEqual(service.is_native(state), expected) - if not expected: - service.prepare(self.root, state, self.bundle) - service.start(self.root, state) - service.stop(self.root, state) - self.assertFalse(service.active(state)) - self.run.assert_not_called() - - def test_cloud_helper_remains_executable_but_private(self): - self.prepare() - helper = self.root / "native/e2b/oac-e2b-provider" - self.assertEqual(stat.S_IMODE(helper.stat().st_mode), 0o700) - - def test_unit_preserves_direct_core_and_runtime_process_lifetime(self): - unit = configparser.ConfigParser(interpolation=None) - unit.read(self.unit_path()) - directives = unit["Service"] - executable = str(self.root / "native/bin/oac-core").replace("%", "%%").replace('"', '\\"') - self.assertEqual(directives["ExecStart"], ':"' + executable + '"') - self.assertEqual(directives["WorkingDirectory"], str(self.root).replace("%", "%%")) - self.assertEqual(directives["EnvironmentFile"], str(self.root / "generated/core.env").replace("%", "%%")) - self.assertEqual(directives["KillMode"], "process") - self.assertEqual(directives["Restart"], "on-failure") - self.assertEqual(directives["UMask"], "0077") - self.assertNotIn("ExecStop", directives) - self.assertNotIn("ExecStopPost", directives) - self.assertNotIn("DATABASE_URL", self.unit_path().read_text()) - self.run.assert_not_called() - - def test_repeat_keeps_binary_inodes(self): - self.prepare() - paths = [self.root / "native" / name for name in service.REQUIRED] - for path in (self.root / "native", *paths): - self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o700) - original = [(path.stat().st_ino, path.stat().st_mtime_ns, path.read_bytes()) for path in paths] - self.prepare() - self.assertEqual(original, [(path.stat().st_ino, path.stat().st_mtime_ns, path.read_bytes()) for path in paths]) - self.assertEqual(sorted(path.name for path in self.root.iterdir()), ["generated", "native"]) - - def test_changed_payload_refuses_without_overwriting_installed_binary(self): - self.prepare() - installed = self.root / "native/bin/oac-core" - before = installed.read_bytes() - (self.bundle / "native/bin/oac-core").write_bytes(b"changed") - with self.assertRaisesRegex(RuntimeError, "differ"): - self.prepare() - self.assertEqual(installed.read_bytes(), before) - self.run.assert_not_called() - - def test_ambiguous_paths_and_foreign_units_refuse(self): - for suffix in ("bad\npath", "bad*path", "bad\\path", "bad\x7fpath", 'bad"path', "bad'path"): - with self.assertRaises(RuntimeError): - service.prepare(self.directory / suffix, self.state, self.bundle) - for project in ("other-service", "../oac-0123456789", "oac-0123456789\n"): - state = dict(self.state, project=project) - with self.assertRaises(RuntimeError): - service.prepare(self.root, state, self.bundle) - with self.assertRaises(RuntimeError): - service.stop(self.root, state) - with self.assertRaises(RuntimeError): - service.active(state) - self.run.assert_not_called() - - def test_symlink_payload_is_not_followed(self): - path = self.bundle / "native/bin/oac-core" - path.unlink() - outside = self.directory / "outside" - outside.write_bytes(b"unchanged") - path.symlink_to(outside) - with self.assertRaisesRegex(RuntimeError, "regular"): - self.prepare() - self.assertEqual(outside.read_bytes(), b"unchanged") - self.assertFalse((self.root / "native").exists()) - - def test_preflight_checks_host_and_libraries_without_running_provider(self): - self.host_ready() - service.preflight(self.bundle, self.root) - commands = [call.args[0] for call in self.run.call_args_list] - self.assertEqual(commands[:2], [["systemctl", "--user", "show", "--property=Version", "--value"], - ["loginctl", "show-user", str(service.os.getuid()), "--property=Linger", "--value"]]) - self.assertEqual(commands[2:], [["ldd", str(self.bundle / "native" / name)] for name in service.REQUIRED]) - - def test_preflight_requires_linger_without_kvm_checks(self): - self.host_ready() - with mock.patch.object(service.os, "access", side_effect=AssertionError("No Core device checks")): - service.preflight(self.bundle, self.root) - self.run.reset_mock() - self.run.side_effect = lambda arguments, **kwargs: subprocess.CompletedProcess(arguments, 0, "no\n", "") - with self.assertRaisesRegex(RuntimeError, "lingering"): - service.preflight(self.bundle, self.root) - self.assertFalse(any(call.args[0][0] == "ldd" for call in self.run.call_args_list)) - - def test_failed_commands_do_not_disclose_diagnostics(self): - self.host_ready() - self.run.side_effect = None - self.run.return_value = subprocess.CompletedProcess([], 1, "synthetic-secret", "synthetic-secret") - with self.assertRaises(RuntimeError) as error: - service.preflight(self.bundle, self.root) - self.assertNotIn("synthetic-secret", str(error.exception)) - self.prepare() - self.unit_path() - self.run.side_effect = OSError("synthetic-secret") - with self.assertRaises(RuntimeError) as error: - service.start(self.root, self.state) - self.assertNotIn("synthetic-secret", str(error.exception)) - - def test_missing_shared_library_refuses(self): - self.host_ready() - self.run.side_effect = lambda arguments, **kwargs: subprocess.CompletedProcess( - arguments, 0, "libc.so => not found" if arguments[0] == "ldd" else "yes\n", "") - with self.assertRaisesRegex(RuntimeError, "shared libraries"): - service.preflight(self.bundle, self.root) - - def test_commands_target_only_this_installation(self): - self.prepare() - unit = self.unit_path() - service.start(self.root, self.state) - service.stop(self.root, self.state) - self.assertTrue(service.active(self.state)) - self.run.return_value = subprocess.CompletedProcess([], 3, "", "") - self.assertFalse(service.active(self.state)) - self.assertEqual([call.args[0] for call in self.run.call_args_list], [ - ["systemctl", "--user", "daemon-reload"], - ["systemctl", "--user", "enable", "--now", str(unit)], - ["systemctl", "--user", "stop", "oac-0123456789-core.service"], - ["systemctl", "--user", "is-active", "--quiet", "oac-0123456789-core.service"], - ["systemctl", "--user", "is-active", "--quiet", "oac-0123456789-core.service"], - ]) - - -if __name__ == "__main__": - unittest.main() diff --git a/deploy/install/test_oac.py b/deploy/install/test_oac.py index c1c23ec5..ef2eade1 100644 --- a/deploy/install/test_oac.py +++ b/deploy/install/test_oac.py @@ -6,13 +6,11 @@ import stat import subprocess import tempfile -from types import SimpleNamespace import unittest from unittest import mock import config_model import install -import native_service import oac_cli from installer_fakes import FakeHost @@ -69,46 +67,6 @@ def test_default_parent_is_private_and_symlinks_are_refused(self): oac_cli.private_parent(linked / "core") self.assertFalse((parent / "core").exists()) - def test_old_paired_core_refuses_apply_without_state_writes(self): - self.host.remote_core["https://core.example"] = (200, self.host.core_installation_id) - self.install("web-only", **{"web.core_url": "https://core.example"}) - oac_cli.create_private(self.root / "secrets/core.key.new", "interrupted rotation key") - before = {str(p.relative_to(self.root)): p.read_bytes() for p in self.root.rglob("*") if p.is_file()} - self.host.remote_core["https://core.example"] = (404, None) - with self.assertRaisesRegex(oac_cli.OacError, "not supported;.*reinstall"): - self.apply() - self.assertEqual(before, {str(p.relative_to(self.root)): p.read_bytes() for p in self.root.rglob("*") if p.is_file()}) - self.assertEqual(self.host.recreated, []) - - def test_web_start_checks_written_core_before_starting_services(self): - self.host.remote_core["https://core.example"] = (200, self.host.core_installation_id) - self.install("web-only", **{"web.core_url": "https://core.example"}) - for edited in (False, True): - if edited: - self.edit(lambda config: config["web"].update(core_url="https://unapplied.example")) - for status in (404, 200): - with self.subTest(edited=edited, status=status): - oac_cli.stop(self.root, out=self.output.append) - self.host.recreated.clear() - self.host.remote_core["https://core.example"] = (status, self.host.core_installation_id) - self.host.remote_core["https://unapplied.example"] = (200 if status == 404 else 404, None) - before = {str(p.relative_to(self.root)): p.read_bytes() - for p in self.root.rglob("*") if p.is_file()} - with mock.patch.object(oac_cli, "http", wraps=self.host.http) as requests: - if status == 404: - with self.assertRaisesRegex(oac_cli.OacError, "not supported;.*reinstall"): - oac_cli.start(self.root, out=self.output.append) - self.assertEqual(self.host.running(), set()) - self.assertEqual(self.host.recreated, []) - else: - oac_cli.start(self.root, out=self.output.append) - self.assertEqual(self.host.running(), {"web"}) - urls = [call.args[0] for call in requests.call_args_list] - self.assertIn("https://core.example/core/v1/installation", urls) - self.assertFalse(any("unapplied.example" in url for url in urls)) - self.assertEqual(before, {str(p.relative_to(self.root)): p.read_bytes() - for p in self.root.rglob("*") if p.is_file()}) - def test_foreign_operator_refuses_all_mutations_before_lock_creation(self): self.install() (self.root / ".oac.lock").unlink() @@ -119,17 +77,10 @@ def test_foreign_operator_refuses_all_mutations_before_lock_creation(self): operation(self.root) self.assertEqual(before, {str(p.relative_to(self.root)): p.read_bytes() for p in self.root.rglob("*") if p.is_file()}) - def install(self, mode="all", native=False, **values): - if native: - values["ports.database"] = 15432 - config = config_model.initial(mode, native, **values) - key = None - if mode == "web-only": - key = self.work / "existing.key" - key.write_text("k" * 40) - key.chmod(0o600) - images = {name: IMAGES[name] for name in install.image_names(mode, native)} - install.create(self.root, SimpleNamespace(core_key_file=key), config, {"source_commit": "a" * 40}, images) + def install(self, **values): + config = config_model.initial(**values) + images = {name: IMAGES[name] for name in install.image_names()} + install.create(self.root, config, {"source_commit": "a" * 40}, images) # These tests use finished installations; the installer records that after the first start. oac_cli.save_state(self.root, dict(oac_cli.load_state(self.root), complete=True)) self.apply(start=True) @@ -169,10 +120,9 @@ def assertConverged(self): self.assertEqual((actual[name]["running"], actual[name]["inputs"]), (True, digest), name) for name, text in rendered.files.items(): self.assertEqual(oac_cli.comparable(name, disk[name]), oac_cli.comparable(name, text), name) - if config["mode"] != "web-only": - key = (self.root / "secrets/core.key").read_text() - url = f'http://127.0.0.1:{config["ports"]["core"]}/core/v1/installation' - self.assertEqual(self.host.http(url, oac_cli.bearer(key))[0], 200) + key = (self.root / "secrets/core.key").read_text() + url = f'http://127.0.0.1:{config["ports"]["core"]}/core/v1/installation' + self.assertEqual(self.host.http(url, oac_cli.bearer(key))[0], 200) for line in self.status(): self.assertNotRegex(line, "edited by hand|other inputs|not applied|rejects|unavailable") @@ -195,25 +145,6 @@ def test_apply_changes_a_port_and_the_log_level_and_recreates_only_affected_serv self.assertIn("Nothing to apply.", self.output) self.assertConverged() - def test_native_core_restarts_only_when_its_inputs_change(self): - self.install(native=True) - self.edit(lambda config: config["ports"].update(web=18080)) - self.apply() - self.assertEqual((self.host.native["restarts"], self.host.recreated), (0, ["web"])) - self.host.recreated.clear() - self.edit(lambda config: config["ports"].update(core=18091)) - self.apply() - self.assertEqual((self.host.native["restarts"], self.host.native["addr"]), (1, 18091)) - self.assertEqual(self.host.recreated, ["web"]) - web = json.loads(self.generated("compose.json"))["services"]["web"] - self.assertEqual(web["environment"]["OAC_WEB_UPSTREAM"], "http://127.0.0.1:18091") - # A repair (install.sh rerun) applies with start; the active unit still restarts for new inputs. - self.edit(lambda config: config["log"].update(level="debug")) - self.apply(start=True) - self.assertEqual(self.host.native["restarts"], 2) - self.assertIn('OAC_LOG_LEVEL="debug"', self.host.native["environment"]) - self.assertConverged() - def test_a_stopped_installation_stays_stopped(self): self.install() oac_cli.stop(self.root, out=self.output.append) @@ -250,10 +181,10 @@ def test_hand_edits_are_refused_until_discarded_and_missing_files_are_rewritten( def test_secrets_fixed_fields_and_directories_are_checked(self): self.install() - self.edit(lambda config: config.update(native_core=True, ports=dict(config["ports"], database=15432))) - with self.assertRaisesRegex(oac_cli.OacError, "native_core is fixed"): + self.edit(lambda config: config.update(ingress="managed", host="0.0.0.0")) + with self.assertRaisesRegex(oac_cli.OacError, "ingress is fixed"): self.apply() - self.edit(lambda config: (config.pop("native_core"), config["ports"].pop("database"))) + self.edit(lambda config: config.update(ingress="external", host="127.0.0.1")) (self.root / "generated").chmod(0o755) with self.assertRaisesRegex(oac_cli.OacError, "generated/ must be a directory with mode 0700"): self.apply() @@ -283,18 +214,6 @@ def test_rotate_core_key(self): self.assertEqual(self.host.http(url, oac_cli.bearer(old))[0], 401) self.assertConverged() - def test_web_only_neither_rotates_nor_sends_its_key_to_an_unapplied_core(self): - self.host.remote_core["https://core.example"] = (200, self.host.core_installation_id) - self.install("web-only", **{"web.core_url": "https://core.example"}) - with self.assertRaisesRegex(oac_cli.OacError, "Core owns the Core key"): - oac_cli.rotate_core_key(self.root, yes=True, out=self.output.append) - self.edit(lambda config: config["web"].update(core_url="https://other.example")) - calls = [] - with mock.patch.object(oac_cli, "http", side_effect=lambda url, *a, **k: calls.append(url) or (0, b"")): - self.apply(dry_run=True) - self.assertEqual(calls, []) - self.assertIn("web.core_url changes; apply checks which Core it reaches.", self.output) - def test_public_url_change_lists_bindings_and_requires_confirmation(self): self.install(public_url="https://core.example") self.host.bindings.update(nodes=2, hosted_sandboxes=2) @@ -348,21 +267,16 @@ def test_core_startup_failure_rolls_back_only_from_a_converged_start(self): self.assertConverged() def test_repeated_rolled_back_applies_leave_no_false_edit(self): - for native in (False, True): - with self.subTest(native=native): - self.root = self.work / f"repeated-{native}" - self.host.native_root, self.host.containers = self.root, {} - self.host.native.update(active=False, inputs=None, loaded=None) - self.install(native=native) - self.host.core["rejects"] = lambda environment: 'OAC_EXECUTION_CONCURRENCY="4"' not in environment - for value in (5, 6, 7, 8): - self.edit(lambda config: config["core"].update(execution_concurrency=value)) - with self.assertRaisesRegex(oac_cli.OacError, "services converged on them"): - self.apply() - self.host.core["rejects"] = lambda environment: False - self.edit(lambda config: config["core"].update(execution_concurrency=4)) + self.install() + self.host.core["rejects"] = lambda environment: 'OAC_EXECUTION_CONCURRENCY="4"' not in environment + for value in (5, 6, 7, 8): + self.edit(lambda config: config["core"].update(execution_concurrency=value)) + with self.assertRaisesRegex(oac_cli.OacError, "services converged on them"): self.apply() - self.assertConverged() + self.host.core["rejects"] = lambda environment: False + self.edit(lambda config: config["core"].update(execution_concurrency=4)) + self.apply() + self.assertConverged() def test_a_hand_edit_restored_by_a_rollback_is_still_reported(self): self.install() @@ -385,35 +299,29 @@ def test_the_next_apply_finishes_any_interrupted_apply_rotation_or_rollback(self "after Core": (oac_cli, "compose", compose_up()), "before health": (oac_cli, "health", lambda *a: True), } - native_stages = dict(compose_stages, **{ - "after reload": (native_service, "restart", lambda *a: True), - "after Core": (oac_cli, "compose", compose_up()), - }) - for native in (False, True): - for stage, (target, name, when) in (native_stages if native else compose_stages).items(): - for operation in ("apply", "rotate", "rollback"): - with self.subTest(native=native, stage=stage, operation=operation): - self.root = self.work / f"{native}-{stage}-{operation}".replace(" ", "-") - self.host.native_root, self.host.containers = self.root, {} - self.host.native.update(active=False, inputs=None, loaded=None) - self.install(native=native) - if operation == "rotate": - action = lambda: oac_cli.rotate_core_key(self.root, yes=True, out=self.output.append) - else: - self.edit(lambda config: (config["log"].update(level="debug"), - config["ports"].update(web=18080))) - action = self.apply - self.host.core.update(fails=operation == "rollback", failed=False) - # A rollback is interrupted in what it does after Core failed. - trigger = (lambda *a, when=when, **k: self.host.core["failed"] and when(*a, **k)) \ - if operation == "rollback" else when - with interrupt(target, name, trigger), \ - self.assertRaises((KeyboardInterrupt, oac_cli.OacError)) as raised: - action() - self.assertNotIn(": .", str(raised.exception)) - self.host.core.update(fails=False, failed=False) - self.apply() - self.assertConverged() + for stage, (target, name, when) in compose_stages.items(): + for operation in ("apply", "rotate", "rollback"): + with self.subTest(stage=stage, operation=operation): + self.root = self.work / f"{stage}-{operation}".replace(" ", "-") + self.host.containers = {} + self.install() + if operation == "rotate": + action = lambda: oac_cli.rotate_core_key(self.root, yes=True, out=self.output.append) + else: + self.edit(lambda config: (config["log"].update(level="debug"), + config["ports"].update(web=18080))) + action = self.apply + self.host.core.update(fails=operation == "rollback", failed=False) + # A rollback is interrupted in what it does after Core failed. + trigger = (lambda *a, when=when, **k: self.host.core["failed"] and when(*a, **k)) \ + if operation == "rollback" else when + with interrupt(target, name, trigger), \ + self.assertRaises((KeyboardInterrupt, oac_cli.OacError)) as raised: + action() + self.assertNotIn(": .", str(raised.exception)) + self.host.core.update(fails=False, failed=False) + self.apply() + self.assertConverged() def test_uninstall_removes_the_services_volumes_unused_images_and_directory(self): self.install() @@ -442,7 +350,7 @@ def test_uninstall_changes_nothing_unless_confirmed(self): self.assertFalse(any("down" in command or "rm" in command for command in self.host.commands)) def test_uninstall_removes_an_installation_that_never_finished(self): - install.create(self.root, SimpleNamespace(core_key_file=None), config_model.initial("all", False), + install.create(self.root, config_model.initial(), {"source_commit": "a" * 40}, IMAGES) (self.root / "config.json").unlink() installation = oac_cli.load_state(self.root)["installation_id"] diff --git a/deploy/install/test_sandbox_setup.py b/deploy/install/test_sandbox_setup.py index 3ecdec5d..105dd05b 100644 --- a/deploy/install/test_sandbox_setup.py +++ b/deploy/install/test_sandbox_setup.py @@ -30,7 +30,7 @@ def setUp(self): self.root = Path(temporary.name) (self.root / "secrets").mkdir() (self.root / "secrets/core.key").write_text("fixture-core-key\n") - self.config, self.state = config_model.initial("all"), {"installation_id": INSTALLATION} + self.config, self.state = config_model.initial(), {"installation_id": INSTALLATION} def initialize(self, current, selection): requests = [] diff --git a/docs/configuration.md b/docs/configuration.md index c629d849..d653d1bd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,7 +13,7 @@ Web's **System** page shows the installation's addresses, the default models, th ## Process settings: config.json -The installer writes every setting that applies to the installation's [mode](./getting-started/install-options.md#modes), so the file shows each value. Installer flags listed in [installation options](./getting-started/install-options.md) only seed it. To change a setting, edit the file and apply it: +The installer writes every setting, so the file shows each value. Installer flags listed in [installation options](./getting-started/install-options.md) only seed it. To change a setting, edit the file and apply it: ```sh ~/.oac/core/oac apply --dry-run # show the changed settings, files and restarts @@ -22,9 +22,9 @@ The installer writes every setting that applies to the installation's [mode](./g ### How oac apply works -1. It validates `config.json` and changes nothing if a value is invalid. `mode`, `native_core` and `ingress` are fixed after installation; to change them, install into a new directory. It also checks the listeners that a changed `host`, port or managed-ingress `public_url` adds ([ports](./getting-started/install-options.md#ports)), and changes nothing if `host` is not an address of this machine or another program holds one of their ports; the installation's own listeners do not count. -2. It writes the files Core, Web and Compose read into `generated/`: `compose.json`, `core.env`, `core-key-digests.json`, `settings.json` and, when used, `runtime-history.json`, the managed `Caddyfile` and the native Core unit. Don't edit them. A generated file edited by hand stops `apply` until you move the change into `config.json` and run `oac apply --discard-edits`, which keeps the edited copy as `generated/.edited-