From 1c19589e6eaad9e37a401148030a7f0e032cf16d Mon Sep 17 00:00:00 2001 From: yuanhe Date: Fri, 2 Oct 2026 23:35:58 +0800 Subject: [PATCH 01/23] Publish each release's Compose files and draft images. The release renders compose.yaml from the template with that build's image digests and uploads it with ports.yaml. Manual build- drafts push the same GHCR images, so a candidate can be pulled before a version tag exists. Co-authored-by: Cursor --- .github/workflows/release.yml | 6 +- deploy/compose/compose.yaml | 121 ++++++++++++--------- deploy/compose/local.yaml | 4 - deploy/compose/ports.yaml | 5 + deploy/compose/smoke-pins.json | 8 ++ deploy/install/test_compose.py | 62 +++++++---- docs/configuration.md | 20 ++-- docs/getting-started/install-options.md | 14 +-- docs/maintainers.md | 10 +- docs/zh/configuration.md | 22 ++-- docs/zh/getting-started/install-options.md | 16 +-- docs/zh/maintainers.md | 12 +- scripts/ci_plan.py | 4 +- scripts/ci_plan_test.py | 4 +- scripts/compose-smoke.py | 22 +++- scripts/publish-core-release.py | 31 +++++- scripts/publish-core-release.test.py | 14 ++- scripts/render-compose.py | 67 ++++++++++++ 18 files changed, 299 insertions(+), 143 deletions(-) delete mode 100644 deploy/compose/local.yaml create mode 100644 deploy/compose/ports.yaml create mode 100644 deploy/compose/smoke-pins.json create mode 100644 scripts/render-compose.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e67a7367..fbd241eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -141,8 +141,8 @@ jobs: compression-level: 0 if-no-files-found: error - - name: Sign in to GHCR for version releases - if: github.event_name == 'push' + - name: Sign in to GHCR + if: github.event_name == 'push' || inputs.draft_release env: GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -159,5 +159,5 @@ jobs: RELEASE_MODE: ${{ github.event_name == 'push' && 'publish' || 'draft' }} run: python3 scripts/publish-core-release.py --assets "$HOME/.oac/build/release-upload" - name: Remove registry credentials - if: always() && github.event_name == 'push' + if: always() && (github.event_name == 'push' || inputs.draft_release) run: rm -f "$RUNNER_TEMP/oac-release-docker/config.json" diff --git a/deploy/compose/compose.yaml b/deploy/compose/compose.yaml index 9f5b92a3..c7ae346d 100644 --- a/deploy/compose/compose.yaml +++ b/deploy/compose/compose.yaml @@ -1,8 +1,11 @@ -# OpenAgentCore v0.0.3. Images and installer payload are one matched release. -# Set OAC_PUBLIC_URL to your platform's HTTPS origin when ready; startup defaults to localhost. -x-ingress-image: &ingress-image ghcr.io/minimax-ai/openagentcore/ingress:v0.0.3@sha256:e485a8cae0b904389501f00bcee6a2c5b35be83412a9ff81a740f93b8cdbf326 +# Release template. scripts/render-compose.py fills the __OAC_*__ tokens with this +# release's image digests, source revision and node-metadata checksum. +# Do not run this file until it has been rendered. +# Data is bind-mounted from ${OAC_DATA_DIR:-./data}. Set OAC_PUBLIC_URL when the +# platform domain is ready; startup defaults to localhost. +x-ingress-image: &ingress-image __OAC_IMAGE_INGRESS__ x-core: &core - image: ghcr.io/minimax-ai/openagentcore/core:v0.0.3@sha256:6934a26d5cb7c878128ea8187ced446336345de7750c415be510740806663575 + image: __OAC_IMAGE_CORE__ platform: linux/amd64 user: "65532:65532" read_only: true @@ -20,9 +23,17 @@ x-core: &core OAC_NATIVE_INSTALLER_DIR: /opt/oac/native-installers OAC_HARNESSES: claude_sdk,codex,mcode volumes: - - core-config:/run/oac:ro - - database-secret:/run/database:ro - - core-state:/state + - type: bind + source: ${OAC_DATA_DIR:-./data}/secrets/core + target: /run/oac + read_only: true + - type: bind + source: ${OAC_DATA_DIR:-./data}/secrets/database + target: /run/database + read_only: true + - type: bind + source: ${OAC_DATA_DIR:-./data}/state + target: /state services: init: @@ -35,12 +46,9 @@ services: - source: init-script target: /init.py volumes: - - core-config:/data/core - - web-secret:/data/web - - database-secret:/data/database - - core-state:/data/state - - node-payload:/data/payload - - database:/data/database-data:ro + - type: bind + source: ${OAC_DATA_DIR:-./data} + target: /data database: image: postgres:16-alpine@sha256:1a66d744c1b459e13b05a8fca341da84cb63383e99ce262210efee5a319d4551 @@ -53,8 +61,13 @@ services: POSTGRES_DB: agents_api POSTGRES_PASSWORD_FILE: /run/database/password volumes: - - database:/var/lib/postgresql/data - - database-secret:/run/database:ro + - type: bind + source: ${OAC_DATA_DIR:-./data}/database + target: /var/lib/postgresql/data + - type: bind + source: ${OAC_DATA_DIR:-./data}/secrets/database + target: /run/database + read_only: true healthcheck: test: [CMD-SHELL, "pg_isready -h 127.0.0.1 -U agents_api -d agents_api"] interval: 2s @@ -79,7 +92,7 @@ services: migrate: {condition: service_completed_successfully} web: - image: ghcr.io/minimax-ai/openagentcore/web:v0.0.3@sha256:d1eb4cc8870aebd080773fd16db92a5a1db205e0aab10066d4db16f1284a88f5 + image: __OAC_IMAGE_WEB__ platform: linux/amd64 user: "65532:65532" restart: unless-stopped @@ -93,8 +106,14 @@ services: OAC_WEB_CORE_KEY_FILE: /run/oac/core.key OAC_WEB_NODE_PAYLOAD_DIR: /node-payload volumes: - - web-secret:/run/oac:ro - - node-payload:/node-payload:ro + - type: bind + source: ${OAC_DATA_DIR:-./data}/secrets/web + target: /run/oac + read_only: true + - type: bind + source: ${OAC_DATA_DIR:-./data}/node-payload + target: /node-payload + read_only: true gateway: image: *ingress-image @@ -137,15 +156,10 @@ services: logging: {driver: none} command: [cat, /run/oac/core.key] volumes: - - web-secret:/run/oac:ro - -volumes: - database: - database-secret: - core-config: - web-secret: - core-state: - node-payload: + - type: bind + source: ${OAC_DATA_DIR:-./data}/secrets/web + target: /run/oac + read_only: true configs: gateway-config: @@ -182,11 +196,12 @@ configs: import urllib.request import uuid - REVISION = 'cc7e1aad3bde47598d161d6372e2e211bb61d9cd' - BASE = 'https://github.com/MiniMax-AI/OpenAgentCore/releases/download/v0.0.3/' + REVISION = '__OAC_REVISION__' + BASE = '__OAC_RELEASE_BASE__' ARCHIVE = 'oac-' + REVISION + '-linux-amd64' - CHECKSUM = '579d2d43accfc45a0a8567db5bc33b407c48b05b0d731bea3fed73e6ade558ae' + CHECKSUM = '__OAC_ARCHIVE_CHECKSUM__' MEMBERS = ('manifest.json', 'SHA256SUMS', 'node-install.pyz', 'runtime/seccomp.json') + OWNERS = {'database': 70, 'secrets': 65532, 'state': 65532, 'caddy': 65532, 'domain': 65532, 'node-payload': 65532} def digest(data): return hashlib.sha256(data).hexdigest() @@ -217,55 +232,61 @@ configs: raise RuntimeError('Release identity mismatch') return files - def write(path, data): + def write(path, data, owner=65532): path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_name(path.name + '.tmp') temporary.write_bytes(data) temporary.chmod(0o600) - os.chown(temporary, 65532, 65532) + os.chown(temporary, owner, owner) os.replace(temporary, path) def initialize(root, fetch=download): - for name in ('core', 'web', 'database', 'state', 'payload'): + root.chmod(0o700) + for name, owner in OWNERS.items(): directory = root / name directory.mkdir(exist_ok=True) directory.chmod(0o700) + os.chown(directory, owner, owner) + for name in ('core', 'web', 'database'): + directory = root / 'secrets' / name + directory.mkdir(exist_ok=True) + directory.chmod(0o700) os.chown(directory, 65532, 65532) - with (root / 'core/.init.lock').open('w') as lock: + with (root / 'secrets/.init.lock').open('w') as lock: fcntl.flock(lock, fcntl.LOCK_EX) - marker = root / 'core/installation.json' + marker = root / 'installation.json' if marker.exists(): receipt = json.loads(marker.read_text()) if receipt['source_commit'] != REVISION: - raise RuntimeError('This volume belongs to another release; create a new installation') + raise RuntimeError('This data directory belongs to another release; create a new installation') for name, checksum in receipt['files'].items(): if digest((root / name).read_bytes()) != checksum: - raise RuntimeError('Installation files changed; restore the matching volumes') + raise RuntimeError('Installation files changed; restore the matching data directory') print('Existing installation verified', flush=True) return - if any((root / 'database-data').iterdir()) or any((root / 'state').iterdir()): - raise RuntimeError('Existing data requires its original installation volumes') + if any((root / 'database').iterdir()) or any((root / 'state').iterdir()): + raise RuntimeError('Existing data requires its original installation files') files = fetch() - prefix = 'payload/releases/' + REVISION + '/' + prefix = 'node-payload/releases/' + REVISION + '/' for name, data in files.items(): write(root / (prefix + name), data) - write(root / 'payload/active.json', json.dumps({'source_commit': REVISION}).encode()) - # Parent directories of the public payload must be traversable by Web. - for path in (root / 'payload').rglob('*'): + write(root / 'node-payload/active.json', json.dumps({'source_commit': REVISION}).encode()) + for path in (root / 'node-payload').rglob('*'): if path.is_dir(): path.chmod(0o755) + os.chown(path, 65532, 65532) generators = { - 'web/core.key': lambda: secrets.token_hex(32), - 'database/password': lambda: secrets.token_hex(32), - 'core/credential.key': lambda: base64.b64encode(secrets.token_bytes(32)).decode(), - 'core/installation.id': lambda: str(uuid.uuid4()), + 'secrets/web/core.key': lambda: secrets.token_hex(32), + 'secrets/database/password': lambda: secrets.token_hex(32), + 'secrets/core/credential.key': lambda: base64.b64encode(secrets.token_bytes(32)).decode(), + 'secrets/core/installation.id': lambda: str(uuid.uuid4()), } for name, generate in generators.items(): if not (root / name).exists(): write(root / name, (generate() + '\n').encode()) - key = (root / 'web/core.key').read_text().strip() - write(root / 'core/core-key-digests.json', json.dumps([digest(key.encode())]).encode()) - names = [*generators, 'core/core-key-digests.json', 'payload/active.json', + key = (root / 'secrets/web/core.key').read_text().strip() + write(root / 'secrets/core/core-key-digests.json', json.dumps([digest(key.encode())]).encode()) + names = [*generators, 'secrets/core/core-key-digests.json', 'node-payload/active.json', *(prefix + name for name in MEMBERS)] receipt = {'source_commit': REVISION, 'files': {name: digest((root / name).read_bytes()) for name in names}} write(marker, json.dumps(receipt).encode()) diff --git a/deploy/compose/local.yaml b/deploy/compose/local.yaml deleted file mode 100644 index 339b92b7..00000000 --- a/deploy/compose/local.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# Local access; platforms route directly to gateway:8080 without this override. -services: - gateway: - ports: ["127.0.0.1:8080:8080"] diff --git a/deploy/compose/ports.yaml b/deploy/compose/ports.yaml new file mode 100644 index 00000000..92a2f9c0 --- /dev/null +++ b/deploy/compose/ports.yaml @@ -0,0 +1,5 @@ +# Host installation publishes Web. Hosting platforms omit this file. +services: + gateway: + ports: + - "${OAC_HOST:-127.0.0.1}:${OAC_WEB_PORT:-8080}:8080" diff --git a/deploy/compose/smoke-pins.json b/deploy/compose/smoke-pins.json new file mode 100644 index 00000000..9d3781ae --- /dev/null +++ b/deploy/compose/smoke-pins.json @@ -0,0 +1,8 @@ +{ + "core": "ghcr.io/minimax-ai/openagentcore/core:v0.0.3@sha256:6934a26d5cb7c878128ea8187ced446336345de7750c415be510740806663575", + "web": "ghcr.io/minimax-ai/openagentcore/web:v0.0.3@sha256:d1eb4cc8870aebd080773fd16db92a5a1db205e0aab10066d4db16f1284a88f5", + "ingress": "ghcr.io/minimax-ai/openagentcore/ingress:v0.0.3@sha256:e485a8cae0b904389501f00bcee6a2c5b35be83412a9ff81a740f93b8cdbf326", + "revision": "cc7e1aad3bde47598d161d6372e2e211bb61d9cd", + "release_base": "https://github.com/MiniMax-AI/OpenAgentCore/releases/download/v0.0.3/", + "archive_checksum": "579d2d43accfc45a0a8567db5bc33b407c48b05b0d731bea3fed73e6ade558ae" +} diff --git a/deploy/install/test_compose.py b/deploy/install/test_compose.py index 5fded431..b70e83de 100644 --- a/deploy/install/test_compose.py +++ b/deploy/install/test_compose.py @@ -3,6 +3,7 @@ import base64 import copy import hashlib +import importlib.util import io import json import os @@ -14,9 +15,24 @@ from unittest.mock import patch import uuid - ROOT = Path(__file__).resolve().parents[2] -COMPOSE = ROOT / 'deploy/compose/compose.yaml' +spec = importlib.util.spec_from_file_location("render_compose", ROOT / "scripts/render-compose.py") +render_compose = importlib.util.module_from_spec(spec) +spec.loader.exec_module(render_compose) + + +def rendered_compose(directory): + text = render_compose.render({ + 'IMAGE_CORE': 'ghcr.io/example/core@sha256:' + 'a' * 64, + 'IMAGE_WEB': 'ghcr.io/example/web@sha256:' + 'b' * 64, + 'IMAGE_INGRESS': 'ghcr.io/example/ingress@sha256:' + 'c' * 64, + 'REVISION': 'd' * 40, + 'RELEASE_BASE': 'https://example.com/releases/v1/', + 'ARCHIVE_CHECKSUM': 'e' * 64, + }) + path = Path(directory) / 'compose.yaml' + path.write_text(text) + return path class ComposeTests(unittest.TestCase): @@ -24,14 +40,18 @@ class ComposeTests(unittest.TestCase): def render(cls, public_url=None): env = dict(os.environ) env.pop('OAC_PUBLIC_URL', None) + env['OAC_DATA_DIR'] = '/tmp/oac-compose-fixture' if public_url is not None: env['OAC_PUBLIC_URL'] = public_url return json.loads(subprocess.check_output( - ['docker', 'compose', '--env-file', os.devnull, '-f', str(COMPOSE), + ['docker', 'compose', '--env-file', os.devnull, '-f', str(cls.compose_file), '--profile', 'tools', 'config', '--format', 'json'], env=env)) @classmethod def setUpClass(cls): + cls.temporary = tempfile.TemporaryDirectory() + cls.addClassCleanup(cls.temporary.cleanup) + cls.compose_file = rendered_compose(cls.temporary.name) cls.compose = cls.render() def setUp(self): @@ -40,7 +60,6 @@ def setUp(self): self.temporary = tempfile.TemporaryDirectory(dir=base) self.addCleanup(self.temporary.cleanup) self.root = Path(self.temporary.name) - (self.root / 'database-data').mkdir() self.code = {'__name__': 'compose_initializer'} exec(self.compose['configs']['init-script']['content'], self.code) self.files = {name: b'fixture' for name in self.code['MEMBERS']} @@ -55,25 +74,26 @@ def initialize(self, fetch=None): def test_fresh_installation_and_restart_keep_identity_and_keys(self): self.initialize() saved = {str(p.relative_to(self.root)): p.read_bytes() for p in self.root.rglob('*') if p.is_file()} - key = saved['web/core.key'].strip() + key = saved['secrets/web/core.key'].strip() self.assertEqual(len(key), 64) - self.assertEqual(json.loads(saved['core/core-key-digests.json']), [hashlib.sha256(key).hexdigest()]) - self.assertEqual(len(base64.b64decode(saved['core/credential.key'])), 32) - uuid.UUID(saved['core/installation.id'].decode().strip()) - for name in ('web/core.key', 'database/password', 'core/credential.key'): + self.assertEqual(json.loads(saved['secrets/core/core-key-digests.json']), [hashlib.sha256(key).hexdigest()]) + self.assertEqual(len(base64.b64decode(saved['secrets/core/credential.key'])), 32) + uuid.UUID(saved['secrets/core/installation.id'].decode().strip()) + for name in ('secrets/web/core.key', 'secrets/database/password', 'secrets/core/credential.key'): self.assertEqual((self.root / name).stat().st_mode & 0o777, 0o600) self.initialize(lambda: self.fail('A completed installation must not download again')) self.assertEqual(saved, {str(p.relative_to(self.root)): p.read_bytes() for p in self.root.rglob('*') if p.is_file()}) def test_existing_data_without_installation_secrets_is_refused(self): - (self.root / 'database-data/PG_VERSION').write_text('16') + (self.root / 'database').mkdir() + (self.root / 'database/PG_VERSION').write_text('16') with self.assertRaisesRegex(RuntimeError, 'original installation'): self.initialize(lambda: self.fail('Must refuse before downloading')) - self.assertFalse((self.root / 'web/core.key').exists()) + self.assertFalse((self.root / 'secrets/web/core.key').exists()) def test_changed_or_missing_keys_and_another_release_are_refused(self): self.initialize() - path = self.root / 'database/password' + path = self.root / 'secrets/database/password' original = path.read_bytes() path.write_bytes(b'different') with self.assertRaisesRegex(RuntimeError, 'files changed'): @@ -88,10 +108,10 @@ def test_changed_or_missing_keys_and_another_release_are_refused(self): def test_interrupted_fresh_initialization_retains_generated_keys(self): self.initialize() - key = (self.root / 'web/core.key').read_bytes() - (self.root / 'core/installation.json').unlink() + key = (self.root / 'secrets/web/core.key').read_bytes() + (self.root / 'installation.json').unlink() self.initialize() - self.assertEqual(key, (self.root / 'web/core.key').read_bytes()) + self.assertEqual(key, (self.root / 'secrets/web/core.key').read_bytes()) def archive(self): output = io.BytesIO() @@ -133,9 +153,9 @@ def test_compose_uses_private_services_and_ordered_initialization(self): self.assertNotIn('ports', service) self.assertIn('@sha256:', service['image']) for volume in service.get('volumes', []): - self.assertNotIn('docker.sock', volume['source']) - self.assertEqual(volume['type'], 'volume') - self.assertEqual({v['source'] for v in services['web']['volumes']}, {'web-secret', 'node-payload'}) + self.assertNotIn('docker.sock', json.dumps(volume)) + self.assertEqual(volume['type'], 'bind') + self.assertEqual({v['target'] for v in services['web']['volumes']}, {'/run/oac', '/node-payload'}) self.assertEqual(services['credentials']['logging']['driver'], 'none') schema = json.loads((ROOT / 'deploy/install/config.schema.json').read_text()) harnesses = schema['properties']['core']['properties']['harnesses']['default'] @@ -149,7 +169,11 @@ def test_public_url_can_be_configured_after_initial_startup(self): for name, setting in (('core', 'OAC_PUBLIC_URL'), ('migrate', 'OAC_PUBLIC_URL'), ('web', 'OAC_WEB_ORIGIN')): self.assertEqual(configured['services'][name]['environment'][setting], expected) - self.assertEqual(configured['volumes'], self.compose['volumes']) + self.assertEqual( + {service: [item.get('target') for item in spec.get('volumes', [])] + for service, spec in configured['services'].items()}, + {service: [item.get('target') for item in spec.get('volumes', [])] + for service, spec in self.compose['services'].items()}) def test_platform_network_injection_keeps_the_credentials_profile_valid(self): # Dokploy isolated deployments attach a project network to every service. diff --git a/docs/configuration.md b/docs/configuration.md index d653d1bd..e247f772 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -108,22 +108,22 @@ Set a default in **System** → **Default model configuration**, or use `PUT /co ## Compose installations -The [standalone Compose template](./getting-started/install-options.md#docker-compose-and-hosting-platforms) uses its Compose definition and the platform's environment as the source of process settings. An unset or empty `OAC_PUBLIC_URL` selects `http://localhost:8080`, allowing startup before a public domain is configured. Core and Web receive that same value. For public access, set `OAC_PUBLIC_URL` to the exact public HTTPS origin without a trailing slash and redeploy Core and Web with the same project and volumes; changing an environment variable requires container recreation, not just a restart. Configure the public origin before adding nodes or executors. The platform owns TLS and routes to `gateway:8080`; Web's installer-managed domain setup is unavailable. +The [standalone Compose file](./getting-started/install-options.md#docker-compose-and-hosting-platforms) from a release uses its Compose definition and the platform's environment as the source of process settings. An unset or empty `OAC_PUBLIC_URL` selects `http://localhost:8080`, allowing startup before a public domain is configured. Core and Web receive that same value. For public access, set `OAC_PUBLIC_URL` to the exact public HTTPS origin without a trailing slash and redeploy Core and Web with the same project and data directory; changing an environment variable requires container recreation, not just a restart. Configure the public origin before adding nodes or executors. The platform owns TLS and routes to `gateway:8080`; Web's installer-managed domain setup is unavailable. The initialization service generates secrets and the installation ID once, then verifies them on subsequent deployments. Each secret has one persistent source; Core's key digest is derived from Web's sign-in key. Initialization never replaces missing or changed secrets on an existing installation. Core reads its existing process environment and file settings, so the installer-specific `config.json`, `oac apply` and startup settings snapshot do not apply to this deployment. -| Compose volume | Content | Readers | +| Data directory path | Content | Readers | | --- | --- | --- | -| `database` | PostgreSQL data | PostgreSQL; initialization checks whether it is empty | -| `database-secret` | Generated database password | PostgreSQL, migrator and Core | -| `core-config` | Credential encryption key, installation ID, Core key digest and initialization receipt | Migrator and Core | -| `web-secret` | Generated Core sign-in key | Web and the explicit `credentials` tool | -| `core-state` | Private Provider state | Core | -| `node-payload` | Verified node installation metadata | Web | +| `database/` | PostgreSQL data | PostgreSQL; initialization checks whether it is empty | +| `secrets/database/` | Generated database password | PostgreSQL, migrator and Core | +| `secrets/core/` | Credential encryption key, installation ID and Core key digest | Migrator and Core | +| `secrets/web/` | Generated Core sign-in key | Web and the explicit `credentials` tool | +| `state/` | Private Provider state | Core | +| `node-payload/` | Verified node installation metadata | Web | -Initialization prepares these volumes; application services receive their secret volumes read-only. The `credentials` tool disables container logging. Retrieve its output only in an operator terminal. Database passwords and credential encryption keys are never printed. +Initialization prepares this directory; application services receive their secret directories read-only. The `credentials` tool disables container logging. Retrieve its output only in an operator terminal. Database passwords and credential encryption keys are never printed. -The Compose project name scopes the volumes. Preserve every volume together with that project's definition and public URL. Removing only secret volumes does not reset an installation; initialization refuses to start over an existing database. Core also binds the installation ID to its database. Runtime settings continue to live in [Core's database](#runtime-settings-web). +`OAC_DATA_DIR` selects the directory and defaults to `./data` beside the Compose file. Preserve it together with that project's definition and public URL. Removing only the secret directories does not reset an installation; initialization refuses to start over an existing database. Core also binds the installation ID to its database. Runtime settings continue to live in [Core's database](#runtime-settings-web). ## Docker node configuration diff --git a/docs/getting-started/install-options.md b/docs/getting-started/install-options.md index 2e506380..7fbe5c24 100644 --- a/docs/getting-started/install-options.md +++ b/docs/getting-started/install-options.md @@ -16,16 +16,16 @@ The installer prints each stage, then a summary of addresses, sign-in details an ## Docker Compose and hosting platforms -Use the self-contained [Compose template](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/compose/compose.yaml) with Docker Compose 2.26 or newer on Linux amd64. It pulls the existing, digest-pinned v0.0.3 images and starts PostgreSQL, Core, Web and an HTTP gateway. The one-time initialization service generates random secrets in persistent volumes and prepares the node installer; the migration service initializes the database before Core starts. [Compose configuration](../configuration.md#compose-installations) owns the settings and volumes. +Use the `compose.yaml` from a release with Docker Compose 2.26 or newer on Linux amd64. The release renders it from the [Compose template](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/compose/compose.yaml), pins that release's image digests, and starts PostgreSQL, Core, Web and an HTTP gateway. Data is bind-mounted from a directory. The one-time initialization service generates random secrets there and prepares the node installer; the migration service initializes the database before Core starts. [Compose configuration](../configuration.md#compose-installations) owns the settings and the data directory. -For a local trial, download `compose.yaml` and the [local port override](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/compose/local.yaml) into one directory, then run: +For a local trial, download `compose.yaml` and `ports.yaml` from the same release into one directory, then run: ```sh -docker compose -f compose.yaml -f local.yaml up -d --wait --wait-timeout 900 +docker compose -f compose.yaml -f ports.yaml up -d --wait --wait-timeout 900 docker compose -f compose.yaml run --rm credentials ``` -The `credentials` command prints the generated Core key to your terminal without storing it in container logs. Open `http://localhost:8080` and use that key to sign in. All installation secrets are generated automatically; keep the same Compose project and its volumes when restarting. +The `credentials` command prints the generated Core key to your terminal without storing it in container logs. Open `http://localhost:8080` and use that key to sign in. All installation secrets are generated automatically; keep the same Compose project and its data directory when restarting. The first initialization downloads and verifies the release's approximately 385 MB control archive, retaining only the small node installation metadata. Later starts verify the saved files without downloading again. Image downloads are additional. An interrupted first initialization can be rerun; an existing database with missing installation secrets is refused. @@ -33,17 +33,17 @@ You can deploy before choosing a domain: leave `OAC_PUBLIC_URL` unset or empty, ### Dokploy -Create a Docker Compose application and paste `compose.yaml`. Set `OAC_PUBLIC_URL` to the public HTTPS origin, enable isolated deployment, and add a domain for service `gateway`, port `8080`. Enable **HTTPS** and select a certificate provider such as **Let's Encrypt** for that domain before deploying. Deploy without `local.yaml`; internal services publish no host ports. The [template metadata](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/compose/dokploy.toml) supplies the generated domain and environment when packaging this Compose file for Dokploy's template catalog; HTTPS and its certificate provider still need to be enabled after import. +Create a Docker Compose application and paste `compose.yaml`. Set `OAC_PUBLIC_URL` to the public HTTPS origin, enable isolated deployment, and add a domain for service `gateway`, port `8080`. Enable **HTTPS** and select a certificate provider such as **Let's Encrypt** for that domain before deploying. Deploy without `ports.yaml`; internal services publish no host ports. The [template metadata](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/compose/dokploy.toml) supplies the generated domain and environment when packaging this Compose file for Dokploy's template catalog; HTTPS and its certificate provider still need to be enabled after import. ### Coolify -Create a **Docker Compose Empty** service and paste `compose.yaml`. Set `OAC_PUBLIC_URL` to the public HTTPS origin and assign that domain to `gateway` on port `8080`. Add Coolify's `exclude_from_hc: true` to the `init`, `migrate` and `credentials` service definitions so completed initialization and optional tooling do not affect its overall health. Save and deploy without `local.yaml`; Coolify supplies HTTPS. +Create a **Docker Compose Empty** service and paste `compose.yaml`. Set `OAC_PUBLIC_URL` to the public HTTPS origin and assign that domain to `gateway` on port `8080`. Add Coolify's `exclude_from_hc: true` to the `init`, `migrate` and `credentials` service definitions so completed initialization and optional tooling do not affect its overall health. Save and deploy without `ports.yaml`; Coolify supplies HTTPS. On either platform, open its server terminal and run `docker compose ls` to find the deployed project name and Compose file. Using those exact values and the deployment's `OAC_PUBLIC_URL`, run `docker compose -p -f run --rm credentials`, then sign in at the configured origin. The [Dokploy domain guide](https://docs.dokploy.com/docs/core/docker-compose/domains) and [Coolify Compose guide](https://coolify.io/docs/services/configuration/docker-compose) describe their domain and service controls. These are importable deployment files; no hosted marketplace listing is published by this repository. After signing in, choose the sandbox backend and add nodes using [Nodes](./nodes.md). The Compose stack deploys the control plane; execution machines remain separate. -Stop with `docker compose stop` using the same files and environment. Back up all [installation volumes](../configuration.md#compose-installations) together while the services are stopped. Follow the [installation version policy](./operations.md#installation-version-policy): a different release needs a new Compose project and fresh volumes. +Stop with `docker compose stop` using the same files and environment. Back up the data directory together while the services are stopped. Follow the [installation version policy](./operations.md#installation-version-policy): a different release needs a new Compose project and a fresh data directory. ## Process settings diff --git a/docs/maintainers.md b/docs/maintainers.md index e166be7e..591450fb 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -130,21 +130,21 @@ Distribution and Runtime archives use `pigz` level 6 with at most four compressi ### Container registry -Version releases publish Linux amd64 images as `ghcr.io/minimax-ai/openagentcore/:`, where `` is `core`, `web`, `runtime` or `ingress`. For example, `ghcr.io/minimax-ai/openagentcore/core:v1.2.3`. PostgreSQL uses its upstream image and is not republished. The registry images are loaded from the release archives without rebuilding. Existing tags are reused only when their image config digest matches the release; a different image stops publication. No floating `latest` tag is published. SemVer build metadata uses `_` in place of `+` in container tags; version strings longer than 128 characters cannot be published to GHCR. Manual draft builds do not push images. +Version releases and manual `build-` drafts publish Linux amd64 images as `ghcr.io/minimax-ai/openagentcore/:`, where `` is `core`, `web`, `runtime` or `ingress`. For example, `ghcr.io/minimax-ai/openagentcore/core:v1.2.3`. A draft uses the tag `build-`. PostgreSQL uses its upstream image and is not republished. The registry images are loaded from the release archives without rebuilding. Existing tags are reused only when their image config digest matches the release; a different image stops publication. No floating `latest` tag is published. SemVer build metadata uses `_` in place of `+` in container tags; version strings longer than 128 characters cannot be published to GHCR. After the images are verified, the publisher uploads `compose.yaml` and `ports.yaml`, with checksums, rendered for that release. A draft Release stays unpublished. 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. -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; the installation archives and their checksums remain unchanged. These images still require the configuration, secrets and routing described in [Configuration](./configuration.md); publishing them does not provide a platform deployment template. +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). `install.sh` resolves the latest stable release once, or the release named by `--version`, verifies the control archive and runs that bundle's installer; the [installation guide](./getting-started/install.md#install) covers its use. Go check and build jobs share Go module and compiler-cache directories under `~/.oac/cache/`, keyed by runner OS and architecture, all Go module files, the check/build partition and the commit. Partitioned keys prevent concurrent jobs from saving different compiler subsets under one key. Release builds can seed their cache from backend checks as well as earlier release builds. An older cache only seeds downloads and compilation; every check still runs. Release jobs also cache npm package downloads and the pinned microsandbox archive, whose checksum is verified on every build. Actions cache visibility follows GitHub ref scoping; a tag-specific cache is not shared with other release tags. New keys are saved only after a successful job. -Never move a release tag or overwrite published assets. If publication fails, inspect the Release first: publication may have completed despite a lost response. Leave a complete published Release as it is. For an incomplete draft, delete that draft only after inspection, download the original `core-release-` Actions artifact with `gh run download RUN_ID --name core-release-REVISION --dir ASSET_DIRECTORY`, and use a checkout of that exact source revision to run `python3 scripts/publish-core-release.py --assets ASSET_DIRECTORY`. Set `GH_REPO`, `GH_TOKEN`, `RELEASE_REVISION`, `RELEASE_TAG` and `RELEASE_MODE` to the original publication inputs and sign Docker into GHCR for version publication. The script revalidates the assets and refuses existing releases. Do not rerun the combined build job or recreate the tag to recover a failed upload. +Never move a release tag or overwrite published assets. If publication fails, inspect the Release first: publication may have completed despite a lost response. Leave a complete published Release as it is. For an incomplete draft, delete that draft only after inspection, download the original `core-release-` Actions artifact with `gh run download RUN_ID --name core-release-REVISION --dir ASSET_DIRECTORY`, and use a checkout of that exact source revision to run `python3 scripts/publish-core-release.py --assets ASSET_DIRECTORY`. Set `GH_REPO`, `GH_TOKEN`, `RELEASE_REVISION`, `RELEASE_TAG` and `RELEASE_MODE` to the original publication inputs and sign Docker into GHCR. The script revalidates the assets and refuses existing releases. Do not rerun the combined build job or recreate the tag to recover a failed upload. ### Build a candidate without publishing -A manual run takes a full commit SHA, runs the same checks and builds, defaults to the offline archive, and never publishes: +A manual run takes a full commit SHA, runs the same checks and builds, defaults to the offline archive, and leaves the Release unpublished: ```sh revision=$(git rev-parse HEAD) @@ -152,7 +152,7 @@ gh workflow run core-release --repo MiniMax-AI/OpenAgentCore --ref main \ -f ref="$revision" -f offline=true -f draft_release=true ``` -With `draft_release=true` the result is an unpublished `build-` draft Release; with `draft_release=false` the files stay in the Actions artifact. Use the exact matched asset set; never mix builds or resolve components through `latest`. +With `draft_release=true` the result is an unpublished `build-` draft Release whose images are pushed under that tag; with `draft_release=false` the files stay in the Actions artifact. Use the exact matched asset set; never mix builds or resolve components through `latest`. ## Continuous integration diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index ec0bdb73..990b4c18 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,7 +1,7 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: afc0f02492f63e2032009fc7a79aa72051ca2c377d2e7788ef6f7d1d15f8b8de +source_hash: eb30344d11d98506b56e62bf2fc668b37d4db050dfc7a0c02c486f4727d85874 --- Core 安装的每项设置都恰好只有一个归属位置。共有两类: @@ -112,22 +112,22 @@ Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置 ## Compose 安装 {#compose-installations} -[独立 Compose 模板](getting-started/install-options.md#docker-compose-and-hosting-platforms)使用其 Compose 定义和平台环境作为进程设置的来源。`OAC_PUBLIC_URL` 未设置或为空时,会选用 `http://localhost:8080`,从而允许在配置公共域名前启动。Core 和 Web 会收到同一个值。要允许公共访问,请将 `OAC_PUBLIC_URL` 设置为不带尾部斜杠的准确公共 HTTPS 源地址,并使用相同的项目和数据卷重新部署 Core 和 Web;更改环境变量需要重新创建容器,而不只是重启。请在添加节点或执行器之前配置公共源地址。平台负责 TLS,并将流量路由到 `gateway:8080`;Web 由安装程序管理的域设置不可用。 +发行版中的[独立 Compose 文件](getting-started/install-options.md#docker-compose-and-hosting-platforms)使用其 Compose 定义和平台环境作为进程设置的来源。`OAC_PUBLIC_URL` 未设置或为空时,会选用 `http://localhost:8080`,从而允许在配置公共域名前启动。Core 和 Web 会收到同一个值。要允许公共访问,请将 `OAC_PUBLIC_URL` 设置为不带尾部斜杠的准确公共 HTTPS 源地址,并使用相同的项目和数据目录重新部署 Core 和 Web;更改环境变量需要重新创建容器,而不只是重启。请在添加节点或执行器之前配置公共源地址。平台负责 TLS,并将流量路由到 `gateway:8080`;Web 由安装程序管理的域设置不可用。 初始化服务首次生成机密信息和安装 ID,随后在后续部署中验证它们。每项机密信息都只有一个持久来源;Core 的密钥摘要派生自 Web 的登录密钥。对于现有安装,初始化绝不会替换缺失或已更改的机密信息。Core 会读取现有的进程环境和文件设置,因此安装程序专用的 `config.json`、`oac apply` 和启动时提供的设置快照不适用于此部署。 -| Compose 卷 | 内容 | 读取方 | +| 数据目录路径 | 内容 | 读取方 | | --- | --- | --- | -| `database` | PostgreSQL 数据 | PostgreSQL;初始化会检查它是否为空 | -| `database-secret` | 生成的数据库密码 | PostgreSQL、迁移程序和 Core | -| `core-config` | 凭据加密密钥、安装 ID、Core 密钥摘要和初始化回执 | 迁移程序和 Core | -| `web-secret` | 生成的 Core 登录密钥 | Web 和显式 `credentials` 工具 | -| `core-state` | 私有 Provider 状态 | Core | -| `node-payload` | 已验证的节点安装元数据 | Web | +| `database/` | PostgreSQL 数据 | PostgreSQL;初始化会检查它是否为空 | +| `secrets/database/` | 生成的数据库密码 | PostgreSQL、迁移程序和 Core | +| `secrets/core/` | 凭据加密密钥、安装 ID 和 Core 密钥摘要 | 迁移程序和 Core | +| `secrets/web/` | 生成的 Core 登录密钥 | Web 和显式 `credentials` 工具 | +| `state/` | 私有 Provider 状态 | Core | +| `node-payload/` | 已验证的节点安装元数据 | Web | -初始化会准备这些卷;应用服务以只读方式接收其中的机密卷。`credentials` 工具会禁用容器日志记录。只能在运维人员终端中获取其输出。数据库密码和凭据加密密钥绝不打印。 +初始化会准备该目录;应用服务以只读方式接收各自的机密目录。`credentials` 工具会禁用容器日志记录。只能在运维人员终端中获取其输出。数据库密码和凭据加密密钥绝不打印。 -Compose 项目名称用于限定卷的作用域。必须将该项目的定义和公共 URL 与每个卷一同保留。仅删除机密卷不会重置安装;如果数据库已经存在,初始化会拒绝重新开始。Core 还会将安装 ID 与其数据库绑定。运行时设置仍存储在 [Core 的数据库](#runtime-settings-web)中。 +`OAC_DATA_DIR` 选择该目录,默认是 Compose 文件旁的 `./data`。必须将该项目的定义和公共 URL 与该目录一同保留。仅删除机密目录不会重置安装;如果数据库已经存在,初始化会拒绝重新开始。Core 还会将安装 ID 与其数据库绑定。运行时设置仍存储在 [Core 的数据库](#runtime-settings-web)中。 ## Docker 节点配置 {#docker-node-configuration} diff --git a/docs/zh/getting-started/install-options.md b/docs/zh/getting-started/install-options.md index 03b553f3..9e1b4f8a 100644 --- a/docs/zh/getting-started/install-options.md +++ b/docs/zh/getting-started/install-options.md @@ -1,7 +1,7 @@ --- title: "安装选项与高级部署" source: docs/getting-started/install-options.md -source_hash: 7178baf93a8d8b6e7f086d73033afe4ea14afcf41c88dbcc6031a3a6dd20ca17 +source_hash: 7f501e5b31bf499881b76cd05476f64a9b9746d0adc168d3cb71158c7ed94574 --- [默认安装](install.md)无需任何选项。使用本页可以在现有反向代理后运行,或者在无法访问互联网时进行安装。 @@ -18,16 +18,16 @@ source_hash: 7178baf93a8d8b6e7f086d73033afe4ea14afcf41c88dbcc6031a3a6dd20ca17 ## Docker Compose 与托管平台 {#docker-compose-and-hosting-platforms} -在 Linux amd64 上使用 Docker Compose 2.26 或更高版本,并采用自带完整依赖的 [Compose 模板](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/compose/compose.yaml)。它会拉取现有的、按摘要固定版本的 v0.0.3 镜像,并启动 PostgreSQL、Core、Web 和一个 HTTP 网关。一次性初始化服务会在持久化卷中生成随机机密信息并准备节点安装程序;迁移服务会在 Core 启动前初始化数据库。[Compose 配置](../configuration.md#compose-installations)负责管理各项设置和卷。 +在 Linux amd64 上使用发行版中的 `compose.yaml` 和 Docker Compose 2.26 或更高版本。发行流程会根据 [Compose 模板](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/compose/compose.yaml)渲染该文件,固定该发行版的镜像摘要,并启动 PostgreSQL、Core、Web 和一个 HTTP 网关。数据通过目录 bind mount 挂载。一次性初始化服务会在该目录中生成随机机密信息并准备节点安装程序;迁移服务会在 Core 启动前初始化数据库。[Compose 配置](../configuration.md#compose-installations)负责管理各项设置和数据目录。 -进行本地试用时,请将 `compose.yaml` 和[本地端口覆盖](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/compose/local.yaml)下载到同一个目录,然后运行: +进行本地试用时,请将同一发行版的 `compose.yaml` 和 `ports.yaml` 下载到同一个目录,然后运行: ```sh -docker compose -f compose.yaml -f local.yaml up -d --wait --wait-timeout 900 +docker compose -f compose.yaml -f ports.yaml up -d --wait --wait-timeout 900 docker compose -f compose.yaml run --rm credentials ``` -`credentials` 命令会将生成的 Core 密钥打印到终端,而不会将其存储在容器日志中。打开 `http://localhost:8080` 并使用该密钥登录。所有安装机密信息都会自动生成;重启时请保留同一个 Compose 项目及其卷。 +`credentials` 命令会将生成的 Core 密钥打印到终端,而不会将其存储在容器日志中。打开 `http://localhost:8080` 并使用该密钥登录。所有安装机密信息都会自动生成;重启时请保留同一个 Compose 项目及其数据目录。 首次初始化会下载并验证该发布版本中约 385 MB 的控制归档文件,仅保留较小的节点安装元数据。后续启动会验证已保存的文件,而不会再次下载。镜像需要额外下载。首次初始化中断后可以重新运行;如果现有数据库缺少安装机密信息,初始化会被拒绝。 @@ -35,17 +35,17 @@ docker compose -f compose.yaml run --rm credentials ### Dokploy {#dokploy} -创建一个 Docker Compose 应用并粘贴 `compose.yaml`。将 `OAC_PUBLIC_URL` 设置为公共 HTTPS 源地址,启用隔离部署,并为 `gateway` 服务添加域名和端口 `8080`。部署前,为此域名启用 **HTTPS**,并选择 **Let's Encrypt** 等证书提供程序。部署时不要使用 `local.yaml`;内部服务不会发布主机端口。将此 Compose 文件打包到 Dokploy 模板目录时,[模板元数据](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/compose/dokploy.toml)会提供生成的域名和环境;导入后仍需启用 HTTPS 及其证书提供程序。 +创建一个 Docker Compose 应用并粘贴 `compose.yaml`。将 `OAC_PUBLIC_URL` 设置为公共 HTTPS 源地址,启用隔离部署,并为 `gateway` 服务添加域名和端口 `8080`。部署前,为此域名启用 **HTTPS**,并选择 **Let's Encrypt** 等证书提供程序。部署时不要使用 `ports.yaml`;内部服务不会发布主机端口。将此 Compose 文件打包到 Dokploy 模板目录时,[模板元数据](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/compose/dokploy.toml)会提供生成的域名和环境;导入后仍需启用 HTTPS 及其证书提供程序。 ### Coolify {#coolify} -创建一个 **Docker Compose Empty** 服务并粘贴 `compose.yaml`。将 `OAC_PUBLIC_URL` 设置为公共 HTTPS 源地址,并将该域名分配给 `gateway` 服务的端口 `8080`。将 Coolify 的 `exclude_from_hc: true` 添加到 `init`、`migrate` 和 `credentials` 的服务定义中,使已完成的初始化和可选工具不会影响其总体健康状态。保存并在不包含 `local.yaml` 的情况下部署;HTTPS 由 Coolify 提供。 +创建一个 **Docker Compose Empty** 服务并粘贴 `compose.yaml`。将 `OAC_PUBLIC_URL` 设置为公共 HTTPS 源地址,并将该域名分配给 `gateway` 服务的端口 `8080`。将 Coolify 的 `exclude_from_hc: true` 添加到 `init`、`migrate` 和 `credentials` 的服务定义中,使已完成的初始化和可选工具不会影响其总体健康状态。保存并在不包含 `ports.yaml` 的情况下部署;HTTPS 由 Coolify 提供。 在这两个平台上,打开服务器终端并运行 `docker compose ls`,查找已部署的项目名称和 Compose 文件。使用这些完全一致的值以及该部署的 `OAC_PUBLIC_URL`,运行 `docker compose -p -f run --rm credentials`,然后在已配置的源地址登录。[Dokploy 域名指南](https://docs.dokploy.com/docs/core/docker-compose/domains)和[Coolify Compose 指南](https://coolify.io/docs/services/configuration/docker-compose)介绍了各自的域和服务控制项。这些都是可导入的部署文件;本仓库不发布托管市场条目。 登录后,使用 [Nodes](nodes.md)选择沙箱后端并添加节点。Compose 堆栈部署控制平面;执行机器仍需单独部署。 -使用相同的文件和环境运行 `docker compose stop` 以停止服务。停止服务后,将所有[安装卷](../configuration.md#compose-installations)一起备份。请遵循[安装版本策略](operations.md#installation-version-policy):使用不同发布版本时,需要创建新的 Compose 项目并使用全新的卷。 +使用相同的文件和环境运行 `docker compose stop` 以停止服务。停止服务后,备份数据目录。请遵循[安装版本策略](operations.md#installation-version-policy):使用不同发布版本时,需要创建新的 Compose 项目并使用全新的数据目录。 ## 进程设置 {#process-settings} diff --git a/docs/zh/maintainers.md b/docs/zh/maintainers.md index 6ba85ac2..57584eeb 100644 --- a/docs/zh/maintainers.md +++ b/docs/zh/maintainers.md @@ -1,7 +1,7 @@ --- title: "构建并发布 OpenAgentCore" source: docs/maintainers.md -source_hash: 11e24e329928f4803518aeaf95a202dcbc98ff1c007860c2f94b1ca96adb5d6a +source_hash: 7e5fcad21f6b4aa9f88f9d1dd19024aec6cdea009a7c2e879431f5a604fd6bac --- 本指南面向负责构建和发布 OpenAgentCore 的维护者。要安装 Core 和 Web,请使用 [安装指南](getting-started/install.md)。安装器代码遵循的规则见 [安装器设计规则](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/install/README.md);必需检查见 [CONTRIBUTING](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md#required-checks)。 @@ -132,21 +132,21 @@ git push origin v1.2.3 ### 容器注册表 {#container-registry} -版本发布会将 Linux amd64 镜像发布为 `ghcr.io/minimax-ai/openagentcore/:`,其中 `` 为 `core`、`web`、`runtime` 或 `ingress`。例如,`ghcr.io/minimax-ai/openagentcore/core:v1.2.3`。PostgreSQL 使用其上游镜像,不会重新发布。注册表镜像从发布归档中加载,不会重新构建。仅当现有标签的镜像配置摘要与本次发布相同时才复用该标签;如果镜像不同,则停止发布。不会发布浮动 `latest` 标签。SemVer 构建元数据在容器标签中使用 `_` 代替 `+`;长度超过 128 个字符的版本字符串无法发布到 GHCR。手动草稿构建不会推送镜像。 +版本发布和手动的 `build-` 草稿都会将 Linux amd64 镜像发布为 `ghcr.io/minimax-ai/openagentcore/:`,其中 `` 为 `core`、`web`、`runtime` 或 `ingress`。例如,`ghcr.io/minimax-ai/openagentcore/core:v1.2.3`。草稿使用标签 `build-`。PostgreSQL 使用其上游镜像,不会重新发布。注册表镜像从发布归档中加载,不会重新构建。仅当现有标签的镜像配置摘要与本次发布相同时才复用该标签;如果镜像不同,则停止发布。不会发布浮动 `latest` 标签。SemVer 构建元数据在容器标签中使用 `_` 代替 `+`;长度超过 128 个字符的版本字符串无法发布到 GHCR。镜像验证之后,发布器会上传为该发行版渲染的 `compose.yaml` 和 `ports.yaml` 及其校验和。草稿 Release 保持未发布。 合并的构建/发布作业使用具有 `packages: write` 权限的 `GITHUB_TOKEN`。首次发布时,GitHub 会将每个容器软件包创建为私有:软件包管理员必须先在各自的软件包设置中将全部四个软件包改为 **Public**,用户才能匿名拉取。请参阅 [GitHub container visibility](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)。更改可见性后,请验证未认证拉取。仅更改仓库可见性并不会使新的容器软件包变为公开。 -GHCR 和 GitHub Releases 不共享事务。发布失败后,GHCR 中可能仍会保留一些匹配的版本标签;请保留这些镜像,并使用原始构件按照下文的草稿恢复流程操作。除清单缺失以外,注册表故障都会停止发布。作业摘要会记录按摘要固定的引用;安装归档及其校验和保持不变。这些镜像仍需要[配置](configuration.md)中描述的配置、机密和路由;发布镜像不会提供平台部署模板。 +GHCR 和 GitHub Releases 不共享事务。发布失败后,GHCR 中可能仍会保留一些匹配的版本标签;请保留这些镜像,并使用原始构件按照下文的草稿恢复流程操作。除清单缺失以外,注册表故障都会停止发布。作业摘要会记录按摘要固定的引用。这些镜像和渲染后的 Compose 文件仍需要[配置](configuration.md)中描述的配置、机密和路由。 `install.sh` 会解析一次最新稳定版,或解析 `--version` 指定的发布版,验证控制归档并运行该捆绑包的安装器;其用法见[安装指南](getting-started/install.md#install)。 Go 检查和构建作业共享 `~/.oac/cache/` 下的 Go 模块和编译器缓存目录,缓存键由运行器 OS 和架构、全部 Go 模块文件、检查/构建分区以及提交确定。分区键可防止并发作业在同一个键下保存不同的编译器子集。发布构建既可以使用后端检查的缓存,也可以使用更早发布构建的缓存。较旧的缓存只会为下载和编译提供初始内容;每项检查仍会运行。发布作业还会缓存 npm 软件包下载内容和固定版本的 microsandbox 归档,并在每次构建时验证后者的校验和。Actions 缓存可见性遵循 GitHub ref 的作用域;特定标签的缓存不会与其他发布标签共享。只有作业成功后才会保存新键。 -绝不移动发布标签或覆盖已发布的资源。发布失败时,请先检查 Release:即使响应丢失,发布也可能已经完成。对于完整的已发布 Release,请保持原样。对于不完整的草稿,仅在检查后将其删除,然后使用 `gh run download RUN_ID --name core-release-REVISION --dir ASSET_DIRECTORY` 下载原始 `core-release-` Actions 构建产物,并使用该确切源代码修订版本的检出运行 `python3 scripts/publish-core-release.py --assets ASSET_DIRECTORY`。将 `GH_REPO`、`GH_TOKEN`、`RELEASE_REVISION`、`RELEASE_TAG` 和 `RELEASE_MODE` 设置为原始发布输入,并将 Docker 登录到 GHCR 以发布版本。该脚本会重新验证资源,并拒绝使用已有 Release。恢复上传失败时,绝不能重新运行合并的构建作业,也绝不能重新创建标签。 +绝不移动发布标签或覆盖已发布的资源。发布失败时,请先检查 Release:即使响应丢失,发布也可能已经完成。对于完整的已发布 Release,请保持原样。对于不完整的草稿,仅在检查后将其删除,然后使用 `gh run download RUN_ID --name core-release-REVISION --dir ASSET_DIRECTORY` 下载原始 `core-release-` Actions 构建产物,并使用该确切源代码修订版本的检出运行 `python3 scripts/publish-core-release.py --assets ASSET_DIRECTORY`。将 `GH_REPO`、`GH_TOKEN`、`RELEASE_REVISION`、`RELEASE_TAG` 和 `RELEASE_MODE` 设置为原始发布输入,并将 Docker 登录到 GHCR。该脚本会重新验证资源,并拒绝使用已有 Release。恢复上传失败时,绝不能重新运行合并的构建作业,也绝不能重新创建标签。 ### 构建候选版本但不发布 {#build-a-candidate-without-publishing} -手动运行需要完整的提交 SHA,会执行相同的检查和构建,默认生成离线归档,并且绝不发布: +手动运行需要完整的提交 SHA,会执行相同的检查和构建,默认生成离线归档,并且不会发布 Release: ```sh revision=$(git rev-parse HEAD) @@ -154,7 +154,7 @@ gh workflow run core-release --repo MiniMax-AI/OpenAgentCore --ref main \ -f ref="$revision" -f offline=true -f draft_release=true ``` -设置 `draft_release=true` 时,结果是未发布的 `build-` 草稿 Release;设置 `draft_release=false` 时,文件会保留在 Actions 构建产物中。请使用完全匹配的构件集合;绝不能混用构建结果,也绝不能通过 `latest` 解析组件。 +设置 `draft_release=true` 时,结果是未发布的 `build-` 草稿 Release,其镜像会以该标签推送;设置 `draft_release=false` 时,文件会保留在 Actions 构建产物中。请使用完全匹配的构件集合;绝不能混用构建结果,也绝不能通过 `latest` 解析组件。 ## 持续集成 {#continuous-integration} diff --git a/scripts/ci_plan.py b/scripts/ci_plan.py index 2b3ef9d6..870f1de1 100644 --- a/scripts/ci_plan.py +++ b/scripts/ci_plan.py @@ -65,8 +65,8 @@ (("packages/claude-sdk-adapter/", "packages/mcode-harness/"), WEB, ("harness", "native", "backend", "distribution")), (("packages/tsconfig/",), (".json",), ("harness", "native")), (("deploy/install/",), (".py", ".json", ".sh"), ("distribution",)), - (("deploy/compose/",), (".yaml", ".toml"), ("distribution", "compose")), - (("scripts/compose-smoke.py", "deploy/install/test_compose.py"), (".py",), ("distribution", "compose")), + (("deploy/compose/",), (".yaml", ".toml", ".json"), ("distribution", "compose")), + (("scripts/compose-smoke.py", "scripts/render-compose.py", "deploy/install/test_compose.py"), (".py",), ("distribution", "compose")), (("deploy/install-release.sh", "scripts/install-release.", "scripts/publish-core-release.", "scripts/core-distribution-manifest.", "scripts/build-core-distribution.sh", "scripts/config-reference.py", "scripts/build-web.sh"), SCRIPTS, ("distribution",)), diff --git a/scripts/ci_plan_test.py b/scripts/ci_plan_test.py index 02791e08..7a943410 100644 --- a/scripts/ci_plan_test.py +++ b/scripts/ci_plan_test.py @@ -23,8 +23,8 @@ def test_installer_does_not_download_a_browser_or_run_database_tests(self): self.assertEqual(self.jobs("deploy/install/install.py", "scripts/install-release.test.py"), {"hygiene", "distribution"}) def test_compose_inputs_select_live_and_fixture_checks_without_image_builds(self): - for path in ("deploy/compose/compose.yaml", "deploy/compose/local.yaml", "deploy/compose/dokploy.toml", - "scripts/compose-smoke.py", "deploy/install/test_compose.py"): + for path in ("deploy/compose/compose.yaml", "deploy/compose/ports.yaml", "deploy/compose/dokploy.toml", + "scripts/compose-smoke.py", "scripts/render-compose.py", "deploy/install/test_compose.py"): self.assertEqual(self.jobs(path), {"hygiene", "distribution", "compose"}) self.assertFalse(ci.select([path])["image"]) diff --git a/scripts/compose-smoke.py b/scripts/compose-smoke.py index e27830c3..56b43e27 100644 --- a/scripts/compose-smoke.py +++ b/scripts/compose-smoke.py @@ -3,6 +3,7 @@ import hashlib import http.cookiejar +import importlib.util import json import os from pathlib import Path @@ -14,8 +15,10 @@ import urllib.request import uuid - ROOT = Path(__file__).resolve().parents[1] +render_spec = importlib.util.spec_from_file_location("render_compose", ROOT / "scripts/render-compose.py") +render_compose = importlib.util.module_from_spec(render_spec) +render_spec.loader.exec_module(render_compose) def main(): @@ -26,6 +29,15 @@ def main(): artifacts = Path.home() / '.oac/tests' artifacts.mkdir(parents=True, exist_ok=True) directory = Path(tempfile.mkdtemp(prefix=project + '-', dir=artifacts)) + data = directory / 'data' + data.mkdir(mode=0o700) + pins = json.loads((ROOT / 'deploy/compose/smoke-pins.json').read_text()) + rendered = directory / 'compose.yaml' + rendered.write_text(render_compose.render({ + 'IMAGE_CORE': pins['core'], 'IMAGE_WEB': pins['web'], 'IMAGE_INGRESS': pins['ingress'], + 'REVISION': pins['revision'], 'RELEASE_BASE': pins['release_base'], + 'ARCHIVE_CHECKSUM': pins['archive_checksum'], + })) override = directory / 'ports.json' def publish(port): @@ -34,10 +46,10 @@ def publish(port): ]}}})) publish(0) - env = {**os.environ, 'COMPOSE_PROGRESS': 'plain'} + env = {**os.environ, 'COMPOSE_PROGRESS': 'plain', 'OAC_DATA_DIR': str(data)} env.pop('OAC_PUBLIC_URL', None) command = ['docker', 'compose', '--env-file', os.devnull, '-p', project, - '-f', str(ROOT / 'deploy/compose/compose.yaml'), '-f', str(override)] + '-f', str(rendered), '-f', str(override)] def compose(*args, timeout=120): result = subprocess.run(command + list(args), env=env, cwd=ROOT, capture_output=True, timeout=timeout) @@ -85,7 +97,7 @@ def terminate(_signum, _frame): signal.signal(signal.SIGTERM, terminate) try: - print('Starting published images with an unset public URL and empty volumes', flush=True) + print('Starting published images with an unset public URL and an empty data directory', flush=True) compose('up', '-d', '--wait', '--wait-timeout', '600', timeout=900) address = 'http://' + compose('port', 'gateway', '8080').decode().strip() key = compose('run', '--rm', '-T', '--no-deps', 'credentials').decode().strip() @@ -114,7 +126,7 @@ def terminate(_signum, _frame): assert uploaded['bytes'] == len(content), 'Upload was truncated' private_logs(key, project_key) - print('Configuring a reachable URL and recreating containers with the same volumes', flush=True) + print('Configuring a reachable URL and recreating containers with the same data directory', flush=True) # Retain the assigned port across recreation, without claiming a fixed host port. publish(address.rsplit(':', 1)[1]) env['OAC_PUBLIC_URL'] = address diff --git a/scripts/publish-core-release.py b/scripts/publish-core-release.py index 4ad8f8c3..64ad20cc 100644 --- a/scripts/publish-core-release.py +++ b/scripts/publish-core-release.py @@ -19,6 +19,10 @@ "distribution", pathlib.Path(__file__).with_name("core-distribution-manifest.py")) distribution = importlib.util.module_from_spec(spec) spec.loader.exec_module(distribution) +render_spec = importlib.util.spec_from_file_location( + "render_compose", pathlib.Path(__file__).with_name("render-compose.py")) +render_compose = importlib.util.module_from_spec(render_spec) +render_spec.loader.exec_module(render_compose) REPOSITORY = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+") @@ -250,18 +254,33 @@ def upload(path): or any(a["state"] != "uploaded" for a in actual) or {a["name"]: a["size"] for a in actual} != expected): raise ValueError("Release asset inventory differs from the build") - if mode == "draft": - return - # GHCR is not transactional with Releases. Keep the Release a draft until - # every versioned image has been pushed and verified. Matching tags are reusable. images = publish_images(assets, repository, revision, tag) + compose_files = render_compose.write_assets(assets, { + "IMAGE_CORE": images["core"]["digest"], + "IMAGE_WEB": images["web"]["digest"], + "IMAGE_INGRESS": images["ingress"]["digest"], + "REVISION": revision, + "RELEASE_BASE": "https://github.com/" + repository + "/releases/download/" + tag + "/", + "ARCHIVE_CHECKSUM": distribution.sha256(assets / (stem + ".tar.gz")), + }) + expected.update({path.name: path.stat().st_size for path in compose_files}) + parallel_each(upload, compose_files) + release = api(repository, endpoint) + verify_draft(release, tag, revision) + expected.update({path.name: path.stat().st_size for path in compose_files}) + actual = release["assets"] + if (len(actual) != len(expected) + or any(a["state"] != "uploaded" for a in actual) + or {a["name"]: a["size"] for a in actual} != expected): + raise ValueError("Compose asset inventory differs from the build") inventory = json.dumps({"source_commit": revision, "images": images}, indent=2) + "\n" - # Keep digest receipts in the Actions summary without changing release assets. if os.environ.get("GITHUB_STEP_SUMMARY"): with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary: summary.write("## GHCR images\n\n```json\n" + inventory + "```\n") print(inventory) - # Uploads can take minutes. Recheck immediately before the one publish request. + if mode == "draft": + return + # Uploads can take minutes. Recheck the tag immediately before publishing the draft. verify_tag(repository, tag, revision) result = api(repository, endpoint, "--method", "PATCH", "-F", "draft=false") if result["id"] != release_id or result["draft"] or result["tag_name"] != tag: diff --git a/scripts/publish-core-release.test.py b/scripts/publish-core-release.test.py index b98b9314..eebada29 100644 --- a/scripts/publish-core-release.test.py +++ b/scripts/publish-core-release.test.py @@ -52,7 +52,9 @@ def setUp(self): self.context_repository = self.canonical_repository = "MiniMax-AI/OpenAgentCore" stack = contextlib.ExitStack() self.addCleanup(stack.close) - self.images = stack.enter_context(mock.patch.object(publisher, "publish_images", return_value={})) + self.images = stack.enter_context(mock.patch.object(publisher, "publish_images", return_value={ + name: {"digest": "ghcr.io/minimax-ai/openagentcore/" + name + "@sha256:" + "ab" * 32} + for name in ("core", "web", "runtime", "ingress")})) self.api = stack.enter_context(mock.patch.object(publisher, "api", side_effect=self.response)) def test_registry_failure_leaves_release_draft(self): @@ -62,9 +64,11 @@ def test_registry_failure_leaves_release_draft(self): self.assertTrue(self.release["draft"]) self.assertFalse(any("PATCH" in call.args for call in self.writes())) - def test_draft_does_not_publish_images(self): + def test_draft_publishes_images_and_stays_unpublished(self): self.publish(tag="build-" + self.revision, mode="draft") - self.images.assert_not_called() + self.images.assert_called_once() + self.assertTrue(self.release["draft"]) + self.assertEqual(len(self.release["assets"]), 16) def test_missing_native_asset_refuses_release_creation(self): (self.assets / f"oac-native-{self.revision}-windows-amd64.tar.gz").unlink() @@ -135,7 +139,7 @@ def response(repo, endpoint, *args): return result if endpoint == "releases/7": self.assertEqual(active, 0) - self.assertEqual(len(self.release["assets"]), 12) + self.assertIn(len(self.release["assets"]), (12, 16)) return self.response(repo, endpoint, *args) self.api.side_effect = response self.publish() @@ -146,7 +150,7 @@ def test_version_tag_publishes_complete_fixed_id(self): self.publish() self.assertFalse(self.release["draft"]) self.assertFalse(self.release["prerelease"]) - self.assertEqual(len(self.release["assets"]), 12) + self.assertEqual(len(self.release["assets"]), 16) self.assertEqual(self.api.call_args.args[1:], ("releases/7", "--method", "PATCH", "-F", "draft=false")) diff --git a/scripts/render-compose.py b/scripts/render-compose.py new file mode 100644 index 00000000..88882670 --- /dev/null +++ b/scripts/render-compose.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Fill the Compose template with one release's image digests and node metadata. + +The template is deploy/compose/compose.yaml. A release publishes the rendered +file; this script does not run Docker. +""" +import hashlib +import pathlib +import re +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[1] +TEMPLATE = ROOT / "deploy/compose/compose.yaml" +PORTS = ROOT / "deploy/compose/ports.yaml" +TOKENS = ("IMAGE_CORE", "IMAGE_WEB", "IMAGE_INGRESS", "REVISION", "RELEASE_BASE", "ARCHIVE_CHECKSUM") +IMAGE = re.compile(r"^.+@sha256:[0-9a-f]{64}$") + + +def render(values): + """Return compose.yaml text. values uses the token names in TOKENS.""" + missing = [name for name in TOKENS if name not in values] + if missing: + raise ValueError("Missing Compose values: " + ", ".join(missing)) + for name in ("IMAGE_CORE", "IMAGE_WEB", "IMAGE_INGRESS"): + if not IMAGE.fullmatch(values[name]): + raise ValueError(name + " must be a digest-pinned image reference") + if not re.fullmatch(r"[0-9a-f]{40}", values["REVISION"]): + raise ValueError("REVISION must be a full source commit SHA") + if not re.fullmatch(r"[0-9a-f]{64}", values["ARCHIVE_CHECKSUM"]): + raise ValueError("ARCHIVE_CHECKSUM must be a SHA-256 hex digest") + base = values["RELEASE_BASE"] + if not base.startswith("https://") or not base.endswith("/") or " " in base: + raise ValueError("RELEASE_BASE must be an https URL ending with /") + text = TEMPLATE.read_text() + for name in TOKENS: + token = "__OAC_" + name + "__" + if token not in text: + raise ValueError("Compose template is missing " + token) + text = text.replace(token, values[name]) + leftover = sorted(set(re.findall(r"__OAC_[A-Z_]+__", text))) + if leftover: + raise ValueError("Unreplaced Compose tokens: " + ", ".join(leftover)) + return text + + +def checksum_line(name, data): + body = data.encode() if isinstance(data, str) else data + return hashlib.sha256(body).hexdigest() + " " + name + "\n" + + +def write_assets(directory, values): + """Write compose.yaml, ports.yaml and their checksums. Returns the four paths.""" + directory = pathlib.Path(directory) + rendered = render(values) + ports = PORTS.read_bytes() + files = { + "compose.yaml": rendered.encode(), + "ports.yaml": ports, + } + written = [] + for name, data in files.items(): + path = directory / name + path.write_bytes(data) + checksum = path.with_name(name + ".sha256") + checksum.write_text(checksum_line(name, data)) + written.extend((path, checksum)) + return written From eba8300a353f460567f42a22578fc982c66cf1a4 Mon Sep 17 00:00:00 2001 From: yuanhe Date: Fri, 2 Oct 2026 23:52:16 +0800 Subject: [PATCH 02/23] Report the process settings Core loaded, and check them before startup. Core validates its environment in one place, including oac-core check-config, and no longer reads the installer settings file. Co-authored-by: Cursor --- .../src/features/system/StartupSettings.tsx | 4 +- apps/web/src/i18n/locales/en/system.ts | 5 +- apps/web/src/i18n/locales/zh-CN/system.ts | 5 +- apps/web/src/lib/installation.ts | 2 +- contracts/agents-api/admin-api.md | 11 +- contracts/agents-api/core.openapi.yaml | 10 +- contracts/agents-api/zh/admin-api.md | 13 +- deploy/install/configuration.py | 5 +- docs/configuration.md | 13 +- docs/zh/configuration.md | 15 +- .../agents-client/src/admin-projection.ts | 4 +- packages/agents-client/src/admin-types.ts | 9 +- services/core/cmd/server/installation.go | 23 +- services/core/cmd/server/main.go | 17 +- .../core/cmd/server/process_configuration.go | 44 +--- .../cmd/server/process_configuration_test.go | 12 +- services/core/internal/api/installation.go | 29 +-- .../core/internal/processconfig/config.go | 204 ++++++++++++++++++ .../internal/processconfig/config_test.go | 48 +++++ 19 files changed, 340 insertions(+), 133 deletions(-) create mode 100644 services/core/internal/processconfig/config.go create mode 100644 services/core/internal/processconfig/config_test.go diff --git a/apps/web/src/features/system/StartupSettings.tsx b/apps/web/src/features/system/StartupSettings.tsx index edcec53e..9d999e2f 100644 --- a/apps/web/src/features/system/StartupSettings.tsx +++ b/apps/web/src/features/system/StartupSettings.tsx @@ -28,7 +28,7 @@ export function StartupSettings({ configuration }: { configuration: CoreInstalla
{configuration === null ?

{t("startup.none")}

: <>

- + {configuration.path ? , }} /> - + : {t("startup.effective")}} {configuration.applied_at ? {t("startup.appliedAt", { time: formatDateTime(Date.parse(configuration.applied_at) / 1000, locale) })} : null}

