Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ An existing directory runs the release named by `OAC_REVISION` in its `compose.y

`install.py` prepares a new installation in a `.staging-*` directory inside the installation directory: the node payload, `.env`, `oac` and both Compose files. After `docker compose config` accepts them, it moves them into place, `compose.yaml` last. An earlier failure removes the stage, and the directory too when the run created it, so the same command can be rerun; the next run also removes a stage left by a killed run. A directory with other contents but no `compose.yaml` is refused.

The host `oac` command also implements `apply`, `core-key` and `rotate-core-key`. `apply` runs init, which finishes a first start that stopped before it and refuses a data volume committed to another release, then checks Core configuration before starting services and recreating those that changed. Reading the key executes `oac-web core-key` in Web. Rotation runs in the initialization container, where Linux file ownership is identical on every host, then restarts Core and Web. Start, stop, logs and removal use `docker compose`. The initialization image verifies and copies bundled node metadata without network access. Its receipt authenticates immutable metadata and credentials; the operator-rotatable Core key and its digest remain mutable. No service receives a Docker socket. Initialization emits structured step logs without credential values; view them with `docker compose logs --timestamps init`.
The host `oac` command also implements `apply`, `core-key`, `rotate-core-key` and `uninstall`. `apply` runs init, which finishes a first start that stopped before it and refuses a data volume committed to another release, then checks Core configuration before starting services and recreating those that changed. Reading the key executes `oac-web core-key` in Web. Rotation runs in the initialization container, where Linux file ownership is identical on every host, then restarts Core and Web. Start, stop and logs use `docker compose`; `oac uninstall` removes the installation, its data volume, the lock and the sidecar. The initialization image verifies and copies bundled node metadata without network access. Its receipt authenticates immutable metadata and credentials; the operator-rotatable Core key and its digest remain mutable. No service receives a Docker socket. Initialization emits structured step logs without credential values; view them with `docker compose logs --timestamps init`.

Web serves the console and forwards `/v1` and `/api/v1` to Core, so it is the only published service. HTTPS is terminated by the operator's reverse proxy or hosting platform, which routes to `web:8080`; `OAC_PUBLIC_URL` records that origin.

Expand Down
28 changes: 22 additions & 6 deletions deploy/install-source.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
from urllib.parse import urlsplit
import uuid

# The bundle ships the node installer's distribution modules beside this file.
Expand All @@ -20,8 +20,24 @@
import node_payload


# Matches Core's OAC_PUBLIC_URL rule (publicorigin.New): a canonical http(s) origin with no path or credentials.
PUBLIC_ORIGIN = re.compile(r'https?://(?P<host>(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?|\[(?P<ip6>[0-9a-f:.]+)\])(?::(?P<port>[1-9][0-9]{0,4}))?')


def valid_public_origin(value):
match = PUBLIC_ORIGIN.fullmatch(value)
if not match or len(match['host']) > 253 or int(match['port'] or 1) > 65535:
return False
try:
return match['ip6'] is None or bool(ipaddress.IPv6Address(match['ip6']))
except ValueError:
return False


def run(failure, *args, **kwargs):
"""Run a command whose output the user sees; a nonzero exit raises failure."""
# The installation's .env is the one home of OAC_* and COMPOSE_* settings, so the caller's shell cannot override them.
kwargs['env'] = {name: value for name, value in os.environ.items() if not name.upper().startswith(('OAC_', 'COMPOSE_'))}
if subprocess.run(args, **kwargs).returncode:
raise ValueError(failure)

Expand Down Expand Up @@ -90,8 +106,9 @@ def install(bundle, destination, host, port, public_url, upgrade):
except ValueError:
raise ValueError('compose.override.yaml must stay JSON so the installer can replace its own entries') from None
services = override.setdefault('services', {})
for name, image in (('core', 'core'), ('web', 'web'), ('init', 'ingress'), ('database', 'database')):
services.setdefault(name, {})['image'] = images[image]
# compose.yaml is the one home of the image references; earlier installers also wrote them here.
for name in ('core', 'web', 'init', 'database'):
services.setdefault(name, {}).pop('image', None)
web = services['web']
web['volumes'] = [volume for volume in web.get('volumes', []) if not (isinstance(volume, dict) and volume.get('target') == '/node-payload')]
web['volumes'].append({'type': 'bind', 'source': str(destination / 'node-payload'), 'target': '/node-payload', 'read_only': True})
Expand Down Expand Up @@ -158,9 +175,8 @@ def main():
if not 1 <= args.web_port <= 65535:
parser.error('web port must be between 1 and 65535')
if args.public_url is not None:
url = urlsplit(args.public_url)
if url.scheme not in ('http', 'https') or not url.hostname or url.username or url.password or url.path not in ('', '/') or url.query or url.fragment or any(c in args.public_url for c in "\r\n'\\$"):
parser.error('public URL must be an HTTP(S) origin without credentials')
if not valid_public_origin(args.public_url):
parser.error('public URL must be a canonical http or https origin without a path, such as https://core.example')
destination = args.install_dir.resolve()
try:
install(BUNDLE, destination, args.host, args.web_port, args.public_url, args.upgrade)
Expand Down
17 changes: 16 additions & 1 deletion deploy/test_install_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ def test_upgrade_checks_the_new_release_before_replacing_it_and_keeps_operator_e
override = json.loads((self.destination / 'compose.override.yaml').read_text())
nfs = {'type': 'bind', 'source': '/srv/workspaces', 'target': '/srv/workspaces'}
override['services']['core']['volumes'] = [nfs]
override['services']['core']['image'] = 'old'
(self.destination / 'compose.override.yaml').write_text(json.dumps(override))
before = self.snapshot()
upgraded = 'b' * 40
Expand All @@ -134,7 +135,8 @@ def test_upgrade_checks_the_new_release_before_replacing_it_and_keeps_operator_e
services = json.loads((self.destination / 'compose.override.yaml').read_text())['services']
self.assertEqual(services['core']['volumes'], [nfs])
self.assertEqual(os.stat(self.destination / 'compose.override.yaml').st_mode & 0o777, 0o600)
self.assertEqual({services[name]['image'] for name in ('core', 'web', 'init', 'database')}, {'sha256:' + '2' * 64})
self.assertFalse([name for name, service in services.items() if 'image' in service])
self.assertEqual((self.destination / 'compose.yaml').read_text().count('sha256:' + '2' * 64), 4)
self.assertEqual([volume['target'] for volume in services['web']['volumes']], ['/node-payload'])
self.assertIn('OAC_REVISION: ' + upgraded, (self.destination / 'compose.yaml').read_text())
self.assertEqual((self.destination / 'oac').read_bytes(), b'upgraded operator')
Expand All @@ -151,6 +153,19 @@ def test_thin_bundle_rejected_before_creating_installation(self):
self.install()
self.assertFalse(self.destination.exists())

def test_docker_children_ignore_the_shells_installation_settings(self):
with patch.dict(os.environ, {'COMPOSE_PROJECT_NAME': 'other', 'OAC_PUBLIC_URL': 'https://other.example', 'PATH_KEEP': '1'}), patch.object(subprocess, 'run') as run:
run.return_value.returncode = 0
installer.run('failed', 'docker', 'compose', 'up')
env = run.call_args.kwargs['env']
self.assertEqual((env.get('PATH_KEEP'), 'COMPOSE_PROJECT_NAME' in env, 'OAC_PUBLIC_URL' in env), ('1', False, False))

def test_public_url_must_be_what_core_accepts(self):
for value in ('https://core.example', 'http://10.0.0.5:8080', 'http://[::1]:8091'):
self.assertTrue(installer.valid_public_origin(value), value)
for value in ('https://core.example/', 'https://Core.example', 'https://core.example/path', 'https://core.example?', 'https://user@core.example', 'https://core.example:0', 'https://core.example:65536', 'ftp://core.example', 'https://core..example'):
self.assertFalse(installer.valid_public_origin(value), value)

def test_failures_exit_with_one_line_and_the_next_step(self):
(self.bundle / 'SHA256SUMS').write_text('0' * 64 + ' manifest.json\n')
self.assertEqual(self.main(), 'Distribution manifest checksum mismatch')
Expand Down
8 changes: 5 additions & 3 deletions docs/getting-started/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,9 @@ For an installation whose data all lives in its Docker volume, with no independe
cd ~/.oac/core
docker compose stop
project=$(sed -n 's/^COMPOSE_PROJECT_NAME=//p' .env)
image=$(docker compose images --quiet database)
mkdir -p ~/oac-backup
docker run --rm -v "${project}_data:/data:ro" -v ~/oac-backup:/backup --entrypoint tar postgres:16-alpine czf /backup/data.tar.gz -C /data .
docker run --rm -v "${project}_data:/data:ro" -v ~/oac-backup:/backup --entrypoint tar "$image" czf /backup/data.tar.gz -C /data .
cp -a ~/.oac/core ~/oac-backup/installation
docker compose start
```
Expand All @@ -150,7 +151,8 @@ cd ~/.oac/core
project=$(sed -n 's/^COMPOSE_PROJECT_NAME=//p' .env)
docker compose pull
docker compose up --no-start
docker run --rm -v "${project}_data:/data" -v ~/oac-backup:/backup:ro --entrypoint tar postgres:16-alpine xzf /backup/data.tar.gz -C /data
image=$(docker compose images --quiet database)
docker run --rm -v "${project}_data:/data" -v ~/oac-backup:/backup:ro --entrypoint tar "$image" xzf /backup/data.tar.gz -C /data
./oac apply
```

Expand Down Expand Up @@ -247,7 +249,7 @@ A rollback restores the backup taken before the upgrade: stop the services, [res
~/.oac/core/oac uninstall
```

On Windows, run `& "$HOME/.oac/core/oac.exe" uninstall`. The command shows the Compose project, the data volume, the installation directory and the installation ID, and deletes nothing until you type the directory name or `yes`. It then removes the containers, the network and the data volume, the installation directory and the [installation lock](../configuration.md#installation-directory) (`~/.oac/core.lock`). On Windows the running `oac.exe` cannot delete itself: the command prints the `Remove-Item` that removes the rest. It keeps the Docker images so that a reinstall doesn't download them again, and prints the `docker image rm` command that removes them. Run it again and it reports that nothing is installed.
On Windows, run `& "$HOME/.oac/core/oac.exe" uninstall`. The command shows the Compose project, the data volume, the installation directory and the installation ID, and deletes nothing until you type the directory name or `yes`. It then removes the containers, the network and the data volume, the installation directory and the [installation lock](../configuration.md#installation-directory) (`~/.oac/core.lock`). On Windows the running `oac.exe` cannot delete itself: the command prints the `Remove-Item` that removes the rest, and you run it before you reinstall into the same directory. It keeps the Docker images so that a reinstall doesn't download them again, and prints the `docker image rm` command that removes them.

All data goes with it: Projects and API keys, Session history, stored credentials and the Core key. To keep the data, stop the installation with `docker compose stop` instead, or [back it up](#back-up) first.

Expand Down
2 changes: 1 addition & 1 deletion docs/maintainers.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ Distribution and Runtime archives use `pigz` level 6 with at most four compressi

Version releases and manual `build-<full SHA>` drafts publish `ghcr.io/minimax-ai/openagentcore/<component>:<version>`, where `<component>` is `core`, `web`, `runtime`, `ingress` or `postgres`. Core, Web and ingress indexes contain Linux amd64 and arm64 images; Runtime contains Linux amd64. Platform images use `<version>-<architecture>` tags and are loaded from the release archives. Existing version tags must match the release images and platform set. The publisher verifies every version index before updating `latest` for a stable release; prereleases and drafts leave `latest` unchanged. `postgres` is the official PostgreSQL index pinned in `deploy/compose/database-image.txt`, copied with all its platforms and its digest; the publisher refuses a registry tag that names another digest. To move PostgreSQL, change that file to a new digest of the same major version, which is the one place the template, the offline bundle and the mirror read. SemVer build metadata uses `_` in place of `+` in container tags; version strings are limited to 128 characters. After verifying the images, the publisher uploads the release's `compose.yaml` and checksum list. Compose pins Core, Web, ingress and PostgreSQL by their release index digests, and initialization checks its build revision against the Compose revision.

The combined build/publication job uses `GITHUB_TOKEN` with `packages: write`. On the first publication, GitHub creates each container package as private: a package administrator must change all four packages to **Public** in their package settings before users can pull anonymously. See [GitHub container visibility](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry). Verify an unauthenticated pull after changing visibility. Repository visibility alone does not make a new container package public.
The combined build/publication job uses `GITHUB_TOKEN` with `packages: write`. On the first publication, GitHub creates each container package as private: a package administrator must change all five packages (`core`, `web`, `runtime`, `ingress` and `postgres`) to **Public** in their package settings before users can pull anonymously. See [GitHub container visibility](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry). Verify an unauthenticated pull after changing visibility. Repository visibility alone does not make a new container package public.

GHCR and GitHub Releases do not share a transaction. A failed release may leave some matching version tags in GHCR; preserve those images and follow the draft recovery procedure below using the original artifacts. Registry failures other than a missing manifest stop publication. The job summary records digest-pinned references. These images and the rendered Compose files still require the configuration, secrets and routing described in [Configuration](./configuration.md).

Expand Down
10 changes: 6 additions & 4 deletions docs/zh/getting-started/operations.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "运维"
source: docs/getting-started/operations.md
source_hash: daba721bddebf8d6544ddef597390307a7b4a28f041978133acc200004812d69
source_hash: 1f07f9a80da3ad0218949a4856093d716d9d3af2b40426be9e74e3bd0b3ca18b
---

安装运维人员负责 Core 主机、存储和可用性。节点主机运行各自的服务;参阅[节点](nodes.md)。设置见[配置参考](../configuration.md)。
Expand Down Expand Up @@ -138,8 +138,9 @@ Core 记录每次公开资源写入所使用的密钥;历史保留策略为 [`
cd ~/.oac/core
docker compose stop
project=$(sed -n 's/^COMPOSE_PROJECT_NAME=//p' .env)
image=$(docker compose images --quiet database)
mkdir -p ~/oac-backup
docker run --rm -v "${project}_data:/data:ro" -v ~/oac-backup:/backup --entrypoint tar postgres:16-alpine czf /backup/data.tar.gz -C /data .
docker run --rm -v "${project}_data:/data:ro" -v ~/oac-backup:/backup --entrypoint tar "$image" czf /backup/data.tar.gz -C /data .
cp -a ~/.oac/core ~/oac-backup/installation
docker compose start
```
Expand All @@ -152,7 +153,8 @@ cd ~/.oac/core
project=$(sed -n 's/^COMPOSE_PROJECT_NAME=//p' .env)
docker compose pull
docker compose up --no-start
docker run --rm -v "${project}_data:/data" -v ~/oac-backup:/backup:ro --entrypoint tar postgres:16-alpine xzf /backup/data.tar.gz -C /data
image=$(docker compose images --quiet database)
docker run --rm -v "${project}_data:/data" -v ~/oac-backup:/backup:ro --entrypoint tar "$image" xzf /backup/data.tar.gz -C /data
./oac apply
```

Expand Down Expand Up @@ -249,7 +251,7 @@ v0.0.10 及更高版本的安装可以原地升级到更新的发布版本,并
~/.oac/core/oac uninstall
```

Windows 上运行 `& "$HOME/.oac/core/oac.exe" uninstall`。命令会展示 Compose 项目、数据卷、安装目录和安装 ID,在你输入目录名或 `yes` 之前不会删除任何内容。随后它删除容器、网络和数据卷、安装目录以及[安装锁](../configuration.md#installation-directory)(`~/.oac/core.lock`)。Windows 上正在运行的 `oac.exe` 无法删除自身:命令会打印删除其余内容的 `Remove-Item`。它保留 Docker 镜像,重新安装时不必再次下载,并打印删除镜像的 `docker image rm` 命令。再次运行会提示没有已安装的内容。
Windows 上运行 `& "$HOME/.oac/core/oac.exe" uninstall`。命令会展示 Compose 项目、数据卷、安装目录和安装 ID,在你输入目录名或 `yes` 之前不会删除任何内容。随后它删除容器、网络和数据卷、安装目录以及[安装锁](../configuration.md#installation-directory)(`~/.oac/core.lock`)。Windows 上正在运行的 `oac.exe` 无法删除自身:命令会打印删除其余内容的 `Remove-Item`,在同一目录重新安装之前先运行它。它保留 Docker 镜像,重新安装时不必再次下载,并打印删除镜像的 `docker image rm` 命令。

全部数据随之删除:项目和 API 密钥、Session 历史、存储的凭据和 Core 密钥。要保留数据,请用 `docker compose stop` 停止安装,或先[备份](#back-up)。

Expand Down
Loading
Loading