diff --git a/apps/web/src/i18n/locales/en/system.ts b/apps/web/src/i18n/locales/en/system.ts index 66f18a9c..b625e697 100644 --- a/apps/web/src/i18n/locales/en/system.ts +++ b/apps/web/src/i18n/locales/en/system.ts @@ -53,8 +53,9 @@ export const system = { }, startup: { title: "Startup settings", - help: "Core reads these from config.json when it starts. The console only shows them.", - none: "Core was not started from a config.json, so there are no startup settings to show.", + help: "Core reports the process settings it loaded. A sensitive setting shows only whether it is set.", + none: "Core did not report startup settings.", + effective: "These are the settings this Core process loaded.", where: "Change these in , then run ", copyPath: "Copy path", copyCommand: "Copy command", diff --git a/apps/web/src/i18n/locales/zh-CN/system.ts b/apps/web/src/i18n/locales/zh-CN/system.ts index e3a71b5f..fb01e5d2 100644 --- a/apps/web/src/i18n/locales/zh-CN/system.ts +++ b/apps/web/src/i18n/locales/zh-CN/system.ts @@ -55,8 +55,9 @@ export const system: TranslationShape = { }, startup: { title: "启动设置", - help: "Core 启动时从 config.json 读取这些设置。控制台只显示它们。", - none: "Core 不是通过 config.json 启动的,没有可显示的启动设置。", + help: "Core 报告它加载的进程设置。敏感设置只显示是否已设置。", + none: "Core 没有报告启动设置。", + effective: "这些是这个 Core 进程加载的设置。", where: "在 中修改,然后运行 ", copyPath: "复制路径", copyCommand: "复制命令", diff --git a/apps/web/src/lib/installation.ts b/apps/web/src/lib/installation.ts index cad31446..0c4bd3ed 100644 --- a/apps/web/src/lib/installation.ts +++ b/apps/web/src/lib/installation.ts @@ -4,7 +4,7 @@ import { admin } from "./admin-view"; /** * This installation's public address, its `/v1` base URL for applications and - * its config.json startup settings (`/core/v1/installation`). Readable before + * the process settings Core loaded (`/core/v1/installation`). Readable before * any sandbox deployment exists; the console never writes it. */ export const installationQuery = queryOptions({ diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index 40e722ad..fe39e5e6 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -137,17 +137,12 @@ Core writes this record in the same transaction that creates the Session. Later | `api_base_url` | `public_url` followed by `/v1`, the `OPENAI_BASE_URL` for Project API keys. Null when `public_url` is null | | `local_only` | True when `public_url` names a loopback host, which only the Core host reaches | | `source_commit` | The full source commit Core was built from; null for development builds | -| `configuration` | The installer's snapshot of `config.json`; null when the installer did not start Core | +| `configuration` | The process settings Core loaded from its environment. `path` and `apply_command` are empty, and `applied_at` is null | | `address_bindings` | What a change of `public_url` affects, counted on each read | -`configuration` has: +`configuration.settings` has one entry per setting Core loaded, with its dotted `key`, effective `value`, `default`, whether it is `changeable`, whether it is `sensitive`, and the services it `restarts` (`core`, `web`, `database`). -- `path`: the absolute host path of `config.json`, by default `~/.oac/core/config.json`; -- `apply_command`: the command that applies changes, by default `~/.oac/core/oac apply`; -- `applied_at`: when the snapshot was last applied; -- `settings`: one entry per setting, with its dotted `key`, applied `value`, `default`, whether it is `changeable` after installation, whether it is `sensitive`, and the services it `restarts` (`core`, `web`, `database`). - -A sensitive setting has null `value` and `default` and a boolean `configured` instead; only sensitive settings have `configured`. Core refuses to start when the snapshot breaks this rule, repeats a key or has an unknown member. Core only reports the snapshot; [configuration](../../docs/configuration.md) describes each setting. +A sensitive setting has null `value` and `default` and a boolean `configured` instead; only sensitive settings have `configured`. `oac-core check-config` validates the same environment and exits without starting Core or printing a value. [Configuration](../../docs/configuration.md) describes each setting. | `address_bindings` field | Meaning | | --- | --- | diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index f15a9312..6d52b981 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -437,7 +437,7 @@ definitions: configuration: allOf: - $ref: '#/definitions/api.InstallationConfiguration' - description: The installer's settings snapshot (OAC_SETTINGS_FILE); null when the installer did not start Core. + description: The process settings Core loaded. path and apply_command are empty, and applied_at is null, because Core reports its environment rather than an installer file. x-nullable: true installation_id: description: OAC_INSTALLATION_ID; null when Core runs without the sandbox manager. @@ -462,12 +462,14 @@ definitions: api.InstallationConfiguration: properties: applied_at: + description: Null when Core reports its own environment. type: string + x-nullable: true apply_command: - description: Command that applies config.json changes. + description: Command that applies config.json changes. Empty when Core reports its own environment. type: string path: - description: Absolute host path of the installation's config.json. + description: Absolute host path of config.json. Empty when Core reports its own environment. type: string settings: items: @@ -3594,7 +3596,7 @@ paths: - Deployment Model Providers /core/v1/installation: get: - description: Core key only; available before any sandbox deployment exists. Reports the public URL that applications, nodes, sandboxes and self-hosted executors use, the API base URL, Core's source commit and installation ID, the installer's settings snapshot with where to change it, and what is bound to the current public URL. Sensitive settings report only whether they are configured. + description: Core key only; available before any sandbox deployment exists. Reports the public URL that applications, nodes, sandboxes and self-hosted executors use, the API base URL, Core's source commit and installation ID, the process settings Core loaded, and what is bound to the current public URL. Sensitive settings report only whether they are configured. produces: - application/json responses: diff --git a/contracts/agents-api/zh/admin-api.md b/contracts/agents-api/zh/admin-api.md index 071bb2c4..c86c1de6 100644 --- a/contracts/agents-api/zh/admin-api.md +++ b/contracts/agents-api/zh/admin-api.md @@ -1,7 +1,7 @@ --- title: "Core 管理 API" source: contracts/agents-api/admin-api.md -source_hash: 04fbf3485f88ba0395efb31fec57b2e40e9584db6c648f7e83ab7866e45bdfb2 +source_hash: 3fc6573b19b9c78ca8a3b275122793b1a99e31f739d83ff25ff56624013dc428 --- Core 管理 API(`/core/v1`)用于管理安装实例:Project 及其 API 密钥、Project 资源的读取和删除、执行器凭据、部署默认模型、沙箱部署及其节点、监控和审计。Web 的[控制台服务器](../../../docs/zh/web/console-server.md#forwarding-to-core)会为已登录的管理员调用它;运维人员则从 Core 主机上的脚本调用它([编写 Core API 脚本](../../../docs/zh/getting-started/operations.md#script-the-core-api))。生成的架构是 [core.openapi.yaml](../core.openapi.yaml),所有错误都使用 [Core 错误封装](core-errors.md)。 @@ -139,17 +139,12 @@ Core 会在创建 Session 的同一事务中写入此记录。之后的 Agent | `api_base_url` | 在 `public_url` 后附加 `/v1`,即 Project API 密钥使用的 `OPENAI_BASE_URL`。当 `public_url` 为 null 时为 null | | `local_only` | 当 `public_url` 指向回环主机时为 True,该主机只能由 Core 主机访问 | | `source_commit` | Core 构建所依据的完整源代码提交;开发构建为 null | -| `configuration` | 安装器对 `config.json` 的快照;安装器未启动 Core 时为 null | +| `configuration` | Core 从环境加载的进程设置。`path` 和 `apply_command` 为空,`applied_at` 为 null | | `address_bindings` | 更改 `public_url` 所影响的内容,每次读取都会重新统计 | -`configuration` 包含: +`configuration.settings` 为 Core 加载的每项设置一条记录,包含以点分隔的 `key`、生效的 `value`、`default`、是否 `changeable`、是否 `sensitive`,以及会 `restarts` 的服务(`core`、`web`、`database`)。 -- `path`:`config.json` 在主机上的绝对路径,默认值为 `~/.oac/core/config.json`; -- `apply_command`:应用更改的命令,默认值为 `~/.oac/core/oac apply`; -- `applied_at`:最近一次应用快照的时间; -- `settings`:每个设置对应一个条目,包含以点分隔的 `key`、已应用的 `value`、`default`、安装后是否可 `changeable`、是否 `sensitive`,以及会 `restarts` 的服务(`core`、`web`、`database`)。 - -敏感设置的 `value` 和 `default` 为 null,并改为包含一个布尔值 `configured`;只有敏感设置具有 `configured`。如果快照违反此规则、重复使用某个键或包含未知成员,Core 将拒绝启动。Core 仅报告该快照;[配置](../../../docs/zh/configuration.md)会说明每个设置。 +敏感设置的 `value` 和 `default` 为 null,并改为包含一个布尔值 `configured`;只有敏感设置具有 `configured`。`oac-core check-config` 校验同一组环境变量,然后退出,不启动 Core,也不打印值。[配置](../../../docs/zh/configuration.md)会说明每个设置。 | `address_bindings` 字段 | 含义 | | --- | --- | diff --git a/deploy/install/configuration.py b/deploy/install/configuration.py index a09a3813..6a03adc0 100644 --- a/deploy/install/configuration.py +++ b/deploy/install/configuration.py @@ -196,7 +196,6 @@ def core_environment(root, config, state): "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": RUN + "/settings.json", "OAC_PROVIDER_ROOT": "/opt/oac", "OAC_PROVIDER_STATE_ROOT": "/state", "OAC_DEFAULT_HARNESS": core["default_harness"], @@ -242,7 +241,7 @@ def compose_config(root, config, state, candidate=None): 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")] + mounts += [bind(root / "generated" / name, f"{RUN}/{name}") for name in ("core-key-digests.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, @@ -318,7 +317,7 @@ def render(root, config, state, applied_at, candidate=None): 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. + # leaves Core running. Core reports the environment it loaded on its next start. core_settings = [item for item in settings["settings"] if "core" in item["restarts"]] external = { "core": json.dumps({ diff --git a/docs/configuration.md b/docs/configuration.md index e247f772..4a2c02b8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,7 +9,7 @@ Every setting of a Core installation has exactly one home. There are two kinds: | [Process settings](#process-settings-configjson) | Public URL, ports, logging, harnesses, execution concurrency, audit retention, OAuth origins, database pool, Runtime history export | `config.json` in the installation directory (default `~/.oac/core`) | Web domain setup or `oac domain` for managed HTTPS; otherwise edit the file, then run `oac apply` | `oac apply` restarts the services that read the changed settings | | [Runtime settings](#runtime-settings-web) | Sandbox backend and size, nodes, Projects and keys, default models, executor credentials | Core's PostgreSQL database | Web, or the Core API (`/core/v1`) with the Core key | Saved without a Core restart; nodes prepare Runtime changes asynchronously | -Web's **System** page shows the installation's addresses, the default models, the sandbox configuration and, under **Startup settings**, the process settings read-only with the path of `config.json` and the apply command. Secrets live in [`secrets/`](#installation-directory), one copy each. The files in `generated/` are derived from `config.json`. No configuration file defines Projects or API keys. +Web's **System** page shows the installation's addresses, the default models, the sandbox configuration and, under **Startup settings**, the process settings Core loaded. Secrets live in [`secrets/`](#installation-directory), one copy each. The files in `generated/` are derived from `config.json`. No configuration file defines Projects or API keys. ## Process settings: config.json @@ -23,7 +23,7 @@ The installer writes every setting, so the file shows each value. Installer flag ### How oac apply works 1. It validates `config.json` and changes nothing if a value is invalid. `ingress` is fixed after installation; to change it, 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` and the managed `Caddyfile`. 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-