diff --git a/.github/workflows/api-acceptance.yml b/.github/workflows/api-acceptance.yml index 2faff478f..4c881c7d9 100644 --- a/.github/workflows/api-acceptance.yml +++ b/.github/workflows/api-acceptance.yml @@ -40,12 +40,9 @@ jobs: with: go-version-file: go.mod cache: true - - name: Build standalone commands and verify migration entrypoints - env: - OAC_TEST_DATABASE_URL: postgres://agents_api:agents_api_test_only@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/oac_ci_tests?sslmode=disable + - name: Build standalone commands run: | OAC_DEV_CORE_BUILD_DIR="$RUNNER_TEMP/oac-core-build" make build-core - OAC_DATABASE_URL="$OAC_TEST_DATABASE_URL" "$RUNNER_TEMP/oac-core-build/oac-core-migrate" "$RUNNER_TEMP/oac-core-build/oac-core-device" --help "$RUNNER_TEMP/oac-core-build/oac-core-environment-key" --help - uses: actions/setup-python@v6 diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 588a76581..5aa1f5b01 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -190,9 +190,12 @@ jobs: - uses: actions/checkout@v7 with: ref: ${{ inputs.ref || github.sha }} + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod - name: Allocate an isolated Compose project run: python3 -c 'import uuid; print("COMPOSE_SMOKE_PROJECT=oac-smoke-" + uuid.uuid4().hex)' >> "$GITHUB_ENV" - - name: Start published images and verify the installation + - name: Build the images, start the installation and verify it timeout-minutes: 17 run: python3 scripts/compose-smoke.py - name: Remove test containers and volumes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e67a7367e..c370b5fef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -132,7 +132,7 @@ jobs: for asset in "$HOME/.oac/build/core-distribution/"*; do if [[ -f "$asset" ]]; then ln "$asset" "$HOME/.oac/build/release-upload/"; fi done - cp deploy/install-release.sh "$HOME/.oac/build/release-upload/install.sh" + cp deploy/install.sh "$HOME/.oac/build/release-upload/install.sh" (cd "$HOME/.oac/build/release-upload" && sha256sum install.sh > install.sh.sha256) - uses: actions/upload-artifact@v6 with: @@ -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/AGENTS.md b/AGENTS.md index 033f53965..b12acd1e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,7 @@ OpenAgentCore is pre-release. Replace superseded interfaces, execution paths and ## Working in this repository - For each new task, create a new Git worktree. Name its directory after that change's commit subject, in kebab-case, beside the checkout. Run `git pull --ff-only` on the base branch, and create the feature branch in that worktree before development. +- After every push to a pull request, wait 60 seconds, then run `gh pr checks` and confirm CI passes. Fix any failure before reporting the work as done. - [CONTRIBUTING.md](CONTRIBUTING.md): documentation ownership, repository boundary, workflow, independent review, required checks and naming. - [Develop OpenAgentCore](docs/development.md): setup, the repository map, focused checks and [each extension boundary](docs/development.md#choose-an-extension-boundary). - [API index](docs/api/index.md): each route's caller and credential. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 929787bb8..5710c3f55 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ This guide owns how to work in the repository: documentation ownership, the repo | Distribution builds, Runtime image builds, CI and publication | [Maintainer guide](docs/maintainers.md) | | Website: landing page, bilingual documentation maintenance, documentation site build and GitHub Pages publication | [Website guide](website/README.md) | | Self-hosted Runtime installation, recovery and local operation | [Self-hosted execution](docs/getting-started/self-hosted.md) | -| Installer lifecycle, locking, generated state, managed HTTPS and downloads | [Installer design rules](deploy/install/README.md) | +| Installer lifecycle, locking, generated state, managed HTTPS and downloads | [Deployment](deploy/README.md) and [Node installer](deploy/node/README.md) | | Operator installation and alternatives | [Installation](docs/getting-started/install.md), [installation options](docs/getting-started/install-options.md) | | Settings, defaults, files and installation layout | [Configuration](docs/configuration.md) | | Operator commands, keys, backup and version policy | [Operations](docs/getting-started/operations.md) | diff --git a/Makefile b/Makefile index 1c2e0b5ab..c5c4f5907 100644 --- a/Makefile +++ b/Makefile @@ -178,12 +178,13 @@ check-microsandbox-provider: check-distribution: node --test scripts/build-native-catalog.test.mjs go test ./services/web -count=1 - PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s deploy/install -p 'test_*.py' + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s deploy/node -p 'test_*.py' + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s deploy/compose -p 'test_*.py' + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/acceptance -p 'test_*.py' + PYTHONDONTWRITEBYTECODE=1 python3 deploy/test_install.py PYTHONDONTWRITEBYTECODE=1 python3 scripts/core-distribution-manifest.test.py PYTHONDONTWRITEBYTECODE=1 python3 scripts/publish-core-release.test.py - PYTHONDONTWRITEBYTECODE=1 python3 scripts/install-release.test.py - PYTHONDONTWRITEBYTECODE=1 python3 scripts/config-reference.py --check - bash -n deploy/install/install.sh deploy/install-release.sh scripts/build-web.sh scripts/build-core-distribution.sh scripts/build-core-image-context.sh scripts/prepare-release-runtimes.sh + bash -n deploy/install.sh scripts/build-web.sh scripts/build-core-distribution.sh scripts/build-core-image-context.sh scripts/prepare-release-runtimes.sh ./scripts/build-web.sh build-core-distribution: diff --git a/apps/web/README.md b/apps/web/README.md index 6283a645c..d81b9c0ac 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -36,7 +36,7 @@ pnpm --filter @oac/web build pnpm test:web:acceptance ``` -`pnpm test:web:acceptance` runs the Playwright tests in `apps/web/e2e` in Chrome against the fixture console. After each test, every spec except `domain.spec.ts` checks that the browser sent nothing to `/v1` and no `Authorization` header. The tests do not exercise `services/web` or a real Core; the console server has its own Go tests. `make check-web` runs all of these; [CONTRIBUTING.md](../../CONTRIBUTING.md) lists the repository's required checks. +`pnpm test:web:acceptance` runs the Playwright tests in `apps/web/e2e` in Chrome against the fixture console. After each test, every spec checks that the browser sent nothing to `/v1` and no `Authorization` header. The tests do not exercise `services/web` or a real Core; the console server has its own Go tests. `make check-web` runs all of these; [CONTRIBUTING.md](../../CONTRIBUTING.md) lists the repository's required checks. ## README screenshots diff --git a/apps/web/e2e/domain.spec.ts b/apps/web/e2e/domain.spec.ts deleted file mode 100644 index db1561076..000000000 --- a/apps/web/e2e/domain.spec.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { expect, test, type APIRequestContext } from "@playwright/test"; -import { openConsole } from "./console"; - -const fixture = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; -const endpoint = "**/console/installation/domain"; -const setDomain = (request: APIRequestContext, data: Record) => request.post(`${fixture}/__fixture/domain`, { data }); - -test("System opens domain setup; an apply polls verified readiness", async ({ page, request }, info) => { - await openConsole(page, request, "system", { installation: "local" }); - await page.getByRole("button", { name: "Configure domain and HTTPS", exact: true }).click(); - await expect(page.getByRole("heading", { name: "Domain and HTTPS", exact: true })).toBeVisible(); - await page.getByLabel("Domain", { exact: true }).fill("https://core.example.com"); - await expect(page.getByRole("button", { name: "Apply", exact: true })).toBeDisabled(); - await page.getByLabel("Domain", { exact: true }).fill("core.example.com"); - await page.screenshot({ path: info.outputPath("domain-before-apply.png"), fullPage: true }); - await page.getByRole("button", { name: "Apply", exact: true }).click(); - await expect(page.getByText("Checking the domain…", { exact: true })).toBeVisible(); - await expect(page.getByText("HTTPS is ready.", { exact: true })).toHaveCount(0); - await setDomain(request, { state: "ready", public_url: "https://core.example.com" }); - await expect(page.getByText("HTTPS is ready.", { exact: true })).toBeVisible(); - await expect(page.getByRole("link", { name: "Open https://core.example.com" })).toHaveAttribute("href", "https://core.example.com"); -}); - -test("an existing address requires an explicit confirmed write", async ({ page, request }) => { - await openConsole(page, request, "system"); - await setDomain(request, { state: "ready", public_url: "https://old.example.com", target_url: "https://old.example.com" }); - await page.getByRole("button", { name: "Configure domain and HTTPS", exact: true }).click(); - await page.getByLabel("Domain", { exact: true }).fill("new.example.com"); - const writes: unknown[] = []; - page.on("request", (req) => { if (req.method() === "POST" && req.url().endsWith("/console/installation/domain")) writes.push(req.postDataJSON()); }); - await page.getByRole("button", { name: "Apply", exact: true }).click(); - const dialog = page.getByRole("dialog"); - await expect(dialog).toContainText("https://new.example.com"); - expect(writes).toEqual([{ hostname: "new.example.com" }]); - await dialog.getByRole("button", { name: "Change address", exact: true }).click(); - await expect(dialog).toHaveCount(0); - expect(writes).toEqual([{ hostname: "new.example.com" }, { hostname: "new.example.com", confirm_public_url_change: "https://new.example.com" }]); -}); - -test("an unconfirmed write stops retries until status is read again", async ({ page, request }) => { - await openConsole(page, request, "system"); - await setDomain(request, { state: "ready", public_url: "https://old.example.com", target_url: "https://old.example.com" }); - await page.getByRole("button", { name: "Configure domain and HTTPS", exact: true }).click(); - let count = 0; - await page.route(endpoint, (route) => { if (route.request().method() === "POST") { count++; return route.abort(); } return route.continue(); }); - await page.getByLabel("Domain", { exact: true }).fill("core.example.com"); - await page.getByRole("button", { name: "Apply", exact: true }).click(); - await expect(page.getByText("The request was not confirmed. Refresh the status before trying again.")).toBeVisible(); - await expect(page.getByRole("button", { name: "Apply", exact: true })).toBeDisabled(); - expect(count).toBe(1); - await expect(page.getByText("HTTPS is ready.", { exact: true })).toHaveCount(0); - await expect(page.getByRole("link", { name: "Open https://core.example.com" })).toBeVisible(); - await page.getByRole("button", { name: "Refresh domain status", exact: true }).click(); - await expect(page.getByRole("button", { name: "Apply", exact: true })).toBeEnabled(); -}); - -test("a console restart preserves the new-address link on the login screen", async ({ page, request }) => { - await openConsole(page, request, "system?id=domain"); - await page.getByLabel("Domain", { exact: true }).fill("core.example.com"); - await page.getByRole("button", { name: "Apply", exact: true }).click(); - await expect(page.getByText("Checking the domain…", { exact: true })).toBeVisible(); - await page.route("**/console/auth", (route) => route.fulfill({ json: { mode: "login" } })); - await page.evaluate(() => window.dispatchEvent(new Event("focus"))); - await expect(page.getByRole("heading", { name: "Sign in to OpenAgentCore" })).toBeVisible(); - await expect(page.getByRole("link", { name: "Open https://core.example.com" })).toBeVisible(); -}); - -test("unsupported and failed states preserve actionable facts", async ({ page, request }) => { - await openConsole(page, request, "system"); - await setDomain(request, { supported: false, message: "This installation is managed externally." }); - await page.getByRole("button", { name: "Configure domain and HTTPS", exact: true }).click(); - await expect(page.getByText("Domain setup is not available for this installation.", { exact: false })).toBeVisible(); - await page.getByRole("button", { name: "Show details" }).click(); - await expect(page.locator("[role=tooltip]")).toHaveText("This installation is managed externally."); - await expect(page.getByLabel("Domain", { exact: true })).toHaveCount(0); - await setDomain(request, { supported: true, state: "failed", target_url: "https://core.example.com", message: "DNS does not point to this server." }); - await page.reload(); - await expect(page.getByRole("alert")).toContainText("Domain setup failed."); - await page.getByRole("button", { name: "Show details" }).click(); - await expect(page.locator("[role=tooltip]")).toHaveText("DNS does not point to this server."); - await expect(page.getByRole("button", { name: "Retry setup" })).toBeEnabled(); -}); - -test("a confirmed failed setup clears the login handoff", async ({ page, request }) => { - await openConsole(page, request, "system?id=domain"); - await page.getByLabel("Domain", { exact: true }).fill("core.example.com"); - await page.getByRole("button", { name: "Apply", exact: true }).click(); - await expect(page.getByRole("link", { name: "Open https://core.example.com" })).toBeVisible(); - await setDomain(request, { state: "failed", message: "DNS validation failed." }); - await expect(page.getByRole("alert")).toContainText("Domain setup failed."); - await page.route("**/console/auth", (route) => route.fulfill({ json: { mode: "login" } })); - await page.evaluate(() => window.dispatchEvent(new Event("focus"))); - await expect(page.getByRole("heading", { name: "Sign in to OpenAgentCore" })).toBeVisible(); - await expect(page.getByRole("link", { name: "Open https://core.example.com" })).toHaveCount(0); -}); diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index e89b35994..bc5ea4364 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -125,7 +125,7 @@ test("issues no command before the installation is read, for a loopback public U await page.unroute("**/core/v1/installation"); await add.getByRole("button", { name: "Try again" }).click(); // Nodes on other machines can't reach a loopback public_url. - await expect(add.getByRole("status")).toHaveText("Configure a domain and HTTPS in System before adding nodes."); + await expect(add.getByRole("status")).toHaveText("Set a public HTTPS address before adding nodes."); await expect(add.getByRole("button", { name: "Generate command" })).toHaveCount(0); await add.getByRole("button", { name: "Close dialog" }).click(); await expect(page.getByRole("button", { name: "Add node", exact: true })).toBeDisabled(); diff --git a/apps/web/e2e/overview-readiness.spec.ts b/apps/web/e2e/overview-readiness.spec.ts index 6e66bf91a..55d426b23 100644 --- a/apps/web/e2e/overview-readiness.spec.ts +++ b/apps/web/e2e/overview-readiness.spec.ts @@ -77,10 +77,10 @@ test("successful empty reads preserve true zero counts and empty states", async await expect(failed(page)).toHaveCount(0); }); -test("local-only address has a domain setup link on all three pages and blocks Add node", async ({ page, request }) => { +test("local-only address links to System on other pages and blocks Add node", async ({ page, request }) => { await openConsole(page, request, "overview", { installation: "local" }); const notice = page.getByRole("status", { name: "Public address needs attention" }); - await expect(notice.getByRole("button", { name: "Configure domain and HTTPS" })).toBeVisible(); + await expect(notice.getByRole("button", { name: "Review the public address" })).toBeVisible(); await expect(page.locator(".getting-started-step").first()).toContainText("To do"); await expect(page.locator(".getting-started-step").first()).toContainText("Configure HTTPS"); await page.getByRole("button", { name: "Nodes", exact: true }).click(); @@ -89,7 +89,7 @@ test("local-only address has a domain setup link on all three pages and blocks A await expect(page.getByText("Add node is unavailable while the public address is local only.")).toBeVisible(); await page.getByRole("button", { name: "System", exact: true }).click(); await expect(notice).toBeVisible(); - await expect(page.getByRole("button", { name: "Configure domain and HTTPS" })).toHaveCount(1); + await expect(notice.getByRole("button", { name: "Review the public address" })).toHaveCount(0); expect(await writes(request)).toEqual([]); }); @@ -102,7 +102,7 @@ for (const language of ["en", "zh-CN"] as const) { await page.getByRole("menuitemradio", { name: "简体中文" }).click(); } await expect(page.locator(".overview-activity .error-state")).toContainText(language === "en" ? "Could not read the data" : "无法读取数据"); - await expect(page.locator(".installation-notice")).toContainText(language === "en" ? "Configure HTTPS before connecting" : "连接外部应用和节点前"); + await expect(page.locator(".installation-notice")).toContainText(language === "en" ? "Set a public HTTPS address before connecting" : "连接外部应用和节点前"); if (language === "zh-CN") await expect(page.locator("body")).not.toContainText("Core request failed"); await expect(page.getByRole("article").first()).toContainText(language === "en" ? "Down" : "不可用"); for (const width of [1280, 1440]) { diff --git a/apps/web/src/ConsoleApp.tsx b/apps/web/src/ConsoleApp.tsx index 352d8c1ca..debc7e9f8 100644 --- a/apps/web/src/ConsoleApp.tsx +++ b/apps/web/src/ConsoleApp.tsx @@ -17,7 +17,6 @@ import { SandboxManagerView } from "./features/sandbox/SandboxManagerView"; import { SessionLogPage } from "./features/sessions/SessionLogPage"; import { SessionPage } from "./features/sessions/SessionPage"; import { SkillsPage } from "./features/skills/SkillsPage"; -import { DomainPage } from "./features/system/DomainPage"; import { SystemPage } from "./features/system/SystemPage"; import { VaultsPage } from "./features/vaults/VaultsPage"; import { consoleDepth, ConsoleNavigationContext, useConsoleNavigation, hashWithParams, routeParamsFromHash, type ConsoleIntent, type RouteParams } from "./lib/console-navigation"; @@ -57,7 +56,7 @@ function ConsolePage({ view }: { view: ConsoleView }) { case "vaults": return ; case "projects": return ; case "nodes": return ; - case "system": return params.id === "sandbox" ? : params.id === "domain" ? : ; + case "system": return params.id === "sandbox" ? : ; } } diff --git a/apps/web/src/components/InstallationNotice.test.tsx b/apps/web/src/components/InstallationNotice.test.tsx index 1f90001d7..84339b853 100644 --- a/apps/web/src/components/InstallationNotice.test.tsx +++ b/apps/web/src/components/InstallationNotice.test.tsx @@ -10,10 +10,10 @@ const installation: CoreInstallation = { }; describe("local-only installation notice", () => { - it("links to domain setup without exposing installer commands", () => { + it("links to the public address without exposing installer commands", () => { const html = renderToStaticMarkup(); - expect(html).toContain("Configure domain and HTTPS"); - expect(html).toContain("Configure HTTPS before connecting applications and nodes"); + expect(html).toContain("Review the public address"); + expect(html).toContain("Set a public HTTPS address before connecting applications and nodes"); expect(html).not.toContain("config.json"); expect(html).not.toContain("oac apply"); }); diff --git a/apps/web/src/components/InstallationNotice.tsx b/apps/web/src/components/InstallationNotice.tsx index 25342fe20..9000908de 100644 --- a/apps/web/src/components/InstallationNotice.tsx +++ b/apps/web/src/components/InstallationNotice.tsx @@ -10,6 +10,6 @@ export function InstallationNotice({ installation }: { installation: CoreInstall if (!installation?.local_only) return null; return ; } diff --git a/apps/web/src/features/first-run/ConsoleAccess.tsx b/apps/web/src/features/first-run/ConsoleAccess.tsx index c8432e57c..d9ef1f408 100644 --- a/apps/web/src/features/first-run/ConsoleAccess.tsx +++ b/apps/web/src/features/first-run/ConsoleAccess.tsx @@ -7,7 +7,6 @@ import { ThemeMenu } from "../../components/ThemeMenu"; import { useToast } from "../../components/Toast"; import { setLanguage } from "../../i18n"; import { queryClient } from "../../lib/queries"; -import { DomainHandoff } from "../system/DomainHandoff"; import { OnboardingLayout } from "../onboarding/OnboardingLayout"; import { withTransition } from "../onboarding/view-transition"; import { changeConsoleAuth, ConsoleAuthError, readConsoleAuth, type ConsoleAuth } from "./auth"; @@ -20,7 +19,7 @@ import "./console-access.css"; */ const CORE_KEY_LOCATION = { file: "secrets/core.key", defaultPath: "~/.oac/core/secrets/core.key" } as const; -const ConsoleAccountContext = createContext<{ logout: () => Promise; setDomainHandoff: (url: string | null) => void } | null>(null); +const ConsoleAccountContext = createContext<{ logout: () => Promise } | null>(null); export const useConsoleAccount = () => useContext(ConsoleAccountContext); export function ConsoleLanguage() { @@ -48,7 +47,6 @@ export function ConsoleAccountMenu() { export function ConsoleAccess({ children }: { children: ReactNode }) { const { t } = useTranslation("firstRun"); const [status, setStatus] = useState(null); - const [domainHandoff, setDomainHandoff] = useState(null); const [failed, setFailed] = useState(false); const [revision, setRevision] = useState(0); const generation = useRef(0); @@ -77,18 +75,15 @@ export function ConsoleAccess({ children }: { children: ReactNode }) { window.addEventListener("focus", check); return () => { window.clearInterval(timer); window.removeEventListener("focus", check); }; }, [status?.mode, refresh]); - if (status?.mode === "authenticated") return { + if (status?.mode === "authenticated") return { const next = await changeConsoleAuth({ action: "logout" }); generation.current++; - setDomainHandoff(null); acceptStatus(next, true); } }}>{children}; return }> - {status && !failed ? { queryClient.clear(); - setDomainHandoff(null); // The console opens on the Overview, revealed from the pressed button. withTransition("enter", () => { generation.current++; diff --git a/apps/web/src/features/overview/GettingStarted.tsx b/apps/web/src/features/overview/GettingStarted.tsx index 0b8b9a53d..6589102b3 100644 --- a/apps/web/src/features/overview/GettingStarted.tsx +++ b/apps/web/src/features/overview/GettingStarted.tsx @@ -119,7 +119,7 @@ export function GettingStarted({ fleet, sessions, localOnly, sandboxReset, onRet : localOnly === "failed" ? { label: t("actions.retry", { ns: "common" }), run: onRetryInstallation } : localOnly === true - ? { label: t("installationNotice.configure", { ns: "common" }), run: () => navigate("system", { id: "domain" }) } + ? { label: t("installationNotice.configure", { ns: "common" }), run: () => navigate("system") } : sandbox.action === "setup" ? { label: t("gettingStarted.sandboxes.setup"), run: () => navigate("system", { id: "sandbox" }) } : sandbox.action === "add-node" diff --git a/apps/web/src/features/sandbox/NodeCleanupDialog.tsx b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx index e9d44ee0f..9f4815a3c 100644 --- a/apps/web/src/features/sandbox/NodeCleanupDialog.tsx +++ b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx @@ -19,7 +19,7 @@ export interface NodeCleanup { /** * After Remove: the command that removes the node's service and files from its - * host (deploy/install/node_install.py `uninstall_system`). + * host (deploy/node/node_install.py `uninstall_system`). * The installer first confirms with Core, at the node's own address, that the * node is removed, which holds from the removal on. The command uses sudo unless the shell is already root. A node enrolled with an earlier address may find it gone; then * `--force` skips only that confirmation. Nothing deletes sandboxes, volumes or diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index af0a07e1a..3148bc2e8 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -103,7 +103,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, : installation.data === undefined ? installation.isError ? { text: t("The installation couldn't be read, so no command can be issued."), failed: true } : { text: t("Checking this installation's public URL…") } : !publicUrl - ? { text: t("Configure a domain and HTTPS in System before adding nodes.") } + ? { text: t("Set a public HTTPS address before adding nodes.") } : !nodeFilesAvailable(consoleConfig, deployment.provider) ? { text: t("This console has no node files for {{provider}}. Install Core from the offline bundle, or add the release artifacts and rerun ./install.sh.", { provider: backend }) } : null; diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 63fb3316a..70619c608 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -386,7 +386,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab

{rejection}

{resetRequired ?

{t("Cancel editing to use Reset deployment. This change requires an explicit reset; the saved configuration is unchanged.")}

: null} - {addressRejected ? : null} + {addressRejected ? : null}
) : null} {/* Initial setup needs the cleared key re-entered; updates may keep the saved key. */} diff --git a/apps/web/src/features/sandbox/deployment-specification.ts b/apps/web/src/features/sandbox/deployment-specification.ts index d7ac12f5c..5fb2bc773 100644 --- a/apps/web/src/features/sandbox/deployment-specification.ts +++ b/apps/web/src/features/sandbox/deployment-specification.ts @@ -11,7 +11,6 @@ interface Manifest { microsandbox?: { runtime_sha256?: string; firmware_sha256?: string }; } -// The Standard sizes are a contract with the Core installer (install.sh); see standard-sizes.md before changing their structure. export function defaultSandboxResources(provider: SandboxProvider): SandboxResources { return { ...(provider === "microsandbox" ? standardSizes.microsandbox : standardSizes.docker) }; } diff --git a/apps/web/src/features/sandbox/node-enrollment.ts b/apps/web/src/features/sandbox/node-enrollment.ts index 48b9bf736..49f7666e4 100644 --- a/apps/web/src/features/sandbox/node-enrollment.ts +++ b/apps/web/src/features/sandbox/node-enrollment.ts @@ -6,7 +6,7 @@ import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic"; /** * How long the installer waits, after starting the node service, for Core to * report the node connected and its provider ready - * (deploy/install/node_install.py `wait_ready`, timeout=60). + * (deploy/node/node_install.py `wait_ready`, timeout=60). */ export const NODE_READY_WAIT_MS = 60_000; @@ -17,7 +17,7 @@ export interface HostPrerequisite { /** * What a host needs for the default command, which runs the installer as root * and the node as the `oac-node` system service (sudo mode), as the command - * and deploy/install/node_install.py check it: + * and deploy/node/node_install.py check it: * - the command runs curl, sha256sum and python3 (enrollment-command.ts), and * `sudo` unless the shell is root; `host_checks` needs Python 3.9+, Linux amd64, * systemd as the init system, and SELinux not enforcing; `other_node` refuses a diff --git a/apps/web/src/features/sandbox/standard-sizes.md b/apps/web/src/features/sandbox/standard-sizes.md index 04328c5e3..8c39ef49b 100644 --- a/apps/web/src/features/sandbox/standard-sizes.md +++ b/apps/web/src/features/sandbox/standard-sizes.md @@ -20,10 +20,4 @@ The values must stay within the bounds that Core and `validSandboxResources` acc ## Readers -- The Web setup wizard, through `defaultSandboxResources` in `deployment-specification.ts`. -- The release bundle: `scripts/build-core-distribution.sh` copies this file to `/standard-sizes.json`. -- The Core installer: `deploy/install/sandbox_setup.py` reads the bundled copy when `install.sh` saves the initial microsandbox deployment at the Standard size. - -## Contract - -The keys and structure are a contract with the Core installer. Changing a value is fine. Renaming, removing or adding keys, or restructuring the file, needs a matching change to `deploy/install/sandbox_setup.py`, which rejects a bundled copy whose fields differ. `deployment-specification.test.ts` pins the structure so that an accidental change fails. +The Web setup wizard is the only reader, through `defaultSandboxResources` in `deployment-specification.ts`. `deployment-specification.test.ts` pins the structure so that an accidental change fails. diff --git a/apps/web/src/features/system/DomainHandoff.tsx b/apps/web/src/features/system/DomainHandoff.tsx deleted file mode 100644 index c078c4cb0..000000000 --- a/apps/web/src/features/system/DomainHandoff.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { domainTarget } from "./domain-api"; - -/** A navigation invitation, never a browser-side claim that HTTPS is reachable. */ -export function DomainHandoff({ url }: { url: string | null }) { - const { t } = useTranslation("system"); - const target = domainTarget(url); - if (!target) return null; - return ; -} diff --git a/apps/web/src/features/system/DomainPage.tsx b/apps/web/src/features/system/DomainPage.tsx deleted file mode 100644 index 18f70b345..000000000 --- a/apps/web/src/features/system/DomainPage.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, ShieldCheck } from "lucide-react"; -import { useEffect, useRef, useState, type FormEvent } from "react"; -import { useTranslation } from "react-i18next"; -import { ConfirmDialog } from "../../components/ConfirmDialog"; -import { HelpTip, PageBody, RefreshButton } from "../../components/console-ui"; -import { ErrorState } from "../../components/ErrorState"; -import { TableSkeleton } from "../../components/Skeleton"; -import { useConsoleNavigation } from "../../lib/console-navigation"; -import { useConsoleAccount } from "../first-run/ConsoleAccess"; -import { applyDomain, domainHostname, domainInProgress, domainQuery, domainReconnecting, DomainRequestError, type DomainStatus } from "./domain-api"; -import { DomainHandoff } from "./DomainHandoff"; -import "./domain.css"; - -export function DomainPage() { - const { t } = useTranslation("system"); - const { navigate } = useConsoleNavigation(); - const query = useQuery(domainQuery); - return
-
-
- -

{t("domain.title")}

{t("domain.help")} -
-
- - {query.data ? : query.isError - ? void query.refetch()} /> - : } - -
; -} - -function initialHost(snapshot: DomainStatus): string { - try { return domainHostname(new URL(snapshot.target_url ?? snapshot.public_url ?? "").hostname) ?? ""; } - catch { return ""; } -} - -function DomainForm({ initial }: { initial: DomainStatus }) { - const { t } = useTranslation("system"); - const account = useConsoleAccount(); - const setDomainHandoff = account?.setDomainHandoff; - const queryClient = useQueryClient(); - const query = useQuery(domainQuery); - const snapshot = query.data ?? initial; - const [hostname, setHostname] = useState(() => initialHost(initial)); - const [busy, setBusy] = useState(false); - const [uncertain, setUncertain] = useState(false); - const [attemptedUrl, setAttemptedUrl] = useState(null); - const [error, setError] = useState<{ text: string; detail?: string } | null>(null); - const [confirmation, setConfirmation] = useState(null); - const pending = useRef(false); - const lifetime = useRef(null); - useEffect(() => { - const controller = new AbortController(); lifetime.current = controller; - return () => controller.abort(); - }, []); - - const host = domainHostname(hostname); - const active = domainInProgress(snapshot); - const blocked = busy || active || uncertain || query.isError || query.isFetching || !snapshot.supported; - const failed = query.isError && !domainReconnecting(query); - const reconnect = uncertain || busy ? attemptedUrl : snapshot.state === "ready" ? snapshot.target_url ?? snapshot.public_url : (active || query.isError) ? snapshot.target_url : null; - useEffect(() => { - if (reconnect) setDomainHandoff?.(reconnect); - else if (snapshot.state === "failed" || snapshot.state === "unconfigured") setDomainHandoff?.(null); - }, [reconnect, snapshot.state, setDomainHandoff]); - - async function refresh() { - const current = lifetime.current; - const result = await query.refetch(); - if (current?.signal.aborted || result.isError) return; - setUncertain(false); setError(null); setAttemptedUrl(null); - if (result.data?.state === "failed" || result.data?.state === "unconfigured") account?.setDomainHandoff(null); - } - - async function save(name: string, confirmed = false) { - const current = lifetime.current; - if (!current || current.signal.aborted || pending.current || blocked) return; - pending.current = true; setBusy(true); setError(null); - const target = `https://${name}`; - setAttemptedUrl(target); - try { - await queryClient.cancelQueries({ queryKey: domainQuery.queryKey }); - if (current.signal.aborted) return; - account?.setDomainHandoff(target); - const next = await applyDomain(name, confirmed, AbortSignal.any([current.signal, AbortSignal.timeout(30_000)])); - if (current.signal.aborted) return; - queryClient.setQueryData(domainQuery.queryKey, next); - setConfirmation(null); - account?.setDomainHandoff(next.target_url); - } catch (cause) { - if (current.signal.aborted) return; - if (cause instanceof DomainRequestError && cause.status === 409 && cause.code === "public_url_confirmation_required") { - account?.setDomainHandoff(null); - setConfirmation(name); - } else if (cause instanceof DomainRequestError && cause.status >= 400 && cause.status < 500 && cause.status !== 408) { - account?.setDomainHandoff(null); - setError({ text: t("domain.rejected"), detail: cause.message }); - } else { - setConfirmation(null); setUncertain(true); setError({ text: t("domain.uncertain") }); - account?.setDomainHandoff(target); - } - } finally { - pending.current = false; - if (!current.signal.aborted) setBusy(false); - } - } - - function submit(event: FormEvent) { event.preventDefault(); if (host && !confirmation) void save(host); } - - return
- {!snapshot.supported ?

{t("domain.unsupported")} {snapshot.message ? {snapshot.message} : null}

: <> -
-
- {t("domain.hostnameHelp")} - { setHostname(event.target.value); setError(null); }} /> - {hostname.trim() && !host ?

{t("domain.invalidHostname")}

: null} -
-
-

{t("domain.prerequisites")}

-
- - void refresh()} /> -
-
- {(active || snapshot.state === "ready") && !failed && !busy && !uncertain ?

{t(`domain.states.${snapshot.state}`)}

: null} - {snapshot.state === "failed" ?

{t("domain.failed")} {snapshot.message ? {snapshot.message} : null}

: null} - {failed ?

{t(active ? "domain.disconnected" : "domain.loadFailed")}

: null} - {error && !confirmation ?

{error.text} {error.detail ? {error.detail} : null}

: null} - - } - { setConfirmation(null); setError(null); }} onConfirm={() => { if (confirmation) void save(confirmation, true); }}> -

{t("domain.confirmBody", { url: confirmation ? `https://${confirmation}` : "" })}

- {error?.detail ? {error.detail} : null} -
-
; -} diff --git a/apps/web/src/features/system/StartupSettings.tsx b/apps/web/src/features/system/StartupSettings.tsx index edcec53e2..9d999e2f5 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/features/system/SystemPage.tsx b/apps/web/src/features/system/SystemPage.tsx index a9ac25207..0fc42d3cd 100644 --- a/apps/web/src/features/system/SystemPage.tsx +++ b/apps/web/src/features/system/SystemPage.tsx @@ -40,7 +40,7 @@ export function SystemPage() { const about = installation.data; const facts = about ? ( -
navigate("system", { id: "domain" })}>{t("domain.configure")}}> +
{about.public_url ? {about.public_url} : {t("installation.notSet")}} diff --git a/apps/web/src/features/system/domain-api.test.ts b/apps/web/src/features/system/domain-api.test.ts deleted file mode 100644 index d4bee11f6..000000000 --- a/apps/web/src/features/system/domain-api.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -// QueryObserver enables browser polling only when window exists at module load. -vi.hoisted(() => vi.stubGlobal("window", {})); - -import { QueryClient, QueryObserver } from "@tanstack/react-query"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { applyDomain, DOMAIN_RECONNECT_GRACE_MS, domainHostname, domainQuery, domainReconnecting, domainTarget, DomainRequestError } from "./domain-api"; - -afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); - -describe("domain navigation and writes", () => { - it("accepts DNS names while rejecting URLs, IPs and path-like input", () => { - expect(domainHostname(" CORE.Example.COM ")).toBe("core.example.com"); - expect(domainHostname("例子.com")).toBe("xn--fsqu00a.com"); - for (const input of ["https://core.example.com", "127.0.0.1", "2130706433", "localhost", "core.example.com:443", "user@core.example.com", "core.example.com/path", "core.example.com?x=1", "-core.example.com"]) expect(domainHostname(input)).toBeNull(); - for (const target of ["javascript:alert(1)", "http://core.example.com", "https://core.example.com@evil.example.com", "https://core.example.com/path"]) expect(domainTarget(target)).toBeNull(); - expect(domainTarget("https://core.example.com")).toBe("https://core.example.com"); - }); - it("sends explicit confirmation to the same-origin manager exactly once", async () => { - const snapshot = { supported: true, state: "checking", public_url: null, target_url: "https://core.example.com", message: null }; - const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(snapshot), { status: 202 })); - vi.stubGlobal("fetch", fetch); - expect(await applyDomain("core.example.com", true, new AbortController().signal)).toEqual(snapshot); - expect(fetch).toHaveBeenCalledExactlyOnceWith("/console/installation/domain", expect.objectContaining({ method: "POST", credentials: "same-origin", body: JSON.stringify({ hostname: "core.example.com", confirm_public_url_change: "https://core.example.com" }) })); - }); - it("preserves confirmation errors and never retries a failed write", async () => { - const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: { code: "public_url_confirmation_required", message: "Confirm address" } }), { status: 409 })); - vi.stubGlobal("fetch", fetch); - await expect(applyDomain("core.example.com", false, new AbortController().signal)).rejects.toMatchObject({ status: 409, code: "public_url_confirmation_required" }); - expect(fetch).toHaveBeenCalledTimes(1); - }); - it("rejects a malformed or unsafe manager response", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ supported: true, state: "ready", public_url: null, target_url: "https://evil.example.com/path", message: null })))); - await expect(applyDomain("core.example.com", false, new AbortController().signal)).rejects.toBeInstanceOf(DomainRequestError); - }); - it("keeps polling setup while the gateway restarts and reports a disconnect only after the grace period", async () => { - vi.useFakeTimers(); - const status = (state: string) => new Response(JSON.stringify({ supported: true, state, public_url: null, target_url: "https://core.example.com", message: null })); - const fetch = vi.fn().mockResolvedValueOnce(status("checking")).mockRejectedValueOnce(new TypeError("Failed to fetch")) - .mockImplementation(() => Promise.resolve(new Response(null, { status: 502 }))); - vi.stubGlobal("fetch", fetch); - const client = new QueryClient(); - const observer = new QueryObserver(client, domainQuery); - const unsubscribe = observer.subscribe(() => undefined); - try { - await vi.advanceTimersByTimeAsync(2_000); - expect(observer.getCurrentResult().isError).toBe(true); - expect(domainReconnecting(observer.getCurrentResult())).toBe(true); - await vi.advanceTimersByTimeAsync(DOMAIN_RECONNECT_GRACE_MS - 4_000); - expect(domainReconnecting(observer.getCurrentResult())).toBe(true); - await vi.advanceTimersByTimeAsync(2_000); - expect(observer.getCurrentResult().isError).toBe(true); - expect(domainReconnecting(observer.getCurrentResult())).toBe(false); - fetch.mockImplementation(() => Promise.resolve(status("ready"))); - await vi.advanceTimersByTimeAsync(2_000); - expect(observer.getCurrentResult()).toMatchObject({ isError: false, data: { state: "ready" } }); - const calls = fetch.mock.calls.length; - await vi.advanceTimersByTimeAsync(10_000); - expect(fetch).toHaveBeenCalledTimes(calls); - expect(fetch.mock.calls.every(([, init]) => init.method === undefined)).toBe(true); - } finally { unsubscribe(); client.clear(); } - }); -}); diff --git a/apps/web/src/features/system/domain-api.ts b/apps/web/src/features/system/domain-api.ts deleted file mode 100644 index b4a81ee40..000000000 --- a/apps/web/src/features/system/domain-api.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { queryOptions } from "@tanstack/react-query"; - -export interface DomainStatus { - supported: boolean; - state: "unconfigured" | "checking" | "applying" | "ready" | "failed"; - public_url: string | null; - target_url: string | null; - message: string | null; -} - -export class DomainRequestError extends Error { - constructor(readonly status: number, readonly code: string, message: string) { super(message); } -} - -/** A hostname, never a URL, port, IP address or path. IDNs use their ASCII form. */ -export function domainHostname(input: string): string | null { - const text = input.trim(); - if (!text || /[\s/:?#@\\]/u.test(text)) return null; - try { - const host = new URL(`https://${text}`).hostname; - const labels = host.split("."); - return host.length <= 253 && labels.length > 1 && !/^\d+$/.test(labels.at(-1)!) && - labels.every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label)) ? host : null; - } catch { return null; } -} - -/** Only a canonical HTTPS domain can become the post-apply navigation link. */ -export function domainTarget(value: string | null): string | null { - if (!value?.startsWith("https://")) return null; - const host = domainHostname(value.slice(8)); - return host && value === `https://${host}` ? value : null; -} - -export function domainInProgress(status?: DomainStatus): boolean { - return status?.state === "checking" || status?.state === "applying"; -} - -function parseStatus(value: unknown): DomainStatus { - if (!value || typeof value !== "object") throw new DomainRequestError(502, "invalid_response", "Invalid domain settings response."); - const data = value as Record; - if (typeof data.supported !== "boolean" || !["unconfigured", "checking", "applying", "ready", "failed"].includes(String(data.state)) || - ![data.public_url, data.target_url, data.message].every((entry) => entry === null || typeof entry === "string") || - (data.target_url !== null && !domainTarget(data.target_url as string))) { - throw new DomainRequestError(502, "invalid_response", "Invalid domain settings response."); - } - return { supported: data.supported, state: data.state as DomainStatus["state"], public_url: data.public_url as string | null, - target_url: data.target_url as string | null, message: data.message as string | null }; -} - -async function domainRequest(init: RequestInit): Promise { - const response = await fetch("/console/installation/domain", { credentials: "same-origin", cache: "no-store", ...init }); - if (!response.ok) { - const body = await response.json().catch(() => null); - throw new DomainRequestError(response.status, typeof body?.error?.code === "string" ? body.error.code : "request_failed", - typeof body?.error?.message === "string" ? body.error.message : "Domain settings could not be read."); - } - return parseStatus(await response.json()); -} - -/** Setup restarts the gateway, which briefly drops the console's own connection; polls ride that out before reporting a disconnect. */ -export const DOMAIN_RECONNECT_GRACE_MS = 30_000; - -/** No response, or a proxy error while the gateway restarts. */ -function connectionLost(error: unknown): boolean { - return !(error instanceof DomainRequestError) || error.status === 502 || error.status === 503 || error.status === 504; -} - -/** A lost connection during setup, before the grace period since the last status has passed. */ -export function domainReconnecting(query: { data: DomainStatus | undefined; error: unknown; dataUpdatedAt: number; errorUpdatedAt: number }): boolean { - return query.error != null && domainInProgress(query.data) && connectionLost(query.error) && - query.errorUpdatedAt - query.dataUpdatedAt < DOMAIN_RECONNECT_GRACE_MS; -} - -export const domainQuery = queryOptions({ - queryKey: ["console-domain"], - queryFn: ({ signal }) => domainRequest({ signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]) }), - retry: false, - refetchInterval: (query) => domainInProgress(query.state.data) && (!query.state.error || connectionLost(query.state.error)) ? 2_000 : false, -}); - -/** One same-origin write per user action; never retries a possibly accepted apply. */ -export function applyDomain(hostname: string, confirmed: boolean, signal: AbortSignal): Promise { - return domainRequest({ method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ hostname, ...(confirmed ? { confirm_public_url_change: `https://${hostname}` } : {}) }), signal }); -} diff --git a/apps/web/src/features/system/domain.css b/apps/web/src/features/system/domain.css deleted file mode 100644 index 9718d93de..000000000 --- a/apps/web/src/features/system/domain.css +++ /dev/null @@ -1,7 +0,0 @@ -.domain-settings { display: grid; gap: 24px; width: min(100%, 640px); } -.domain-form { display: grid; gap: 20px; padding: 24px; border-radius: var(--radius-card); background: var(--surface); box-shadow: var(--shadow-card); } -.domain-security, .domain-actions { display: flex; align-items: center; gap: 10px; } -.domain-security { color: var(--fg); font-size: 14px; } -.domain-prerequisites, .domain-state, .domain-error { margin: 0; font-size: 14px; line-height: 1.6; } -.domain-prerequisites { color: var(--ink-2); } -.domain-error { color: var(--danger); } diff --git a/apps/web/src/i18n/locales/en/common.ts b/apps/web/src/i18n/locales/en/common.ts index 8b8be17fb..0095357c7 100644 --- a/apps/web/src/i18n/locales/en/common.ts +++ b/apps/web/src/i18n/locales/en/common.ts @@ -10,8 +10,8 @@ export const common = { }, installationNotice: { title: "Public address needs attention", - body: "Configure HTTPS before connecting applications and nodes from other machines.", - configure: "Configure domain and HTTPS", + body: "Set a public HTTPS address before connecting applications and nodes from other machines.", + configure: "Review the public address", addBlocked: "Add node is unavailable while the public address is local only.", }, actions: { diff --git a/apps/web/src/i18n/locales/en/core-errors.ts b/apps/web/src/i18n/locales/en/core-errors.ts index f35e539f2..ba11e4086 100644 --- a/apps/web/src/i18n/locales/en/core-errors.ts +++ b/apps/web/src/i18n/locales/en/core-errors.ts @@ -40,5 +40,5 @@ export const coreErrors = { "sandbox_credential_invalid": "The E2B API key was rejected. The saved configuration is unchanged.", "sandbox_configuration_invalid": "Select a ready immutable E2B template build with matching resources.", "sandbox_verification_unconfirmed": "E2B verification could not be confirmed. Refresh before submitting again.", - "sandbox_configuration_error": "E2B sandboxes need a public HTTPS address. Configure a domain and HTTPS in System." + "sandbox_configuration_error": "E2B sandboxes need a public HTTPS address. Set OAC_PUBLIC_URL to an HTTPS origin." } as const; diff --git a/apps/web/src/i18n/locales/en/keys.ts b/apps/web/src/i18n/locales/en/keys.ts index ca4ce7c82..9f02e263e 100644 --- a/apps/web/src/i18n/locales/en/keys.ts +++ b/apps/web/src/i18n/locales/en/keys.ts @@ -108,7 +108,7 @@ export const keys = { loading: "Reading the API address", failed: "The API address couldn't be read.", localOnly: "For access from other machines, configure a domain and HTTPS in System.", - noAddress: "Core has no public API address yet. Configure a domain and HTTPS in System.", + noAddress: "Core has no public API address yet. Set OAC_PUBLIC_URL to an HTTPS origin.", model: "Replace {{model}} with a model name your model provider serves, or remove the model field to use this deployment's default model configuration. Running an Agent needs a model provider: pass one in each request, save one on the Agent, or rely on the deployment default. Self-hosted Sessions never use the deployment default.", keyPlaceholder: "", projectKey: "Set OPENAI_API_KEY to an API key issued for this project. A key is shown only once, when it is issued; if it's lost, issue a new one.", diff --git a/apps/web/src/i18n/locales/en/system.ts b/apps/web/src/i18n/locales/en/system.ts index 66f18a9ca..01539ccb9 100644 --- a/apps/web/src/i18n/locales/en/system.ts +++ b/apps/web/src/i18n/locales/en/system.ts @@ -2,41 +2,6 @@ export const system = { help: "This installation's addresses, each harness's default model configuration and the settings every project shares. Use the sandbox backend page to change sandboxes, and config.json to change startup settings.", refresh: "Refresh system configuration", loading: "Loading…", - domain: { - "title": "Domain and HTTPS", - "configure": "Configure domain and HTTPS", - "back": "Back to System", - "help": "Set a domain for this installation. HTTPS certificates are issued and renewed automatically.", - "loadFailed": "Domain settings could not be loaded.", - "unsupported": "Domain setup is not available for this installation.", - "rejected": "The domain change was not accepted.", - "details": "Show details", - "hostname": "Domain", - "hostnameHelp": "Enter a domain without https://, a port or a path.", - "invalidHostname": "Enter a domain such as core.example.com.", - "automatic": "Automatic HTTPS", - "dnsHelp": "Point the domain’s A or AAAA record to this server. For certificate verification and HTTPS access, ports 80 and 443 must be reachable from the internet and not used by another program on the server.", - "prerequisites": "Point your domain to this server before applying.", - "submitting": "Applying…", - "retry": "Retry setup", - "apply": "Apply", - "refresh": "Refresh domain status", - "states": { - "unconfigured": "Not configured", - "checking": "Checking the domain…", - "applying": "Setting up HTTPS…", - "ready": "HTTPS is ready.", - "failed": "Setup failed." - }, - "failed": "Domain setup failed. Check the details before retrying.", - "disconnected": "The console disconnected during setup. Check the new address or refresh the status.", - "uncertain": "The request was not confirmed. Refresh the status before trying again.", - "confirmTitle": "Change public address?", - "confirm": "Change address", - "confirmBody": "Switch to {{url}}? Connected nodes, sandboxes and executors may need to reconnect using the new address. The console will restart and you will need to sign in again.", - "reconnect": "After setup, continue at the new address and sign in again.", - "open": "Open {{url}}" - }, installation: { title: "Installation", failed: "The installation could not be read.", @@ -53,8 +18,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/common.ts b/apps/web/src/i18n/locales/zh-CN/common.ts index 0df1a05da..1148cf184 100644 --- a/apps/web/src/i18n/locales/zh-CN/common.ts +++ b/apps/web/src/i18n/locales/zh-CN/common.ts @@ -10,8 +10,8 @@ export const common = { }, installationNotice: { title: "公开地址需要处理", - body: "连接外部应用和节点前,请先配置 HTTPS。", - configure: "配置域名与 HTTPS", + body: "连接外部应用和节点前,请先设置一个公网 HTTPS 地址。", + configure: "查看公开地址", addBlocked: "公开地址仅限本机访问,暂时无法添加节点。", }, actions: { diff --git a/apps/web/src/i18n/locales/zh-CN/core-errors.ts b/apps/web/src/i18n/locales/zh-CN/core-errors.ts index 3af8049d1..4334ca3c1 100644 --- a/apps/web/src/i18n/locales/zh-CN/core-errors.ts +++ b/apps/web/src/i18n/locales/zh-CN/core-errors.ts @@ -40,5 +40,5 @@ export const coreErrors = { "sandbox_credential_invalid": "E2B API 密钥被拒绝。已保存的配置未改变。", "sandbox_configuration_invalid": "请选择已就绪且资源匹配的不可变 E2B 模板构建。", "sandbox_verification_unconfirmed": "无法确认 E2B 验证结果。请刷新后再提交。", - "sandbox_configuration_error": "E2B 沙箱需要可从互联网访问的 HTTPS 地址,请在系统中配置域名与 HTTPS。" + "sandbox_configuration_error": "E2B 沙箱需要可从互联网访问的 HTTPS 地址,请把 OAC_PUBLIC_URL 设为一个 HTTPS 源地址。" } as const; diff --git a/apps/web/src/i18n/locales/zh-CN/keys.ts b/apps/web/src/i18n/locales/zh-CN/keys.ts index 00cc62a1f..40c50861e 100644 --- a/apps/web/src/i18n/locales/zh-CN/keys.ts +++ b/apps/web/src/i18n/locales/zh-CN/keys.ts @@ -109,8 +109,8 @@ export const keys: TranslationShape = { copyFailed: "请选中文本后手动复制。", loading: "正在读取 API 地址", failed: "无法读取 API 地址。", - localOnly: "从其他机器调用前,请先在系统中配置域名与 HTTPS。", - noAddress: "Core 尚未配置公开 API 地址,请在系统中配置域名与 HTTPS。", + localOnly: "从其他机器调用前,请先把 OAC_PUBLIC_URL 设为一个 HTTPS 源地址。", + noAddress: "Core 尚未配置公开 API 地址,请把 OAC_PUBLIC_URL 设为一个 HTTPS 源地址。", model: "把 {{model}} 换成模型服务提供的模型名;也可以删掉 model 字段,使用本部署的默认模型配置。运行 Agent 需要模型服务:在每个请求里传入、保存在 Agent 上,或使用部署默认值。自托管 Session 不使用部署默认值。", keyPlaceholder: "<项目 API key>", projectKey: "把 OPENAI_API_KEY 设为这个项目签发的 API key。key 只在签发时显示一次;丢失后请签发新 key。", diff --git a/apps/web/src/i18n/locales/zh-CN/system.ts b/apps/web/src/i18n/locales/zh-CN/system.ts index e3a71b5fd..072fd56c5 100644 --- a/apps/web/src/i18n/locales/zh-CN/system.ts +++ b/apps/web/src/i18n/locales/zh-CN/system.ts @@ -4,41 +4,6 @@ export const system: TranslationShape = { help: "这个安装的地址、每个执行框架的默认模型配置,以及所有项目共用的设置。沙箱在对应的后端页面修改,启动设置在 config.json 中修改。", refresh: "刷新系统配置", loading: "正在加载…", - domain: { - "title": "域名与 HTTPS", - "configure": "配置域名与 HTTPS", - "back": "返回系统", - "help": "为当前安装配置域名。HTTPS 证书会自动签发和续期。", - "loadFailed": "无法加载域名配置。", - "unsupported": "此安装暂不支持在控制台配置域名。", - "rejected": "域名变更未被接受。", - "details": "查看详情", - "hostname": "域名", - "hostnameHelp": "只填写域名,不包含 https://、端口或路径。", - "invalidHostname": "请填写有效域名,例如 core.example.com。", - "automatic": "自动配置 HTTPS", - "dnsHelp": "将域名的 A 或 AAAA 记录指向这台服务器。80 和 443 端口用于证书验证和 HTTPS 访问,需能从公网访问,且不能被服务器上的其他程序占用。", - "prerequisites": "应用前,请先将域名解析到这台服务器。", - "submitting": "正在应用…", - "retry": "重试配置", - "apply": "应用", - "refresh": "刷新域名状态", - "states": { - "unconfigured": "未配置", - "checking": "正在检查域名…", - "applying": "正在配置 HTTPS…", - "ready": "HTTPS 已就绪。", - "failed": "配置失败。" - }, - "failed": "域名配置失败,请查看详情后重试。", - "disconnected": "配置期间连接已断开,请打开新地址或刷新状态。", - "uncertain": "尚未确认请求结果,请刷新状态后再重试。", - "confirmTitle": "更换公开地址?", - "confirm": "更换地址", - "confirmBody": "更换为 {{url}}?已连接的节点、沙箱和 executor 可能需要使用新地址重新连接。控制台会重启,需要重新登录。", - "reconnect": "配置完成后,请前往新地址重新登录。", - "open": "打开 {{url}}" - }, installation: { title: "安装", failed: "无法读取安装信息。", @@ -55,8 +20,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 cad314467..0c4bd3eda 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/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 5388b2d50..b4b76fa5b 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -163,7 +163,7 @@ export const chinese = { "Reaches {{core}}, as do its sandboxes": "能访问 {{core}},它的沙箱也要能访问", "The command creates the oac-node service user and a system service. It installs no software; if something is missing it stops and says what to install.": "命令会创建 oac-node 服务用户和一个系统服务。它不安装任何软件;缺少什么时会停下并说明要装什么。", "oac-node joins the docker group, which is equivalent to root on this host.": "oac-node 会加入 docker 组,这在这台主机上等同于 root 权限。", - "Configure a domain and HTTPS in System before adding nodes.": "添加节点前,请先在系统中配置域名与 HTTPS。", + "Set a public HTTPS address before adding nodes.": "添加节点前,请先设置一个公网 HTTPS 地址。", "Clean up the host": "清理主机", "{{name}} is removed from Core. To remove its service and files from the host, run:": "{{name}} 已从 Core 移除。要删除它在主机上的服务和文件,请运行:", "{{name}} is removed from Core, but its service and files stay on the host.": "{{name}} 已从 Core 移除,但它的服务和文件仍留在主机上。", diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index 40e722ad2..fe39e5e66 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 f15a93129..ddf6a108d 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -437,10 +437,10 @@ 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. + description: The ID in OAC_INSTALLATION_ID_FILE; null when Core runs without the sandbox manager. type: string x-nullable: true local_only: @@ -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/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 72dfb1dc1..2af2c9244 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -224,6 +224,6 @@ Storage and credential failures stay errors: an empty or failed read never prove ## Canonical node specification -`sandbox/deployment_contract.go` owns the resource bounds, provider requirements, release patterns and canonical field order; `sandbox/deployment.go` applies them in Core. The installer consumes the generated declaration in `deploy/install/node_spec.py`, so there is no second set of limits or patterns. Regenerate it from the repository root with `go run ./services/core/cmd/specification-contract -write`; the sandbox Go tests, part of `make check`, reject a stale projection. +`sandbox/deployment_contract.go` owns the resource bounds, provider requirements, release patterns and canonical field order; `sandbox/deployment.go` applies them in Core. The installer consumes the generated declaration in `deploy/node/node_spec.py`, so there is no second set of limits or patterns. Regenerate it from the repository root with `go run ./services/core/cmd/specification-contract -write`; the sandbox Go tests, part of `make check`, reject a stale projection. The specification digest is the SHA-256 of compact UTF-8 JSON with `provider` first, then `resources`, then `runtime` when the provider requires it. Resource and Runtime fields follow the contract's declaration order; zero optional disk fields are omitted and required fields stay present. Release identities are lowercase ASCII, and the digest never depends on the incoming field order or whitespace. `services/core/internal/sandbox/testdata/deployment-contract.json` holds shared acceptance cases, exact canonical bytes and digests that both the Go and Python tests consume. diff --git a/contracts/agents-api/zh/admin-api.md b/contracts/agents-api/zh/admin-api.md index 071bb2c42..c86c1de67 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/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index 509fed4b0..dea0cf983 100644 --- a/contracts/agents-api/zh/sandbox-deployment.md +++ b/contracts/agents-api/zh/sandbox-deployment.md @@ -1,7 +1,7 @@ --- title: "沙箱部署" source: contracts/agents-api/sandbox-deployment.md -source_hash: e84ced73c64da30f33feba032d7e8e9bc8c33b3062604a47258102460615429b +source_hash: 06a69e3d0ba245ab27c0b5d7390364a35d10fb8478e2d0f6867749b29368f980 --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择 Sandbox Provider、每个沙箱的资源以及不可变的 Runtime 发行版。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源或 Runtime。该选择独立于 Harness;部署可以保持未配置状态,既无节点,也不接受托管准入。 @@ -226,6 +226,6 @@ POST 会在持久保存候选配置之前对其进行验证,并且不会创建 ## 规范的节点规格 {#canonical-node-specification} -`sandbox/deployment_contract.go`负责资源边界、提供商要求、发行版模式和规范字段顺序;`sandbox/deployment.go`在 Core 中应用这些规则。安装程序会使用 `deploy/install/node_spec.py` 中生成的声明,因此不存在第二套限制或模式。请在仓库根目录运行 `go run ./services/core/cmd/specification-contract -write` 重新生成;作为 `make check` 一部分的沙箱 Go 测试会拒绝过时的投影。 +`sandbox/deployment_contract.go`负责资源边界、提供商要求、发行版模式和规范字段顺序;`sandbox/deployment.go`在 Core 中应用这些规则。安装程序会使用 `deploy/node/node_spec.py` 中生成的声明,因此不存在第二套限制或模式。请在仓库根目录运行 `go run ./services/core/cmd/specification-contract -write` 重新生成;作为 `make check` 一部分的沙箱 Go 测试会拒绝过时的投影。 规范摘要是紧凑 UTF-8 JSON 的 SHA-256,其中 `provider` 位于首位,其次是 `resources`,然后在提供商需要时放置 `runtime`。资源和 Runtime 字段遵循契约的声明顺序;值为零的可选磁盘字段会被省略,必填字段则保持存在。发行版标识采用小写 ASCII,摘要绝不会受传入字段顺序或空白字符影响。`services/core/internal/sandbox/testdata/deployment-contract.json`保存共享验收用例、精确的规范字节和摘要,Go 与 Python 测试都会使用这些内容。 diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 000000000..7d254a21d --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,20 @@ +# Deployment + +| Path | Contents | +| --- | --- | +| `install.sh` | Host installer published with each release | +| `compose/` | Compose template, port overlays and their tests | +| `distribution/` | Image Dockerfiles | +| `node/` | [Node installer](node/README.md), packaged as `node-install.pyz` | + +## Installation + +`install.sh` downloads its release's `compose.yaml` and port files, checks them against `compose-sha256sums.txt`, writes `.env`, and starts Compose. Core applies database migrations when it starts. The host needs Linux amd64 and Docker Compose 2.26 or newer. [Configuration](../docs/configuration.md) owns the installation layout and settings. + +`oac` is a Go command (`services/core/cmd/oac`) in the Core image and the ingress image. The host copy implements `apply`, `core-key` and `rotate-core-key`; `core-key --show` runs `oac-web core-key` in the Web container. Start, stop, logs and removal are `docker compose`. `apply` runs `oac-core check-config` before recreating services. The ingress image runs data initialization as `oac init` and contains no Python. No service receives a Docker socket. + +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. + +## Native daemon installer + +`oac-daemon install` installs the daemon and selected Harnesses on a self-hosted machine. The [credential contract](../contracts/agents-api/environment-executor-credentials.md#installation-grant) covers the grant it claims. The release catalog is in the Core image at `/opt/oac/native-installers`; Core serves it from there. Node installation is separate and stays in `node-install.pyz`. diff --git a/deploy/compose/compose.yaml b/deploy/compose/compose.yaml index 9f5b92a3c..dbe5cdd0a 100644 --- a/deploy/compose/compose.yaml +++ b/deploy/compose/compose.yaml @@ -1,49 +1,29 @@ -# 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 -x-core: &core - image: ghcr.io/minimax-ai/openagentcore/core:v0.0.3@sha256:6934a26d5cb7c878128ea8187ced446336345de7750c415be510740806663575 - platform: linux/amd64 - user: "65532:65532" - read_only: true - init: true - tmpfs: [/tmp:mode=1777] - security_opt: [no-new-privileges:true] - environment: - OAC_PUBLIC_URL: &public-url ${OAC_PUBLIC_URL:-http://localhost:8080} - OAC_DATABASE_URL: postgres://agents_api@database:5432/agents_api?sslmode=disable - OAC_DATABASE_PASSWORD_FILE: /run/database/password - OAC_CREDENTIAL_KEY_FILE: /run/oac/credential.key - OAC_CORE_KEY_DIGESTS_FILE: /run/oac/core-key-digests.json - OAC_PROVIDER_ROOT: /opt/oac - OAC_PROVIDER_STATE_ROOT: /state - 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 - +# Release template. scripts/render-compose.py fills the __OAC_*__ tokens with this +# release's source revision and node-metadata checksum. Images default to the +# floating latest tags; set OAC_IMAGE_CORE, OAC_IMAGE_WEB or OAC_IMAGE_INGRESS +# to select another reference. 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. Other process +# settings pass through unchanged; Core owns their defaults. +x-ingress-image: &ingress-image ${OAC_IMAGE_INGRESS:-ghcr.io/minimax-ai/openagentcore/ingress:latest} services: init: image: *ingress-image platform: linux/amd64 restart: "no" security_opt: [no-new-privileges:true] - command: [python3, /init.py] - configs: - - source: init-script - target: /init.py + command: [/usr/local/bin/oac, init] + environment: + OAC_REVISION: __OAC_REVISION__ + OAC_RELEASE_BASE: __OAC_RELEASE_BASE__ + OAC_ARCHIVE_CHECKSUM: __OAC_ARCHIVE_CHECKSUM__ 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 + image: postgres:16-alpine platform: linux/amd64 restart: unless-stopped depends_on: @@ -53,33 +33,63 @@ 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 timeout: 5s retries: 30 - migrate: - <<: *core - restart: "no" - command: [/usr/local/bin/oac-core-migrate] - depends_on: - database: {condition: service_healthy} - core: - <<: *core + image: ${OAC_IMAGE_CORE:-ghcr.io/minimax-ai/openagentcore/core:latest} + platform: linux/amd64 + user: "65532:65532" restart: unless-stopped - command: - - /bin/sh - - -ec - - export OAC_INSTALLATION_ID="$$(cat /run/oac/installation.id)"; exec /usr/local/bin/oac-core + read_only: true + init: true + tmpfs: [/tmp:mode=1777] + security_opt: [no-new-privileges:true] depends_on: - migrate: {condition: service_completed_successfully} + database: {condition: service_healthy} + environment: + OAC_PUBLIC_URL: &public-url ${OAC_PUBLIC_URL:-http://localhost:8080} + OAC_INSTALLATION_ID_FILE: /run/oac/installation.id + OAC_DATABASE_URL: postgres://agents_api@database:5432/agents_api?sslmode=disable + OAC_DATABASE_PASSWORD_FILE: /run/database/password + OAC_CREDENTIAL_KEY_FILE: /run/oac/credential.key + OAC_CORE_KEY_DIGESTS_FILE: /run/oac/core-key-digests.json + OAC_PROVIDER_STATE_ROOT: /state + OAC_NATIVE_INSTALLER_DIR: /opt/oac/native-installers + OAC_EXECUTION_CONCURRENCY: ${OAC_EXECUTION_CONCURRENCY:-} + OAC_DEFAULT_HARNESS: ${OAC_DEFAULT_HARNESS:-} + OAC_HARNESSES: ${OAC_HARNESSES:-} + OAC_WRITE_AUDIT_RETENTION: ${OAC_WRITE_AUDIT_RETENTION:-} + OAC_OAUTH_TRUSTED_ORIGINS: ${OAC_OAUTH_TRUSTED_ORIGINS:-} + OAC_LOG_LEVEL: ${OAC_LOG_LEVEL:-} + OAC_LOG_FORMAT: ${OAC_LOG_FORMAT:-} + OAC_LOG_ADD_SOURCE: ${OAC_LOG_ADD_SOURCE:-} + OAC_HISTORY_SETTINGS_FILE: ${OAC_HISTORY_SETTINGS_FILE:-} + volumes: + - 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 web: - image: ghcr.io/minimax-ai/openagentcore/web:v0.0.3@sha256:d1eb4cc8870aebd080773fd16db92a5a1db205e0aab10066d4db16f1284a88f5 + image: ${OAC_IMAGE_WEB:-ghcr.io/minimax-ai/openagentcore/web:latest} platform: linux/amd64 user: "65532:65532" restart: unless-stopped @@ -92,185 +102,20 @@ services: OAC_WEB_UPSTREAM: http://core:8091 OAC_WEB_CORE_KEY_FILE: /run/oac/core.key OAC_WEB_NODE_PAYLOAD_DIR: /node-payload + OAC_LOG_LEVEL: ${OAC_LOG_LEVEL:-} + OAC_LOG_FORMAT: ${OAC_LOG_FORMAT:-} + OAC_LOG_ADD_SOURCE: ${OAC_LOG_ADD_SOURCE:-} volumes: - - web-secret:/run/oac:ro - - node-payload:/node-payload:ro - - gateway: - image: *ingress-image - platform: linux/amd64 - user: "65532:65532" - restart: unless-stopped - security_opt: [no-new-privileges:true] - tmpfs: [/tmp:mode=1777] - environment: - XDG_DATA_HOME: /tmp/data - XDG_CONFIG_HOME: /tmp/config - command: [caddy, run, --config, /etc/caddy/Caddyfile, --adapter, caddyfile] - depends_on: [core, web] - expose: ["8080"] - configs: - - source: gateway-config - target: /etc/caddy/Caddyfile + - 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 healthcheck: - test: - - CMD - - python3 - - -c - - | - import urllib.request - client = urllib.request.build_opener(urllib.request.ProxyHandler({})) - for host in ('core:8091', 'web:8080', '127.0.0.1:8080'): - with client.open('http://' + host + '/healthz', timeout=3) as response: - assert response.status == 200 + test: [CMD, /usr/local/bin/oac-web, healthcheck] interval: 5s timeout: 10s retries: 30 - - # Explicit operator action; the key is printed only to the attached terminal. - credentials: - image: *ingress-image - platform: linux/amd64 - user: "65532:65532" - profiles: [tools] - read_only: true - 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: - -configs: - gateway-config: - content: | - { - admin off - auto_https off - persist_config off - } - http://:8080 { - @core path /v1 /v1/* /api/v1/* - handle @core { - reverse_proxy core:8091 { - flush_interval -1 - } - } - handle { - reverse_proxy web:8080 { - flush_interval -1 - } - } - } - - init-script: - content: | - import base64 - import fcntl - import hashlib - import json - import os - from pathlib import Path - import secrets - import tarfile - import urllib.request - import uuid - - REVISION = 'cc7e1aad3bde47598d161d6372e2e211bb61d9cd' - BASE = 'https://github.com/MiniMax-AI/OpenAgentCore/releases/download/v0.0.3/' - ARCHIVE = 'oac-' + REVISION + '-linux-amd64' - CHECKSUM = '579d2d43accfc45a0a8567db5bc33b407c48b05b0d731bea3fed73e6ade558ae' - MEMBERS = ('manifest.json', 'SHA256SUMS', 'node-install.pyz', 'runtime/seccomp.json') - - def digest(data): - return hashlib.sha256(data).hexdigest() - - def download(): - print('Downloading and verifying the matched node installation metadata', flush=True) - checksum = hashlib.sha256() - with urllib.request.urlopen(BASE + ARCHIVE + '.tar.gz', timeout=60) as response: - class Reader: - def read(self, count=-1): - data = response.read(count) - checksum.update(data) - return data - reader, files = Reader(), {} - with tarfile.open(fileobj=reader, mode='r|gz') as archive: - for member in archive: - name = member.name.removeprefix(ARCHIVE + '/') - if member.name == ARCHIVE + '/' + name and name in MEMBERS: - if name in files or not member.isfile() or member.size > 1024 * 1024: - raise RuntimeError('Invalid release metadata member') - files[name] = archive.extractfile(member).read() - while reader.read(1024 * 1024): - pass - if checksum.hexdigest() != CHECKSUM or set(files) != set(MEMBERS): - raise RuntimeError('Release metadata checksum mismatch') - manifest = json.loads(files['manifest.json']) - if manifest['source_commit'] != REVISION or manifest['platform'] != 'linux/amd64': - raise RuntimeError('Release identity mismatch') - return files - - def write(path, data): - 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.replace(temporary, path) - - def initialize(root, fetch=download): - for name in ('core', 'web', 'database', 'state', 'payload'): - directory = root / name - directory.mkdir(exist_ok=True) - directory.chmod(0o700) - os.chown(directory, 65532, 65532) - with (root / 'core/.init.lock').open('w') as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - marker = root / 'core/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') - for name, checksum in receipt['files'].items(): - if digest((root / name).read_bytes()) != checksum: - raise RuntimeError('Installation files changed; restore the matching volumes') - 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') - files = fetch() - prefix = '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('*'): - if path.is_dir(): - path.chmod(0o755) - 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()), - } - 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', - *(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()) - print('Installation initialized; use the credentials service to retrieve the sign-in key', flush=True) - - if __name__ == '__main__': - os.umask(0o077) - initialize(Path('/data')) diff --git a/deploy/compose/local.yaml b/deploy/compose/local.yaml deleted file mode 100644 index 339b92b7e..000000000 --- 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 000000000..b64424fe6 --- /dev/null +++ b/deploy/compose/ports.yaml @@ -0,0 +1,9 @@ +# Host installation publishes Web and Core's loopback admin API for scripts on +# the host. Hosting platforms omit this file and route to web:8080 themselves. +services: + web: + ports: + - "${OAC_HOST:-127.0.0.1}:${OAC_WEB_PORT:-8080}:8080" + core: + ports: + - "127.0.0.1:8091:8091" diff --git a/deploy/compose/smoke-pins.json b/deploy/compose/smoke-pins.json new file mode 100644 index 000000000..f448930cd --- /dev/null +++ b/deploy/compose/smoke-pins.json @@ -0,0 +1,5 @@ +{ + "revision": "cc7e1aad3bde47598d161d6372e2e211bb61d9cd", + "release_base": "https://github.com/MiniMax-AI/OpenAgentCore/releases/download/v0.0.3/", + "archive_checksum": "579d2d43accfc45a0a8567db5bc33b407c48b05b0d731bea3fed73e6ade558ae" +} diff --git a/deploy/compose/test_compose.py b/deploy/compose/test_compose.py new file mode 100644 index 000000000..acefbaaaa --- /dev/null +++ b/deploy/compose/test_compose.py @@ -0,0 +1,107 @@ +"""Qualify the rendered Compose files without building images.""" + +import copy +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +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({ + '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): + @classmethod + def render(cls, public_url=None): + env = dict(os.environ) + env.pop('OAC_PUBLIC_URL', None) + for name in ('OAC_IMAGE_CORE', 'OAC_IMAGE_WEB', 'OAC_IMAGE_INGRESS'): + env.pop(name, 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(cls.compose_file), + '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 test_compose_uses_private_services_and_ordered_initialization(self): + services = self.compose['services'] + self.assertEqual(services['database']['depends_on']['init']['condition'], 'service_completed_successfully') + self.assertIn('pg_isready -h 127.0.0.1', services['database']['healthcheck']['test'][1]) + self.assertEqual(services['core']['depends_on']['database']['condition'], 'service_healthy') + self.assertEqual(sorted(services), ['core', 'database', 'init', 'web']) + for service in services.values(): + self.assertNotIn('build', service) + self.assertNotIn('ports', service) + self.assertTrue(service['image'].endswith(':latest') or service['image'] == 'postgres:16-alpine') + for volume in service.get('volumes', []): + 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.assertIsNone(services['core']['command']) + self.assertNotIn('OAC_WEB_INSTALLATION_SOCKET', services['web']['environment']) + self.assertEqual(services['init']['command'], ['/usr/local/bin/oac', 'init']) + self.assertEqual(services['web']['healthcheck']['test'], ['CMD', '/usr/local/bin/oac-web', 'healthcheck']) + self.assertNotIn('python3', json.dumps(self.compose)) + self.assertEqual(services['init']['environment']['OAC_REVISION'], 'd' * 40) + for name in ('OAC_EXECUTION_CONCURRENCY', 'OAC_DEFAULT_HARNESS', 'OAC_HARNESSES', 'OAC_WRITE_AUDIT_RETENTION', 'OAC_LOG_LEVEL'): + self.assertEqual(services['core']['environment'][name], '', name) + + def test_public_url_can_be_configured_after_initial_startup(self): + for value in (None, '', 'https://oac.example.test', 'http://localhost:9080'): + with self.subTest(public_url=value): + configured = self.render(value) + expected = value or 'http://localhost:8080' + for name, setting in (('core', 'OAC_PUBLIC_URL'), ('web', 'OAC_WEB_ORIGIN')): + self.assertEqual(configured['services'][name]['environment'][setting], expected) + 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_host_ports_publish_web_and_loopback_core(self): + env = dict(os.environ, OAC_DATA_DIR='/tmp/oac-compose-fixture', OAC_HOST='0.0.0.0') + hosted = json.loads(subprocess.check_output( + ['docker', 'compose', '--env-file', os.devnull, '-f', str(self.compose_file), + '-f', str(ROOT / 'deploy/compose/ports.yaml'), 'config', '--format', 'json'], env=env)) + published = {name: [(port.get('host_ip'), port['published']) for port in service.get('ports', [])] + for name, service in hosted['services'].items() if service.get('ports')} + self.assertEqual(published, {'web': [('0.0.0.0', '8080')], 'core': [('127.0.0.1', '8091')]}) + + def test_platform_network_injection_keeps_the_file_valid(self): + # Dokploy isolated deployments attach a project network to every service. + transformed = copy.deepcopy(self.compose) + transformed['networks']['platform'] = {} + for service in transformed['services'].values(): + service.setdefault('networks', {})['platform'] = None + subprocess.run( + ['docker', 'compose', '-f', '-', 'config', '--quiet'], + input=json.dumps(transformed), text=True, check=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/deploy/distribution/Dockerfile b/deploy/distribution/Dockerfile index 146473acd..a20af95ce 100644 --- a/deploy/distribution/Dockerfile +++ b/deploy/distribution/Dockerfile @@ -5,13 +5,13 @@ FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2 RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libgcc-s1 \ && rm -rf /var/lib/apt/lists/* -COPY --chmod=0555 bin/oac-core bin/oac-core-migrate bin/oac-core-device bin/oac-core-environment-key /usr/local/bin/ +COPY --chmod=0555 bin/oac-core bin/oac-core-device bin/oac-core-environment-key bin/oac /usr/local/bin/ COPY e2b/ /opt/oac/e2b/ # Only the small version/checksum catalog, never native execution archives. COPY native-installers/ /opt/oac/native-installers/ -ENV OAC_ADDR=:8091 +ENV OAC_ADDR=:8091 OAC_PROVIDER_ROOT=/opt/oac EXPOSE 8091 USER 65532:65532 CMD ["/usr/local/bin/oac-core"] diff --git a/deploy/distribution/Ingress.Dockerfile b/deploy/distribution/Ingress.Dockerfile index 51c324219..bedebf583 100644 --- a/deploy/distribution/Ingress.Dockerfile +++ b/deploy/distribution/Ingress.Dockerfile @@ -1,6 +1,8 @@ -# The installer owns this image. Core and Web never receive Docker access. -FROM caddy:2.10.2-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d AS caddy -FROM docker:29.0.4-cli@sha256:858bb1e05af16840f5a55143c3e5e14073891fbc92f2c1f6f38dd9c5f2cca03c -RUN apk add --no-cache python3 ca-certificates -COPY --from=caddy /usr/bin/caddy /usr/local/bin/caddy +# One-time data initialization. oac init runs as root so it can chown data +# directories, then exits. It downloads the node payload over HTTPS, so the +# image carries CA certificates. scripts/build-core-distribution.sh builds this +# from a context that also contains the oac binary. +FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 +RUN apk add --no-cache ca-certificates +COPY --chmod=0555 oac /usr/local/bin/oac ENTRYPOINT [] diff --git a/deploy/install-release.sh b/deploy/install-release.sh deleted file mode 100755 index ebcc28097..000000000 --- a/deploy/install-release.sh +++ /dev/null @@ -1,367 +0,0 @@ -#!/usr/bin/env bash -# Download one matched release and delegate installation to its bundled installer. -set -euo pipefail -command -v python3 >/dev/null || { echo 'Python 3.9+ is required.' >&2; exit 1; } -# Keep the caller's stdin available to the bundled installer. -exec python3 /dev/fd/3 "$@" 3<<'PY' -import argparse -import contextlib -import errno -import fcntl -import hashlib -import http.client -import json -import os -import pathlib -import platform -import re -import shutil -import signal -import stat -import subprocess -import sys -import tarfile -import tempfile -import time -import urllib.error -import urllib.parse -import urllib.request - -REPOSITORY = "MiniMax-AI/OpenAgentCore" -API = "https://api.github.com/repos/" + REPOSITORY -ARCHIVE = re.compile(r"oac-([0-9a-f]{40})-linux-amd64\.tar\.gz") - - -class ReleaseError(Exception): - pass - - -class TransferError(ReleaseError): - pass - - -class Progress: - """Byte progress for the standalone downloader, without terminal escapes in logs.""" - def __init__(self, label, total=None): - self.label, self.total = label, total - self.started = time.monotonic() - self.updated = 0 - self.visible = sys.stderr.isatty() and os.environ.get("TERM") != "dumb" - - def update(self, size): - now = time.monotonic() - if not self.visible or now - self.updated < 0.2 and size != self.total: - return - self.updated = now - bar = "" - if self.total: - percent = min(100, size * 100 // self.total) - filled = percent // 5 - bar = "[" + "=" * filled + " " * (20 - filled) + f"] {percent:3d}% " - print(f"\r{self.label}: {bar}{size / 1048576:.1f} MiB ({now - self.started:.0f}s)", - end="\033[K", file=sys.stderr, flush=True) - - def __enter__(self): - return self - - def __exit__(self, *exc): - if self.visible: - with contextlib.suppress(OSError): - print(file=sys.stderr, flush=True) - - -def retry(operation): - for attempt in range(3): - try: - return operation() - except (TransferError, TimeoutError, ConnectionError, http.client.HTTPException): - if attempt == 2: - raise ReleaseError("Download interrupted after 3 attempts. Check the network and rerun the command.") from None - print("==> Download interrupted; retrying...", flush=True) - time.sleep(attempt + 1) - - -class DownloadRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, request, fp, code, msg, headers, newurl): - if urllib.parse.urlsplit(newurl).scheme != "https": - raise ReleaseError("Release downloads require HTTPS") - redirected = super().redirect_request(request, fp, code, msg, headers, newurl) - return redirected - - -def open_url(url, binary=False, offset=0): - headers = {"Accept": "application/octet-stream" if binary else "application/vnd.github+json", - "User-Agent": "OpenAgentCore-installer", "X-GitHub-Api-Version": "2022-11-28"} - if offset: - headers["Range"] = f"bytes={offset}-" - request = urllib.request.Request(url, headers=headers) - try: - return urllib.request.build_opener(DownloadRedirect()).open(request, timeout=60) - except urllib.error.HTTPError as error: - if error.code in (408, 429, 500, 502, 503, 504): - error.close() - raise TransferError("Temporary GitHub download failure") from None - error.close() - if error.code in (401, 403, 404): - raise ReleaseError("Release unavailable. Check the version and GitHub access limits.") from None - raise ReleaseError("GitHub download failed (HTTP " + str(error.code) + "). Retry later.") from None - except urllib.error.URLError: - raise TransferError("Could not reach GitHub") from None - - -def select_release(version): - endpoint = "/releases/latest" if version == "latest" else "/releases/tags/" + urllib.parse.quote(version, safe="") - def metadata(): - with open_url(API + endpoint) as response: - raw = response.read(4 * 1024 * 1024 + 1) - length = getattr(response, "headers", {}).get("Content-Length") - if len(raw) > 4 * 1024 * 1024: - raise ReleaseError("Release metadata is too large") - if length is not None and len(raw) < int(length): - raise TransferError("Incomplete release metadata") - return json.loads(raw) - release = retry(metadata) - if (not isinstance(release, dict) or not isinstance(release.get("tag_name"), str) - or not isinstance(release.get("assets"), list) - or any(not isinstance(asset, dict) or not isinstance(asset.get("name"), str) - for asset in release["assets"])): - raise ReleaseError("Invalid GitHub release metadata") - if release.get("draft") or (version == "latest" and release.get("prerelease")): - raise ReleaseError("Select a published release; latest excludes prereleases") - assets = release.get("assets", []) - bundles = [asset for asset in assets if ARCHIVE.fullmatch(asset["name"])] - if len(bundles) != 1: - raise ReleaseError("This release must contain exactly one Linux amd64 control-plane bundle") - bundle = bundles[0] - sums = [asset for asset in assets if asset["name"] == bundle["name"] + ".sha256"] - if len(sums) != 1: - raise ReleaseError("This release is missing its unique bundle checksum") - for asset in (bundle, sums[0]): - if type(asset.get("id")) is not int or asset["id"] <= 0: - raise ReleaseError("Invalid release asset identity") - if type(asset.get("size")) is not int or asset["size"] <= 0: - raise ReleaseError("Invalid release asset size") - return release["tag_name"], bundle, sums[0] - - -def download(asset, destination, limit=None): - # Resolve latest once, then download only those immutable asset IDs. - expected = asset["size"] - if limit is not None and expected > limit: - raise ReleaseError("Release checksum file is too large") - require_space(destination.parent, expected) - - def transfer(): - offset = destination.stat().st_size if destination.exists() else 0 - if offset == expected: - return - with open_url(API + "/releases/assets/" + str(asset["id"]), binary=True, offset=offset) as response: - status = getattr(response, "status", 200) - if status == 206: - match = re.fullmatch(r"bytes (\d+)-(\d+)/(\d+)", response.headers.get("Content-Range", "")) - if not match or tuple(map(int, match.groups())) != (offset, expected - 1, expected): - raise ReleaseError("Invalid release download range") - elif status == 200: - offset = 0 # Servers may ignore Range; replace the partial file. - else: - raise ReleaseError("Unexpected release download response") - with destination.open("ab" if offset else "wb") as output: - size = offset - for chunk in iter(lambda: response.read1(1024 * 1024), b""): - size += len(chunk) - if size > expected: - raise ReleaseError("Release download exceeds its declared size") - output.write(chunk) - progress.update(size) - if size != expected: - raise TransferError("Incomplete release download") - with Progress("Downloading checksum" if limit else "Downloading archive", expected) as progress: - retry(transfer) - - -def require_space(directory, size): - if shutil.disk_usage(directory).free < size: - raise ReleaseError(f"Not enough disk space in {directory}; need {size} bytes available. Free space and rerun.") - - -@contextlib.contextmanager -def ignore_interrupts(): - handlers = {number: signal.signal(number, signal.SIG_IGN) - for number in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)} - try: - yield - finally: - for number, handler in handlers.items(): - signal.signal(number, handler) - - -@contextlib.contextmanager -def staging(cache): - # A stable lock makes one reserved staging directory safe to discard after a crash. - descriptor = os.open(cache / ".download.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) - with os.fdopen(descriptor, "rb") as lock: - info = os.fstat(lock.fileno()) - if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1: - raise ReleaseError("Release download lock must be an owned regular file") - try: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - raise ReleaseError("Another release download is running; wait for it to finish and retry") from None - temporary = cache / ".download" - if temporary.exists() or temporary.is_symlink(): - info = temporary.lstat() - if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077: - raise ReleaseError("Release staging directory must be private and owned by you") - with ignore_interrupts(): - shutil.rmtree(temporary) - try: - temporary.mkdir(mode=0o700) - yield temporary, lock.fileno() - finally: - with ignore_interrupts(): - if temporary.exists(): - shutil.rmtree(temporary) - - -def extract(archive, destination, stem): - seen = set() - unpacked = 0 - with tarfile.open(archive, "r:gz") as source, Progress("Extracting") as progress: - for member in source: - path = pathlib.PurePosixPath(member.name) - if (not member.isfile() or path.is_absolute() or ".." in path.parts - or "\\" in member.name or len(path.parts) < 2 or path.parts[0] != stem - or path in seen): - raise ReleaseError("Release archive contains an unsafe or duplicate path") - seen.add(path) - require_space(destination, member.size) - target = destination.joinpath(*path.parts) - target.parent.mkdir(parents=True, exist_ok=True) - with source.extractfile(member) as data, target.open("xb") as output: - for chunk in iter(lambda: data.read(1024 * 1024), b""): - output.write(chunk) - unpacked += len(chunk) - progress.update(unpacked) - target.chmod(member.mode & 0o777) - root = destination / stem - if not (root / "install.sh").is_file() or not (root / "manifest.json").is_file(): - raise ReleaseError("Release archive is missing its installer or manifest") - return root - - -def run_installer(root, arguments, lock): - """Wait for the installer to finish cleanup before removing its source bundle.""" - child = None - interrupted = None - - def forward(signum, frame): - nonlocal interrupted - if interrupted is not None: - return # Let the installer finish cleanup despite repeated interrupts. - interrupted = signum - if child is not None: - with contextlib.suppress(ProcessLookupError): - os.killpg(child.pid, signum) - - handlers = {number: signal.signal(number, forward) - for number in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)} - try: - # The child retains the lock if this downloader is killed without cleanup. - child = subprocess.Popen(["bash", str(root / "install.sh"), *arguments], - start_new_session=True, pass_fds=(lock,)) - if interrupted is not None: - with contextlib.suppress(ProcessLookupError): - os.killpg(child.pid, interrupted) - code = child.wait() - if interrupted is not None: - raise KeyboardInterrupt - if code: - raise ReleaseError("Bundled installation failed; fix the reported cause and rerun the command") - finally: - for number, handler in handlers.items(): - signal.signal(number, handler) - - -def install(version, arguments): - if sys.version_info < (3, 9): - raise ReleaseError("Python 3.9+ is required") - if platform.system() != "Linux" or platform.machine() not in ("x86_64", "amd64"): - raise ReleaseError("Core installation currently requires Linux amd64") - home = pathlib.Path.home() / ".oac" - home.mkdir(mode=0o700, exist_ok=True) - cache = home / "releases" - cache.mkdir(mode=0o700, exist_ok=True) - info = cache.lstat() - if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077: - raise ReleaseError("Release directory must be private and owned by your current account") - extracted = None - try: - with staging(cache) as (temporary, lock): - print("==> Finding the " + ("latest stable release" if version == "latest" else "requested release") + "...", flush=True) - tag, bundle, sums = select_release(version) - print("Installing OpenAgentCore " + tag, flush=True) - archive = temporary / bundle["name"] - checksum = temporary / sums["name"] - download(sums, checksum, limit=1024) - expected = checksum.read_text().strip() - match = re.fullmatch(r"([0-9a-f]{64}) " + re.escape(bundle["name"]), expected) - if not match: - raise ReleaseError("Invalid release checksum file") - print("==> Downloading the release archive (this may take a few minutes)...", flush=True) - download(bundle, archive) - print("==> Verifying the archive checksum...", flush=True) - digest = hashlib.sha256() - with archive.open("rb") as source, Progress("Verifying", bundle["size"]) as progress: - checked = 0 - for chunk in iter(lambda: source.read(1024 * 1024), b""): - digest.update(chunk) - checked += len(chunk) - progress.update(checked) - if digest.hexdigest() != match[1]: - raise ReleaseError("Release checksum mismatch; installation was not started") - stem = bundle["name"].removesuffix(".tar.gz") - print("==> Extracting the verified archive...", flush=True) - root = extract(archive, temporary, stem) - manifest = json.loads((root / "manifest.json").read_text()) - if not isinstance(manifest, dict) or manifest.get("source_commit") != ARCHIVE.fullmatch(bundle["name"])[1]: - raise ReleaseError("Release source does not match its bundle") - archive.unlink() - checksum.unlink() - print("==> Starting the bundled installer...", flush=True) - run_installer(root, arguments, lock) - # Only a successful install retains a verified bundle for same-version repair. - extracted = pathlib.Path(tempfile.mkdtemp(prefix="release-", dir=cache)) - root = root.rename(extracted / stem) - print("Verified bundle: " + str(root), flush=True) - except BaseException: - # A second signal or a closed terminal must not interrupt temporary-file cleanup. - with ignore_interrupts(): - if extracted is not None: - shutil.rmtree(extracted) - raise - - -def main(argv): - parser = argparse.ArgumentParser( - prog="install.sh", description="Install the latest OpenAgentCore release. Other arguments go to its installer.", - allow_abbrev=False) - parser.add_argument("--version", default="latest", help="Release tag (default: latest stable release)") - options, arguments = parser.parse_known_args(argv) - install(options.version, arguments) - - -if __name__ == "__main__": - def interrupted(signum, frame): - raise KeyboardInterrupt - for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): - signal.signal(signum, interrupted) - try: - main(sys.argv[1:]) - except (ReleaseError, OSError, ValueError, tarfile.TarError, KeyboardInterrupt) as error: - message = "interrupted; rerun the same command" if isinstance(error, KeyboardInterrupt) else str(error) - if isinstance(error, OSError) and error.errno in (errno.ENOSPC, errno.EDQUOT): - message = "Disk space or quota exhausted. Free space in ~/.oac and rerun the command." - with contextlib.suppress(OSError): - print("Installation failed: " + message, file=sys.stderr) - sys.exit(130 if isinstance(error, KeyboardInterrupt) else 1) -PY diff --git a/deploy/install.dev.sh b/deploy/install.dev.sh new file mode 100755 index 000000000..d52433a38 --- /dev/null +++ b/deploy/install.dev.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Start this checkout. Core, Web and the init image are built here. Node +# metadata still comes from the release named in deploy/compose/smoke-pins.json. +# The published installer is install.sh. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +install_dir="${OAC_INSTALL_DIR_DEFAULT:-$HOME/.oac/local}" +host_address="127.0.0.1" +web_port="8080" + +if [[ -x "$HOME/.oac/build/env-docker/docker" ]]; then + PATH="$HOME/.oac/build/env-docker:$PATH" +fi + +usage() { + cat <<'EOF' +Usage: install.dev.sh [--install-dir DIR] [--host ADDRESS] [--web-port PORT] + +Builds Core, Web and the init image from this checkout and starts them. +Open http://localhost: and sign in with the printed Core key. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --install-dir) install_dir="${2:?}"; shift 2 ;; + --host) host_address="${2:?}"; shift 2 ;; + --web-port) web_port="${2:?}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage >&2; exit 1 ;; + esac +done + +if [[ "$(uname -s)" != Linux || "$(uname -m)" != x86_64 ]]; then + echo "Core installs on Linux amd64." >&2 + exit 1 +fi +command -v docker >/dev/null || { echo "Docker Engine with Compose is required." >&2; exit 1; } +command -v go >/dev/null || { echo "Go is required to build this checkout." >&2; exit 1; } +if [[ ! -f "$repo_root/apps/web/dist/index.html" ]]; then + echo "Build the console first: pnpm --dir apps/web build" >&2 + exit 1 +fi +if [[ "$install_dir" != /* ]]; then + echo "--install-dir must be absolute." >&2 + exit 1 +fi + +mkdir -p "$install_dir/data" +chmod 700 "$install_dir/data" +build="$install_dir/image-build" +rm -rf "$build" +mkdir -p "$build" + +revision="$(git -C "$repo_root" rev-parse HEAD)" +protocol="$(sed -n 's/^const Version = "\([^"]*\)".*/\1/p' "$repo_root/internal/agentdaemon/proto/version.go")" +export CGO_ENABLED=0 GOOS=linux GOARCH=amd64 + +go_build() { + mkdir -p "$(dirname "$2")" + go build -trimpath -ldflags "-X main.buildRevision=$revision" -o "$2" "./$1" +} + +( + cd "$repo_root" + go_build services/core/cmd/server "$build/core/bin/oac-core" + go_build services/core/cmd/device "$build/core/bin/oac-core-device" + go_build services/core/cmd/environment-key "$build/core/bin/oac-core-environment-key" + go_build services/core/cmd/oac "$build/core/bin/oac" + go_build services/web "$build/web/oac-web" + go_build services/core/cmd/oac "$build/ingress/oac" +) +mkdir -p "$build/core/e2b" "$build/core/native-installers" +python3 - "$build/core/native-installers/catalog.json" "$revision" "$protocol" <<'PY' +import json, sys +path, revision, protocol = sys.argv[1:] +json.dump({"version": revision, "protocol_version": protocol, "artifacts": {"linux-amd64": { + "sha256": "0" * 64, + "url": f"https://example.invalid/oac-native-{revision}-linux-amd64.tar.gz"}}}, open(path, "w")) +PY +cp "$repo_root/deploy/distribution/Dockerfile" "$build/core/Dockerfile" +cp "$repo_root/services/web/Dockerfile" "$build/web/Dockerfile" +cp "$repo_root/deploy/distribution/Ingress.Dockerfile" "$build/ingress/Dockerfile" +cp -a "$repo_root/apps/web/dist" "$build/web/dist" +chmod -R a+rX "$build" + +tag="oac-local" +docker build -q --platform linux/amd64 -t "$tag/core:dev" "$build/core" >/dev/null +docker build -q --platform linux/amd64 -t "$tag/web:dev" "$build/web" >/dev/null +docker build -q --platform linux/amd64 -t "$tag/ingress:dev" "$build/ingress" >/dev/null + +python3 - "$repo_root" "$install_dir" <<'PY' +import importlib.util, json, sys +from pathlib import Path +root, dest = map(Path, sys.argv[1:]) +spec = importlib.util.spec_from_file_location("render", root / "scripts/render-compose.py") +render = importlib.util.module_from_spec(spec) +spec.loader.exec_module(render) +pins = json.loads((root / "deploy/compose/smoke-pins.json").read_text()) +(dest / "compose.yaml").write_text(render.render({ + "REVISION": pins["revision"], + "RELEASE_BASE": pins["release_base"], + "ARCHIVE_CHECKSUM": pins["archive_checksum"], +})) +# The release ports.yaml also publishes Core on 127.0.0.1:8091. A local trial +# reaches Core through Web, so only Web is published. +(dest / "ports.yaml").write_text( + "services:\n web:\n ports:\n - \"${OAC_HOST:-127.0.0.1}:${OAC_WEB_PORT:-8080}:8080\"\n") +PY + +umask 077 +cat >"$install_dir/.env" <&2; usage >&2; exit 1 ;; + esac +done + +if [[ "$(uname -s)" != Linux || "$(uname -m)" != x86_64 ]]; then + echo "Core installs on Linux amd64." >&2 + exit 1 +fi +command -v docker >/dev/null || { echo "Docker Engine with Compose 2.26 or newer is required." >&2; exit 1; } +command -v curl >/dev/null || { echo "curl is required." >&2; exit 1; } +compose_version="$(docker compose version --short 2>/dev/null | sed 's/^v//' || true)" +major="${compose_version%%.*}" +minor="${compose_version#*.}" +minor="${minor%%.*}" +if [[ ! "$major" =~ ^[0-9]+$ || ! "$minor" =~ ^[0-9]+$ ]] || (( major < 2 || (major == 2 && minor < 26) )); then + echo "Docker Compose 2.26 or newer is required (found ${compose_version:-none})." >&2 + exit 1 +fi +if [[ "$install_dir" != /* ]]; then + echo "--install-dir must be absolute." >&2 + exit 1 +fi +if [[ -e "$install_dir" ]] && [[ -n "$(ls -A "$install_dir" 2>/dev/null || true)" ]]; then + echo "Installation directory is not empty: $install_dir" >&2 + exit 1 +fi + +port_busy() { + local port="$1" + if command -v ss >/dev/null; then + if ss -ltn | awk '{print $4}' | grep -Eq "(^|:|\\])${port}$"; then + return 0 + fi + return 1 + fi + (echo >/dev/tcp/127.0.0.1/"$port") >/dev/null 2>&1 +} +if port_busy "$web_port"; then echo "Port $web_port is already in use." >&2; exit 1; fi +asset_base="https://github.com/${repository}/releases/latest/download" +if [[ "$version" != latest ]]; then + asset_base="https://github.com/${repository}/releases/download/${version}" +fi + +cleanup() { + if [[ "$kept" != 1 && -d "$install_dir" ]]; then + (cd "$install_dir" && docker compose down --remove-orphans) >/dev/null 2>&1 || true + rm -rf "$install_dir" + fi +} +trap cleanup EXIT + +mkdir -p "$install_dir" +chmod 700 "$install_dir" +files=(compose.yaml ports.yaml) +curl --fail --silent --show-error --location "$asset_base/compose-sha256sums.txt" --output "$install_dir/compose-sha256sums.txt" +for name in "${files[@]}"; do + curl --fail --silent --show-error --location "$asset_base/$name" --output "$install_dir/$name" +done +(cd "$install_dir" && sha256sum --check --ignore-missing --quiet compose-sha256sums.txt) + +compose_file="$(IFS=:; echo "${files[*]}")" +umask 077 +{ + echo "COMPOSE_PROJECT_NAME=oac-$(od -An -N5 -tx1 /dev/urandom | tr -d ' \n')" + echo "COMPOSE_FILE=$compose_file" + echo "OAC_INSTALL_DIR=$install_dir" + echo "OAC_HOST=$host_address" + echo "OAC_WEB_PORT=$web_port" + if [[ -n "$public_url" ]]; then echo "OAC_PUBLIC_URL=$public_url"; fi +} >"$install_dir/.env" + +( + cd "$install_dir" + docker compose pull + docker compose create core + docker compose cp core:/usr/local/bin/oac ./oac + chmod 755 ./oac + docker compose up -d --wait +) +kept=1 +trap - EXIT +address="http://${host_address}:$web_port" +if [[ -n "$public_url" ]]; then address="$public_url"; fi +if [[ "$host_address" == 0.0.0.0 || "$host_address" == "::" ]]; then address="http://:$web_port"; fi +cat < down --volumes --remove-orphans` from `/` without `COMPOSE_*` variables and with its output discarded, and deletes every file in the installation directory. The directory goes too when the installer created it; otherwise it stays with its lock. Loaded images stay. -- The release downloader waits for the bundled installer in a separate process group. It forwards the first Ctrl-C, SIGTERM or SIGHUP and waits for cleanup before removing the temporary bundle; repeated signals do not interrupt that wait. The child inherits the download lock, so killing the downloader alone cannot expose its active files to another download. -- `remove` touches only the `oac-<10 hex digits>` project named in `state.json`, and never follows a link. `state.json`, then the `oac` command, then `.oac.lock` are removed last, and the files stay when a service can't be removed, so the next run still recognizes the installation and no other command locks it afresh mid-removal. Those final unlinks ignore SIGINT, SIGTERM and SIGHUP; if the command cannot be unlinked, cleanup restores `state.json` for a retry. SIGKILL or power loss during those final unlinks can leave files that need manual removal. The error lists what is left and the commands that remove it; the printed Compose command uses the same directory and environment isolation as automatic cleanup. -- A rerun over an incomplete installation, a `state.json` without `"complete": true`, removes it the same way under the lock and then checks the settings and ports and installs with the flags given now. Only that explicit marker proves completion. A directory without `state.json` is refused whatever it holds, and nothing in it is removed. - -## Uninstall - -- `oac uninstall` is the only uninstall entry point; only the installed `oac` matches its release's layout, so there is no uninstall script. It needs only `state.json`, accepts complete and incomplete installations alike, and calls `remove` with image removal and without `keep_root`. Without `state.json` it removes nothing and says so. The operator guide is [Uninstall](../../docs/getting-started/operations.md#uninstall). -- It removes each image `state.json` records unless a tag names it or a container of any project uses it, and reports those it keeps. The installer loads images untagged, so either means something outside the installation uses the image. It never forces an image removal, and runs Docker as `remove` runs Compose. -- Before confirmation it prints what it removes and, when Core answers, the nodes and the number of sandboxes in use that Core lists, with a warning that uninstall stops no sandbox; E2B sandboxes keep running and billing. It then needs the typed directory or `--yes`; without a terminal only `--yes` confirms. It never contacts a node or a sandbox provider; afterwards it prints the nodes' `--force` uninstall command with the installation ID. - -## Install-time sandbox selection - -After the services are healthy, the installer posts `/core/v1/sandbox/deployment` once for microsandbox at Web's Standard size, as Web's setup would, and never on a repair. The choice is not written to `config.json`; PostgreSQL owns it, and an existing database selection is never overwritten. - -- microsandbox uses Web's Standard size from `apps/web/src/features/sandbox/standard-sizes.json`, which the distribution build copies into the bundle. Keep no other copy of those values. -- A selection with a loopback `public_url` is saved, but no node can serve it until `public_url` is guest-reachable HTTPS. - -## Accounts and permissions - -- The Core/Web installer runs as the launching account, root included, in a writable installation directory. It never invokes sudo, switches accounts or changes Docker permissions. Check the actual platform, Docker and directory prerequisites; root alone is no reason to refuse. -- Installation state and secrets are private under `~/.oac/`. No credential enters build arguments, image layers, browser bundles or diagnostic output. The Compose file is confidential. -- The distribution build uses umask 022 so non-root service users can read the payload; installation credentials and state keep their private modes. - -## Managed HTTPS - -A default installation adds two Compose services from one pinned image: `gateway` runs Caddy, and `installation` runs the packaged `oac domain-server`. The latter runs with the installing account's UID and its Docker socket access and calls the same locked apply implementation. Its only request surface is the private `ingress/api/api.sock`, with Core-key authentication and one typed domain action. Core and Web get no Docker socket, host process authority or writable installation configuration. Web gets only the private API socket directory, never Caddy's admin socket. - -- The gateway publishes the initial Web port, and ports 80 and 443 only for HTTPS (`ingress_config.published`). Caddy issues and renews certificates and keeps its private data in `ingress/data`. `generated/Caddyfile` is derived from `config.json`, and apply reloads it through the private Caddy socket even when container inputs already match. -- A domain change first checks that the hostname resolves and that no other program holds port 80 or 443; ports the gateway publishes while it runs as written are its own. It confirms a public URL change from the applied address, because it returns Core there before switching. It keeps the old entry point while the common apply renders the candidate address: the gateway publishes 80 and 443 and serves the candidate, and the verification needs a trusted certificate and an installation-specific response over HTTPS. Only then does it set `public_url` and apply again. Failure restores the previous configuration, including the gateway without 80 and 443 when HTTPS was off, and reports incomplete recovery; failed retries restore through the common apply even after a partial change. -- The operation record keeps the last successfully applied public address. Apply and start update it after gateway verification and service health checks; generated files alone never prove that a new address is active. A successful apply reconciles the domain operation status after verifying the running services. -- The domain operation refuses unrelated pending `config.json` edits and shares `.oac.lock` with the CLI. Its status file is bookkeeping and the recovery receipt; `config.json` stays the source of desired settings. An interrupted operation keeps its desired files and a visible failure and retry state; it never creates another service project or deletes execution data. -- A Web restart ends console sessions, so the UI gives the new HTTPS sign-in address instead of treating a dropped request as success. -- Split and native installations use external ingress and report that automatic Web domain setup is unavailable. - -## Output - -- Progress describes the operation about to run. Release downloads and archive hashing show byte percentages; extraction shows bytes processed, and quiet bundle verification and image loading show elapsed time. Dynamic output is terminal-only; redirected logs stay plain. Do not imply fresh health checks on a no-change repair. -- Terminal styling is optional: honor `NO_COLOR` and keep redirected logs plain. -- Summaries show credential file locations, never their values. -- `install_display.py` owns shared terminal formatting; `install_output.py` and `node_output.py` own the completion guidance. Ship and checksum the display modules in both the node bootstrap and its retained helper. -- A node summary reports success only after Core connection and provider readiness are confirmed. -- Output from the service account stays plain and passes through the terminal-control sanitizer. - -## Node installer - -The node installer runs as root and prepares the host for one node per installation. - -- It creates or adopts the `oac-node` system user, adds it to the `docker` or `kvm` group (no other group), and installs one root-owned system service per installation that runs the node program as `User=oac-node`. Nodes on a host share that account, so a host serves one Core. -- Docker group membership makes that user, and so the node, root-equivalent on the host; that is inherent to Docker sandboxes. microsandbox needs only `kvm`, user KVM access and the Linux runtime libraries. -- Node configuration and identity live under `~/.oac/nodes//` in the node account's home (`/var/lib/oac-node`); microsandbox uses a separate short private Runtime home. -- The node service owns its provider processes outside the Core container. `KillMode=process` keeps resident microVM and helper processes across a service restart. The service restarts after failures with no start limit, so a node outlasts a Core outage, and stops restarting when the node program exits 78 because Core answered 401 to its credential (a removed node). -- It never installs Docker, KVM or packages and never changes device permissions. It refuses SELinux-enforcing hosts and changes nothing when a check fails. The enrollment token comes only on standard input, never in arguments or the environment. -- Files the service account owns are read, written and deleted only with that account's credentials. The one exception is root removing the account's home after `userdel`, when no process can still run as that account. That work runs in a child that starts its own session with `/dev/null` as input, joins a new session keyring and dies with its parent; root shows its output only as plain text (terminal controls become `?`). SIGINT, SIGHUP and SIGTERM stop that child and what it started. -- Root never runs a file the service account can write, opens a URL it wrote, or follows a link in its home. Capture the trusted bootstrap bytes before dropping to the service account and pass them through the fork; the service account writes its own retained generation helper. Never open the caller's private download directory to it or let root write into service-owned state. -- The generated bootstrap passes only the six standard HTTP/HTTPS proxy and bypass variables through sudo and gives both spellings the lowercase value when present, even if empty, so curl, urllib and the Go registration command follow the same rules. The installation child keeps just those names beside its fixed environment. Proxy values stay out of arguments, saved configuration, service units and diagnostics; never use broad sudo environment inheritance. This covers installation downloads only, not the node service. -- `--uninstall` removes a node only after Core rejects its credential, except for a node that never registered and with `--force`, which Web offers when the old Core address no longer responds. It never touches sandboxes, volumes or images (the Runtime image and the microsandbox store stay), deletes the account only when the installer created it and no node remains, and otherwise removes only the groups it added. -- Refuse resources of an older product name for the same installation ID; never adopt them or remove another installation's resources. - -## Download contract - -The distribution manifest is the one download contract for the Core and node installers: flat versioned file names, and the compressed and unpacked size and SHA-256 of the Runtime. The self-hosted counterpart is the native `catalog.json`, which Core serves as one `.sha256` per installer archive. - -- The default installation downloads the Core, Web and PostgreSQL payloads, never the Runtime image or node execution artifacts. Core's image never acquires execution-only payloads. The offline archive stays an explicit option. -- A node obtains bootstrap metadata from the console that generated its command, or from a local offline bundle. Web serves artifacts it has locally and redirects missing declared execution artifacts to the versioned HTTPS release base in the verified manifest. Web never downloads or caches those bytes. -- Only artifact requests may follow HTTPS redirects, and only without credentials or cookies. Metadata and enrollment requests stay on the configured console. The console publishes only fixed non-secret files and declared artifact names. -- Download into private temporary files, verify size and SHA-256 before an atomic rename, resume interrupted transfers, and reuse only verified cache entries or exact image identities. Never select a release other than the pinned one. -- Python zipapps bundle the shared resolver with each remote bootstrap. The node asset is the `oac-node` binary. -- The public release bootstrap retries transient network failures up to three times and resumes an interrupted asset within that run, using its immutable ID, declared size and final checksum. It checks free space before downloading and before extracting each file. These checks cannot reserve storage or measure a remote Docker engine's disk. -- The bootstrap serializes downloads with `~/.oac/releases/.download.lock`. It discards the private `.download/` staging directory left by an interrupted run before accessing the network, never another active downloader's files. Failed downloads and installations remove staging; only a successful installation retains its verified bundle for repair. Loaded Docker images follow the installation cleanup rule above. -- Release downloads are anonymous. Never add repository credentials to installed node or Runtime configuration. -- Manual builds use the `build-` release tag and tag builds the `v*` tag. The manifest's download base must match the release tag; artifact file names and source provenance keep the full source SHA. - -## Image identity - -The manifest's `images` records each exported image's config digest, and `image_manifest_digests` its OCI manifest or index digest. Derive and verify both from the same archive, including its referenced config and layer bytes, and require the build host's selected image ID to match one of them. - -Docker's classic image store identifies images by config digest, and its containerd store by the OCI descriptor. The build therefore takes the digest the local store resolves from BuildKit's build metadata, never the `--iidfile` config digest alone, and disables provenance attestations so each image and archive holds one platform manifest in both stores. For the same reason the default PostgreSQL image is pinned by its linux/amd64 platform manifest digest: a pulled multi-platform tag keeps its whole index in the containerd store, and its export holds every platform. - -The Core and node installers share one resolver for these identities. It confirms Linux amd64 and the returned immutable local ID, and service and provider configuration and Runtime launches use that ID. Tags never replace identity verification. The microsandbox `runtime_ref` is independent of Docker's local store identity. - -## Native daemon installer - -`oac-daemon install` installs the daemon and selected Harnesses on a self-hosted Linux, macOS or Windows machine; the [credential contract](../../contracts/agents-api/environment-executor-credentials.md#installation-grant) covers the grant it claims. - -- Interactive selection and CLI-only installation share one options and validation path. There is no installation-options file. The saved installation state and explicitly supplied credential and tool-variable files serve runtime operation, not a second configuration language. -- Each release bundles pinned Node.js and npm, the native Harnesses and their adapter assets. Registration lives in the CLI, and native activation and readiness in each adapter's optional `agent.Installation` descriptor. Core never selects native paths or OS-specific steps. -- Bootstrap scripts only download and extract the current platform's archive, after verifying the checksum Core provides. Installation, startup, connection verification and execution stay common. Native bundles must match Core's source revision and Runtime wire version. -- Neither Core installation nor repair downloads native payloads. The Core installer keeps the catalog and any offline archives in the installation's private `native-installers/` directory and mounts it read-only into Core. Core serves the local offline archives or redirects to the catalog URL without proxying or caching; it verifies local archives when it starts, and a corrupt local archive stops Core from starting. -- Every mutation holds the installation directory lock. Publish complete, checksum-verified components from staging, then commit the configuration after native readiness passes. A rerun with the same connection settings adds the selected Harnesses and validates existing contents. Never overwrite, upgrade, repair or migrate installed components; missing, modified, wrong-platform or incompatible content is an explicit error. A partial addition keeps the old configuration and reusable complete components and removes nothing. -- Serialize background PID inspection and publication so concurrent starts cannot create two daemons. An installed daemon registers only the adapter kinds its verified installation manifest names; other Harness executables on `PATH` cannot extend it. Direct `connect` refuses an installed Runtime and points to `start`. -- The installer runs as the current user in writable directories and never elevates. Subprocess diagnostics never expose sensitive parameters or environment values. Readiness checks take the installer's cancellation context and reap their processes before returning. -- Report installation, authenticated connection and model configuration as separate results. Starting execution never downloads or installs Harnesses. Stop and reconnect keep capability snapshots and native Session state. -- `scripts/build-native-installer.mjs` validates pins and startup, hashes every component file, accepts only contained regular files and rejects escaping links. Before it packages Claude, the `native-check` workflow reinstalls the frozen `pnpm deploy` export with the hoisted linker, so flattening contained links keeps Node's dependency resolution; the Runtime image's Claude archive is unchanged. The `native-check` workflow builds and tests installation, addition and reuse, missing arguments and the unsupported Windows MiniMax case on Linux, macOS and Windows. - -## Validation - -`make check-distribution` covers the production proxy, the installation rules, release metadata and native catalog assembly, including bundle manifests larger than Node's default subprocess buffer (catalog assembly reads up to 64 MiB). Diagnostics report observed service health, never fabricated model or environment readiness. Runtime observations belong to Core; do not add monitoring or lifecycle tracking to the installer. diff --git a/deploy/install/config.schema.json b/deploy/install/config.schema.json deleted file mode 100644 index 0afac9169..000000000 --- a/deploy/install/config.schema.json +++ /dev/null @@ -1,233 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "OpenAgentCore installation configuration", - "description": "Process settings of one installation. Edit config.json, then run oac apply.", - "type": "object", - "additionalProperties": false, - "required": ["format"], - "properties": { - "$schema": { - "type": "string", - "description": "Editor hint that points at the installed copy of this schema. Ignored.", - "x-oac": {"setting": false} - }, - "format": { - "const": 1, - "description": "Configuration format for this release. Fixed after installation.", - "x-oac": {"setting": false, "changeable": false} - }, - "public_url": { - "type": ["string", "null"], - "default": null, - "description": "Canonical public origin of Core and Web. With managed ingress, set the DNS hostname in Web or run oac domain; certificates are automatic. With external ingress, configure your TLS reverse proxy before applying this value.", - "x-oac": { - "check": "origin", - "restarts": ["core", "web"], - "derives": ["OAC_PUBLIC_URL", "OAC_WEB_ORIGIN"], - "install_flag": "--public-url" - } - }, - "host": { - "type": "string", - "default": "127.0.0.1", - "description": "Listener IP. With managed ingress only the gateway is public; Core stays on loopback. The default installer listens on all IPv4 interfaces.", - "x-oac": { - "check": "listen_host", - "restarts": [ - "core", - "web" - ], - "derives": [ - "Core/Web port mappings or listen addresses" - ], - "install_flag": "--host" - } - }, - "ports": { - "type": "object", - "additionalProperties": false, - "properties": { - "core": { - "type": "integer", - "minimum": 1024, - "maximum": 65535, - "default": 8091, - "description": "Host port of the Core API.", - "x-oac": { - "restarts": ["core"], - "derives": ["Core port mapping"], - "install_flag": "--core-port" - } - }, - "web": { - "type": "integer", - "minimum": 1024, - "maximum": 65535, - "default": 8080, - "description": "Host port of Web.", - "x-oac": { - "restarts": ["web"], - "derives": ["Web or gateway port mapping"], - "install_flag": "--web-port" - } - } - } - }, - "log": { - "type": "object", - "additionalProperties": false, - "properties": { - "level": { - "enum": ["debug", "info", "warn", "error"], - "default": "info", - "description": "Minimum log level of Core and Web.", - "x-oac": {"restarts": ["core", "web"], "derives": ["OAC_LOG_LEVEL"]} - }, - "format": { - "enum": ["auto", "text", "json"], - "default": "auto", - "description": "Log format. auto writes text to a terminal and JSON otherwise.", - "x-oac": {"restarts": ["core", "web"], "derives": ["OAC_LOG_FORMAT"]} - }, - "add_source": { - "type": "boolean", - "default": false, - "description": "Add the source file and line to each log record.", - "x-oac": {"restarts": ["core", "web"], "derives": ["OAC_LOG_ADD_SOURCE"]} - } - } - }, - "core": { - "type": "object", - "additionalProperties": false, - "properties": { - "execution_concurrency": { - "type": "integer", - "minimum": 1, - "maximum": 1024, - "default": 4, - "description": "Concurrent execution work units in Core. Unrelated to node sandbox capacity.", - "x-oac": {"restarts": ["core"], "derives": ["OAC_EXECUTION_CONCURRENCY"]} - }, - "harnesses": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": {"enum": ["claude_sdk", "codex", "mcode"]}, - "default": ["claude_sdk", "codex", "mcode"], - "description": "Harnesses that Sessions may select.", - "x-oac": {"restarts": ["core"], "derives": ["OAC_HARNESSES"]} - }, - "default_harness": { - "enum": ["claude_sdk", "codex", "mcode"], - "default": "codex", - "description": "Harness used when a Session names none. It must be listed in core.harnesses.", - "x-oac": {"restarts": ["core"], "derives": ["OAC_DEFAULT_HARNESS"]} - }, - "write_audit_retention": { - "type": "string", - "default": "2160h", - "description": "How long non-creation write history is kept, as a Go duration of at least 1h.", - "x-oac": {"check": "go_duration_min_1h", "restarts": ["core"], "derives": ["OAC_WRITE_AUDIT_RETENTION"]} - }, - "oauth_trusted_origins": { - "type": "array", - "uniqueItems": true, - "items": {"type": "string", "x-oac": {"check": "https_origin"}}, - "default": [], - "description": "Extra HTTPS origins trusted as private OAuth issuers.", - "x-oac": {"restarts": ["core"], "derives": ["OAC_OAUTH_TRUSTED_ORIGINS"]} - }, - "database_pool": { - "type": "object", - "additionalProperties": false, - "properties": { - "max_conns": { - "type": ["integer", "null"], - "minimum": 1, - "default": null, - "description": "Maximum database connections. null keeps the driver default, max(4, CPU count).", - "x-oac": {"restarts": ["core"], "derives": ["pool_max_conns in OAC_DATABASE_URL"]} - }, - "min_conns": { - "type": ["integer", "null"], - "minimum": 0, - "default": null, - "description": "Minimum idle database connections. null keeps the driver default, 0.", - "x-oac": {"restarts": ["core"], "derives": ["pool_min_conns in OAC_DATABASE_URL"]} - }, - "max_conn_lifetime": { - "type": ["string", "null"], - "default": null, - "description": "Go duration. null keeps the driver default, 1h.", - "x-oac": {"check": "go_duration", "restarts": ["core"], "derives": ["pool_max_conn_lifetime in OAC_DATABASE_URL"]} - }, - "max_conn_idle_time": { - "type": ["string", "null"], - "default": null, - "description": "Go duration. null keeps the driver default, 30m.", - "x-oac": {"check": "go_duration", "restarts": ["core"], "derives": ["pool_max_conn_idle_time in OAC_DATABASE_URL"]} - }, - "health_check_period": { - "type": ["string", "null"], - "default": null, - "description": "Go duration. null keeps the driver default, 1m.", - "x-oac": {"check": "go_duration", "restarts": ["core"], "derives": ["pool_health_check_period in OAC_DATABASE_URL"]} - } - } - }, - "runtime_history": { - "type": ["object", "null"], - "default": null, - "additionalProperties": false, - "description": "Runtime history collection and OTLP export. null keeps local collection with Core's defaults. Core checks the values at startup.", - "x-oac": {"restarts": ["core"], "derives": ["generated/runtime-history.json", "OAC_HISTORY_SETTINGS_FILE"]}, - "properties": { - "transport": { - "type": "string", - "description": "OTLP export transport: `otlp_http`. Required with endpoint.", - "x-oac": {"restarts": ["core"]} - }, - "endpoint": { - "type": "string", - "description": "OTLP/HTTP metrics URL: a canonical absolute URL with a path and no credentials, query or fragment; HTTPS, or HTTP with insecure. Omit it to keep history local; transport, insecure and headers then must be absent.", - "x-oac": {"restarts": ["core"]} - }, - "insecure": { - "type": "boolean", - "description": "Export without TLS. Required for an HTTP endpoint and refused for HTTPS.", - "x-oac": {"restarts": ["core"]} - }, - "headers": { - "type": "object", - "additionalProperties": {"type": "string"}, - "description": "Headers sent with each export, such as credentials. Host, Content-Length, Content-Type and Content-Encoding are refused. Never shown by oac or Core.", - "x-oac": {"sensitive": true, "restarts": ["core"]} - }, - "queue_capacity": { - "type": "integer", - "description": "Capacity of the history write queue and of the export queue, up to 4096. Omitted or 0 selects 256.", - "x-oac": {"restarts": ["core"]} - }, - "timeout_seconds": { - "type": "integer", - "description": "History write, export and query timeout in seconds, up to 30. Omitted or 0 selects 2.", - "x-oac": {"restarts": ["core"]} - }, - "sample_interval_seconds": { - "type": "integer", - "description": "Periodic sampling interval in seconds, 5 to 300. Omitted or 0 selects 30.", - "x-oac": {"restarts": ["core"]} - } - } - } - } - }, - "ingress": { - "enum": ["managed", "external"], - "default": "external", - "description": "managed provides automatic HTTPS and Web domain setup; install.sh selects it by default. external uses your existing proxy. Fixed after installation.", - "x-oac": {"changeable": false, "install_flag": "--ingress"} - } - } -} diff --git a/deploy/install/config_model.py b/deploy/install/config_model.py deleted file mode 100644 index 9ff9fc73a..000000000 --- a/deploy/install/config_model.py +++ /dev/null @@ -1,258 +0,0 @@ -"""The config.json model: a small JSON Schema subset, defaults and the settings snapshot. - -This deliberately implements only the keywords config.schema.json uses. It is not a -general JSON Schema engine; a test fails if the schema uses any other keyword. -""" -import copy -import json -import os -import re - -SERVICES = ("core", "web", "database") -KEYWORDS = {"$schema", "title", "type", "enum", "const", "default", "description", "minimum", "maximum", - "pattern", "items", "minItems", "uniqueItems", "properties", "required", - "additionalProperties", "x-oac"} -ANNOTATIONS = {"changeable", "restarts", "sensitive", "derives", "install_flag", "check", "setting"} - - -def _schema_text(): - # The loader reads from a directory or from inside the oac zipapp. - path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.schema.json") - return __loader__.get_data(path).decode("utf-8") - - -SCHEMA_TEXT = _schema_text() -SCHEMA = json.loads(SCHEMA_TEXT) - - -class ConfigError(Exception): - """Every problem found in config.json, each naming its key. Values are never included.""" - - def __init__(self, problems): - self.problems = list(problems) - super().__init__("config.json is not valid:\n" + "\n".join(" - " + item for item in self.problems)) - - -def annotation(node, name, default=None): - return node.get("x-oac", {}).get(name, default) - - -def leaves(node=None, prefix=""): - """Yield (dotted key, schema node) for every leaf, in schema order.""" - node = SCHEMA if node is None else node - for name, child in node["properties"].items(): - key = prefix + name - if "properties" in child: - yield from leaves(child, key + ".") - else: - yield key, child - - -def lookup(config, key): - value = config - for part in key.split("."): - if not isinstance(value, dict) or part not in value: - return None - value = value[part] - return value - - -# Checks named by x-oac.check. Core stays the authority for its own semantic rules. -def _origin(value, https_only=False): - """Core's deployment.ValidateCoreURL rule, through the installer's one implementation of it.""" - from configuration import valid_core_origin # configuration imports this module at load time - return valid_core_origin(value) and (not https_only or value.startswith("https://")) - - -_DURATION_UNITS = {"ns": 1e-9, "us": 1e-6, "µs": 1e-6, "μs": 1e-6, "ms": 1e-3, "s": 1, "m": 60, "h": 3600} -_DURATION_PART = re.compile(r"([0-9]*(?:\.[0-9]*)?)(ns|us|µs|μs|ms|s|m|h)") - - -def duration_seconds(text): - """Seconds in a Go duration string, or None when Go would reject it.""" - body = text[1:] if text[:1] in "+-" else text - if body == "0": - return 0.0 - total, position = 0.0, 0 - while position < len(body): - part = _DURATION_PART.match(body, position) - if not part or part[1] in ("", "."): - return None - total += float(part[1]) * _DURATION_UNITS[part[2]] - position = part.end() - if not body: - return None - return -total if text.startswith("-") else total - - -def _listen_host(value): - from configuration import valid_listen_host - return valid_listen_host(value) - - -CHECKS = { - "listen_host": (_listen_host, "must be an IPv4 or IPv6 address without a port or zone"), - "origin": (_origin, "must be a canonical origin such as https://core.example: lowercase, no path or " - "trailing slash, and HTTP only for a loopback host"), - "https_origin": (lambda value: _origin(value, https_only=True), "must be a canonical HTTPS origin"), - "go_duration": (lambda value: duration_seconds(value) is not None, "must be a Go duration such as 30m or 1h"), - "go_duration_min_1h": (lambda value: (duration_seconds(value) or 0) >= 3600, - "must be a Go duration of at least 1h"), -} - - -def _type_ok(value, name): - if name == "integer": - return isinstance(value, int) and not isinstance(value, bool) - return isinstance(value, {"string": str, "boolean": bool, "object": dict, "array": list, - "null": type(None)}[name]) - - -def _validate(node, value, key, problems): - label = key or "config.json" - types = node.get("type") - if types is not None: - types = [types] if isinstance(types, str) else types - if not any(_type_ok(value, name) for name in types): - problems.append(f"{label}: must be {' or '.join(types)}") - return - if "const" in node and (value != node["const"] or type(value) is not type(node["const"])): - problems.append(f"{label}: must be {json.dumps(node['const'])}") - return - if "enum" in node and value not in node["enum"]: - problems.append(f"{label}: must be one of {', '.join(json.dumps(item) for item in node['enum'])}") - return - if _type_ok(value, "integer"): - if "minimum" in node and value < node["minimum"]: - problems.append(f"{label}: must be at least {node['minimum']}") - if "maximum" in node and value > node["maximum"]: - problems.append(f"{label}: must be at most {node['maximum']}") - if isinstance(value, str) and "pattern" in node and not re.search(node["pattern"], value): - problems.append(f"{label}: has an invalid format") - check = annotation(node, "check") - if check and isinstance(value, str) and not CHECKS[check][0](value): - problems.append(f"{label}: {CHECKS[check][1]}") - if isinstance(value, list): - if len(value) < node.get("minItems", 0): - problems.append(f"{label}: needs at least {node['minItems']} item(s)") - if node.get("uniqueItems") and len({json.dumps(item, sort_keys=True) for item in value}) != len(value): - problems.append(f"{label}: lists an item twice") - for index, item in enumerate(value): - _validate(node.get("items", {}), item, f"{label}[{index}]", problems) - if isinstance(value, dict): - properties = node.get("properties", {}) - for name in node.get("required", []): - if name not in value: - problems.append(f"{key + '.' if key else ''}{name}: is required") - for name, item in value.items(): - child = f"{key}.{name}" if key else name - if name in properties: - _validate(properties[name], item, child, problems) - elif isinstance(node.get("additionalProperties"), dict): - _validate(node["additionalProperties"], item, child, problems) - else: - problems.append(f"{child}: unknown key") - - -def _complete(node, value): - for name, child in node.get("properties", {}).items(): - if not annotation(child, "setting", True): - continue - if name not in value: - if "default" in child: - value[name] = copy.deepcopy(child["default"]) - elif "properties" in child: - value[name] = {} - else: - continue - if isinstance(value[name], dict) and "properties" in child: - _complete(child, value[name]) - - -def validate(config): - """Return config with defaults filled in for every key, or raise ConfigError.""" - if not isinstance(config, dict): - raise ConfigError(["config.json: must be a JSON object"]) - problems = [] - _validate(SCHEMA, config, "", problems) - if problems: - raise ConfigError(problems) - full = copy.deepcopy(config) - _complete(SCHEMA, full) - ports = full["ports"] - if len(set(ports.values())) != len(ports): - problems.append("ports: " + ", ".join(sorted(ports)) + " need different ports") - from configuration import loopback_listener - import ingress_config - managed = ingress_config.enabled(full) - if managed and full["public_url"]: - try: - from urllib.parse import urlsplit - origin = full["public_url"] - if origin != "https://" + ingress_config.hostname(urlsplit(origin).hostname): - raise ValueError() - except ValueError: - problems.append("public_url: managed HTTPS requires https:// followed by a DNS hostname, without a port") - if not managed and not loopback_listener(full["host"]) and not (full["public_url"] or "").startswith("https://"): - problems.append("public_url: an HTTPS origin is required when host is not loopback") - core = full["core"] - if core["default_harness"] not in core["harnesses"]: - problems.append("core.default_harness: must be listed in core.harnesses") - if problems: - raise ConfigError(problems) - return ordered(full) - - -def ordered(config, node=None): - """The same content with keys in schema order, so written files read like the reference.""" - node = SCHEMA if node is None else node - if not isinstance(config, dict) or "properties" not in node: - return config - result = {} - for name, child in node["properties"].items(): - if name in config: - result[name] = ordered(config[name], child) - for name, value in config.items(): - result.setdefault(name, value) - return result - - -def initial(**values): - """Every field for a new installation, seeded from installer flags.""" - config = {"$schema": "generated/config.schema.json", "format": 1} - for key, value in values.items(): - if value is None: - continue - target = config - *parents, last = key.split(".") - for part in parents: - target = target.setdefault(part, {}) - target[last] = value - return validate(config) - - -def values(config): - """Every setting of a validated config, by dotted key.""" - return {key: lookup(config, key) for key, node in leaves() if annotation(node, "setting", True)} - - -def settings(config): - """The non-secret snapshot Core serves at GET /core/v1/installation.""" - items = [] - for key, node in leaves(): - if not annotation(node, "setting", True): - continue - value = lookup(config, key) - sensitive = annotation(node, "sensitive", False) - item = {"key": key, "value": None if sensitive else value} - if sensitive: - item["configured"] = bool(value) - item.update({"default": node.get("default"), "changeable": annotation(node, "changeable", True), - "sensitive": sensitive, - "restarts": [name for name in annotation(node, "restarts", []) if name in SERVICES]}) - items.append(item) - return items - - -def sensitive_keys(): - return [key for key, node in leaves() if annotation(node, "sensitive", False)] diff --git a/deploy/install/configuration.py b/deploy/install/configuration.py deleted file mode 100644 index a09a38139..000000000 --- a/deploy/install/configuration.py +++ /dev/null @@ -1,355 +0,0 @@ -"""Derive every file under generated/ from config.json, state.json and secrets/. - -Generated files hold no secret except runtime-history.json, which carries the -operator's export headers. Secrets stay in secrets/, one copy each, and reach the -services as read-only single-file mounts or file paths. -""" -import collections -import hashlib -import ipaddress -import json -from pathlib import Path -import re -from urllib.parse import urlencode, urlsplit - -import config_model -import ingress_config - -# Where Core and Web containers see secrets and generated inputs. -RUN = "/run/oac" -POOL = (("max_conns", "pool_max_conns"), ("min_conns", "pool_min_conns"), - ("max_conn_lifetime", "pool_max_conn_lifetime"), ("max_conn_idle_time", "pool_max_conn_idle_time"), - ("health_check_period", "pool_health_check_period")) - - -_HOST_LABEL = re.compile(r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?") - - -def valid_core_origin(value): - """Accept exactly the origins Core's deployment.ValidateCoreURL accepts - (services/core/internal/deployment/public_url.go), so an - installer value never fails Core's OAC_PUBLIC_URL check at startup.""" - if not isinstance(value, str) or any(char in value for char in "?#@\\% \t\r\n"): - return False - try: - parsed = urlsplit(value) - except ValueError: - return False - netloc = parsed.netloc - if (parsed.scheme not in ("http", "https") or value != parsed.scheme + "://" + netloc - or not netloc or netloc != netloc.lower() or netloc.endswith(":")): - return False - if netloc.startswith("["): - host, _, rest = netloc[1:].partition("]") - if rest and not rest.startswith(":"): - return False - port = rest[1:] if rest else "" - else: - host, _, port = netloc.partition(":") - if port and not (port.isdigit() and str(int(port)) == port and 1 <= int(port) <= 65535): - return False - try: - address = ipaddress.ip_address(host) - # Go's IsLoopback also counts an IPv4-mapped loopback address. - mapped = getattr(address, "ipv4_mapped", None) - loopback = address.is_loopback or bool(mapped and mapped.is_loopback) - except ValueError: - if netloc.startswith("[") or len(host) > 253 or not all(_HOST_LABEL.fullmatch(label) for label in host.split(".")): - return False - loopback = host == "localhost" - return parsed.scheme == "https" or loopback - - -def environment_text(values, header): - """The Compose env_file subset: quoted, single-line literal values.""" - lines = ["# " + header + "\n", - "# Values are literal. Escape backslash, double quote and dollar with backslash.\n"] - for key, value in values.items(): - if (not isinstance(key, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key) - or not isinstance(value, str) or any(char in value for char in "\x00\r\n")): - raise RuntimeError("Core environment requires valid names and single-line string values") - escaped = re.sub(r'([\\"$])', r'\\\1', value) - lines.append(key + '="' + escaped + '"\n') - return "".join(lines) - - -def read_environment(text): - """Parse the environment_text format without shell or variable expansion.""" - result = {} - for line in text.split("\n"): - if not line.strip() or line.lstrip().startswith("#"): - continue - match = re.fullmatch(r'([A-Za-z_][A-Za-z0-9_]*)="((?:[^\\"$\x00\r\n]|\\[\\"$])*)"', line) - if not match or match[1] in result: - raise RuntimeError("An environment file requires unique names and double-quoted literal values") - result[match[1]] = re.sub(r'\\([\\"$])', r'\1', match[2]) - return result - - -def bind(source, target, readonly=True): - return {"type": "bind", "source": str(source).replace("$", "$$"), "target": target, "read_only": readonly} - - -def sha256(data): - return hashlib.sha256(data.encode() if isinstance(data, str) else data).hexdigest() - - -def edit_hint(root): - return f"Generated from {Path(root) / 'config.json'}. Do not edit; change config.json and run {Path(root) / 'oac'} apply." - - -def read_core_key(root): - """The Core key, checked the way Web checks it.""" - key = (Path(root) / "secrets/core.key").read_text().strip() - if len(key) < 32 or any(char.isspace() or char == "\x00" for char in key): - raise RuntimeError("secrets/core.key must hold one Core key of at least 32 characters without whitespace") - return key - - -def secret_digests(root): - return {name: sha256((Path(root) / "secrets" / name).read_bytes()) - for name in ("core.key", "credential.key", "database.password")} - - -def valid_listen_host(value): - try: - return "%" not in value and bool(ipaddress.ip_address(value)) - except ValueError: - return False - - -def loopback_listener(host): - address = ipaddress.ip_address(host) - mapped = getattr(address, "ipv4_mapped", None) - return address.is_loopback or bool(mapped and mapped.is_loopback) - - -def service_host(config, service): - """The address Core or Web listens on; behind managed ingress Core stays on loopback.""" - return str(ipaddress.ip_address("127.0.0.1" if service == "core" and ingress_config.enabled(config) else config["host"])) - - -def service_address(config, service, connect=False): - """One derivation for listen addresses and local operator connections.""" - host = service_host(config, service) - address = ipaddress.ip_address(host) - if connect and address.is_unspecified: - host = "::1" if address.version == 6 else "127.0.0.1" - if address.version == 6: - host = "[" + host + "]" - return f'{host}:{config["ports"][service]}' - - -Listener = collections.namedtuple("Listener", "purpose host port setting") - - -def listeners(config, candidate=None): - """Every host listener the services bind, from the addresses they are rendered with. - - setting is the config.json key that owns the port. candidate is the address domain - setup verifies, as in render. - """ - ports, result = config["ports"], [] - if ingress_config.enabled(config): - # The gateway publishes Web's port and, for HTTPS, 80 and 443; Web itself publishes none. - result += [Listener("Web", config["host"], port, "ports.web") if port == ports["web"] else - Listener("HTTPS", config["host"], port, "public_url") - for port, _ in ingress_config.published(config, candidate)] - else: - result.append(Listener("Web", service_host(config, "web"), ports["web"], "ports.web")) - result.append(Listener("Core", service_host(config, "core"), ports["core"], "ports.core")) - return result - - -def service_origin(config, service): - return "http://" + service_address(config, service, connect=True) - - -def web_origin(config): - return config["public_url"] or service_origin(config, "web") - - -def local_public_url(config): - """OAC_PUBLIC_URL: the public origin, or Core's loopback origin for local use.""" - return config["public_url"] or service_origin(config, "core") - - -def log_environment(log): - result = {"OAC_LOG_LEVEL": log["level"]} - if log["format"] != "auto": - result["OAC_LOG_FORMAT"] = log["format"] - if log["add_source"]: - result["OAC_LOG_ADD_SOURCE"] = "1" - return result - - -def core_environment(root, config, state): - root = Path(root) - core = config["core"] - query = [("sslmode", "disable")] + [(name, str(core["database_pool"][key])) for key, name in POOL - if core["database_pool"][key] is not None] - result = { - "OAC_ADDR": ":8091", - "OAC_PUBLIC_URL": local_public_url(config), - "OAC_DATABASE_URL": "postgres://agents_api@database:5432/agents_api?" + urlencode(query), - "OAC_DATABASE_PASSWORD_FILE": RUN + "/database.password", - "OAC_CREDENTIAL_KEY_FILE": RUN + "/credential.key", - "OAC_CORE_KEY_DIGESTS_FILE": RUN + "/core-key-digests.json", - "OAC_INSTALLATION_ID": state["installation_id"], - "OAC_SETTINGS_FILE": RUN + "/settings.json", - "OAC_PROVIDER_ROOT": "/opt/oac", - "OAC_PROVIDER_STATE_ROOT": "/state", - "OAC_DEFAULT_HARNESS": core["default_harness"], - "OAC_HARNESSES": ",".join(core["harnesses"]), - "OAC_EXECUTION_CONCURRENCY": str(core["execution_concurrency"]), - "OAC_WRITE_AUDIT_RETENTION": core["write_audit_retention"], - } - if (root / "native-installers/catalog.json").is_file(): - result["OAC_NATIVE_INSTALLER_DIR"] = "/opt/oac/native-installers" - if core["oauth_trusted_origins"]: - result["OAC_OAUTH_TRUSTED_ORIGINS"] = ",".join(core["oauth_trusted_origins"]) - if core["runtime_history"] is not None: - result["OAC_HISTORY_SETTINGS_FILE"] = RUN + "/runtime-history.json" - result.update(log_environment(config["log"])) - return result - - -def settings_document(root, config, applied_at): - root = Path(root) - return {"path": str(root / "config.json"), "apply_command": f"{root / 'oac'} apply", - "applied_at": applied_at, "settings": config_model.settings(config)} - - -def compose_config(root, config, state, candidate=None): - root = Path(root) - identity = f'{state["uid"]}:{state["gid"]}' - images = state["images"] - doc = {"name": state["project"], - # Compose interpolates every string, extension fields included. - "x-oac": {"generated_from": str(root / "config.json").replace("$", "$$"), - "edit": "config.json, then oac apply"}, - "services": {}, "volumes": {"database": {}}} - services = doc["services"] - services["database"] = { - "image": images["database"], "restart": "unless-stopped", - "environment": {"POSTGRES_USER": "agents_api", "POSTGRES_DB": "agents_api", - "POSTGRES_PASSWORD_FILE": "/run/secrets/database.password"}, - "volumes": ["database:/var/lib/postgresql/data", - bind(root / "secrets/database.password", "/run/secrets/database.password")], - "healthcheck": {"test": ["CMD-SHELL", "pg_isready -U agents_api -d agents_api"], - "interval": "2s", "timeout": "5s", "retries": 30}, - } - mounts = [bind(root / "secrets" / name, f"{RUN}/{name}") for name in ("credential.key", "database.password")] - if (root / "native-installers/catalog.json").is_file(): - mounts.append(bind(root / "native-installers", "/opt/oac/native-installers")) - mounts += [bind(root / "generated" / name, f"{RUN}/{name}") for name in ("core-key-digests.json", "settings.json")] - if config["core"]["runtime_history"] is not None: - mounts.append(bind(root / "generated/runtime-history.json", f"{RUN}/runtime-history.json")) - shared = {"image": images["core"], "user": identity, - "env_file": [str(root / "generated/core.env").replace("$", "$$")], "volumes": mounts, - "read_only": True, "tmpfs": ["/tmp:mode=1777"], "init": True, - "security_opt": ["no-new-privileges:true"]} - services["migrate"] = dict(shared, command=["/usr/local/bin/oac-core-migrate"], - depends_on={"database": {"condition": "service_healthy"}}) - services["core"] = dict(shared, restart="unless-stopped", ports=[service_address(config, "core") + ":8091"], - depends_on={"migrate": {"condition": "service_completed_successfully"}}, - volumes=mounts + [bind(root / "state/e2b", "/state/e2b", False)]) - environment = { - "OAC_WEB_ORIGIN": web_origin(config), - "OAC_WEB_UPSTREAM": "http://core:8091", - "OAC_WEB_CORE_KEY_FILE": f"{RUN}/core.key", - "OAC_WEB_NODE_PAYLOAD_DIR": "/node-payload", - } - environment.update(log_environment(config["log"])) - web = {"image": images["web"], "user": identity, "restart": "unless-stopped", - "ports": [service_address(config, "web") + ":8080"], "read_only": True, - "security_opt": ["no-new-privileges:true"], - "volumes": [bind(root / "secrets/core.key", f"{RUN}/core.key"), bind(root / "node-payload", "/node-payload")], - "environment": environment} - if ingress_config.enabled(config): - web.pop("ports") - environment["OAC_WEB_INSTALLATION_SOCKET"] = "/installation/api.sock" - environment["OAC_WEB_BOOTSTRAP"] = "1" if not config["public_url"] else "0" - web["volumes"].append(bind(root / "ingress/api", "/installation")) - services["web"] = web - if ingress_config.enabled(config): - services.update(ingress_config.services(root, config, state, bind, candidate)) - return doc - - -LABEL = "io.oac.inputs" - - -class Rendered: - """Generated file contents, plus the inputs digest each service must run with. - - The digest covers everything a service reads: its Compose definition, the - env_file content and the files and secrets it mounts. It is the service's - `io.oac.inputs` label, so the running services can be compared with a - render at any time. - """ - - def __init__(self, files, services): - self.files, self.services = files, services - - -def native_installer_inputs(root): - directory = root / "native-installers" - catalog = directory / "catalog.json" - if not catalog.is_file(): - return None - # Archives are immutable and verified on installation/startup. Adding an - # offline archive must restart Core so its local availability map refreshes. - return [sha256(catalog.read_bytes()), sorted(p.name for p in directory.glob("*.tar.gz") if p.is_file())] - - -def render(root, config, state, applied_at, candidate=None): - """candidate is an HTTPS address domain setup verifies before public_url changes: - the gateway publishes 80 and 443 and serves it too.""" - root = Path(root) - secrets = secret_digests(root) - settings = settings_document(root, config, applied_at) - files = {"config.schema.json": config_model.SCHEMA_TEXT, - "settings.json": json.dumps(settings, indent=2) + "\n", - "core-key-digests.json": json.dumps([sha256(read_core_key(root))]) + "\n"} - history = config["core"]["runtime_history"] - if history is not None: - files["runtime-history.json"] = json.dumps(history, indent=2) + "\n" - core_env = environment_text(core_environment(root, config, state), edit_hint(root)) - files["core.env"] = core_env - # Only settings Core itself restarts for enter its inputs, so a Web setting change - # leaves Core running. Its snapshot then refreshes on Core's next restart. - core_settings = [item for item in settings["settings"] if "core" in item["restarts"]] - external = { - "core": json.dumps({ - "core-key-digests.json": sha256(files["core-key-digests.json"]), - "native-installers": native_installer_inputs(root), - "settings": sha256(json.dumps([settings["path"], settings["apply_command"], core_settings], sort_keys=True)), - "runtime-history.json": sha256(files.get("runtime-history.json", "")), - "credential.key": secrets["credential.key"], "database.password": secrets["database.password"], - }, sort_keys=True), - "web": json.dumps({"core.key": secrets["core.key"]}), - } - compose = compose_config(root, config, state, candidate) - services = {} - for name, service in compose["services"].items(): - # Compose resolves env_file into the service configuration, so its content counts. - text = json.dumps(service, sort_keys=True) + (core_env if "env_file" in service else "") - services[name] = sha256(text + external.get("core" if name == "migrate" else name, "")) - service["labels"] = {LABEL: services[name]} - if ingress_config.enabled(config): - files["Caddyfile"] = ingress_config.caddyfile(config, state, candidate) - files["compose.json"] = json.dumps(compose, indent=2) + "\n" - return Rendered(files, services) - - -def rendered_inputs(files): - """The inputs digest per service that a set of generated files asks for.""" - result = {} - if files.get("compose.json"): - try: - for name, service in json.loads(files["compose.json"])["services"].items(): - result[name] = service.get("labels", {}).get(LABEL) - except (ValueError, KeyError, AttributeError): - return {} - return result diff --git a/deploy/install/ingress.py b/deploy/install/ingress.py deleted file mode 100644 index e0c423edc..000000000 --- a/deploy/install/ingress.py +++ /dev/null @@ -1,284 +0,0 @@ -"""Domain setup through one locked installer operation, from Web or the CLI.""" -import argparse -import hmac -import http.client -from contextlib import closing -from http.server import BaseHTTPRequestHandler -import json -from pathlib import Path -import socket -import socketserver -import ssl -import threading -import time -from urllib.parse import urlsplit - -import config_model -import configuration -import ingress_config as gateway -import oac_cli - - -FAILED = "Domain setup failed. Check gateway and installation logs, then retry." -UNFINISHED = ("checking", "applying") - - -class DomainError(oac_cli.OacError): - def __init__(self, code, message, status=400): - super().__init__(message) - self.code, self.status = code, status - - -def save(root, value): - oac_cli.write_private(Path(root) / "ingress/status.json", json.dumps(value) + "\n") - - -def unfinished(root): - """Whether status.json records a domain setup that has not finished.""" - path = Path(root) / "ingress/status.json" - return path.exists() and json.loads(oac_cli.read_private(path, "domain setup status"))["state"] in UNFINISHED - - -def status(root): - config = oac_cli.load_config(root) - supported = gateway.enabled(config) - written = oac_cli.written_view(root, oac_cli.load_state(root), config) - result = {"supported": supported, "state": "ready" if written["public_url"] else "unconfigured", - "public_url": written["public_url"], "target_url": None, "message": None} - if not supported: - result["message"] = "This installation uses an external reverse proxy. Configure HTTPS there, then set public_url and run oac apply." - else: - path = Path(root) / "ingress/status.json" - if path.exists(): - result.update(json.loads(oac_cli.read_private(path, "domain setup status"))) - if result["state"] in UNFINISHED: - try: - with oac_cli.locked(Path(root)): - # A live CLI or Web operation holds this same lock. Read - # again after acquiring it in case the operation just finished. - result.update(json.loads(oac_cli.read_private(path, "domain setup status"))) - if result["state"] in UNFINISHED: - result.update(state="failed", message="Domain setup was interrupted. Retry the same hostname, or run oac apply on the server: it restores the applied address, or finishes the switch if it had started.") - except oac_cli.OacError: - pass - return result - - -def prepare(root, name, confirmation): - """Validate and reserve a job while the caller holds the installation lock.""" - config, state = oac_cli.load_config(root), oac_cli.load_state(root) - if state.get("complete") is not True: - raise DomainError("installation_incomplete", oac_cli.INCOMPLETE, 409) - if not gateway.enabled(config): - raise DomainError("domain_setup_unavailable", "Managed HTTPS is available in combined Docker installations only") - try: - target = "https://" + gateway.hostname(name) - except ValueError as error: - raise DomainError("invalid_hostname", str(error)) from None - if confirmation is not None and confirmation != target: - raise DomainError("invalid_confirmation", "The confirmation must equal the new HTTPS URL") - # Do not apply unrelated pending operator edits from a Web domain action. - previous, _ = oac_cli.disk_view(oac_cli.read_generated(root, {"settings.json", "runtime-history.json"})) - if previous is None or any(previous.get(key) != value for key, value in config_model.values(config).items() - if key != "public_url"): - raise DomainError("configuration_pending", "Apply or revert pending config.json changes before changing the domain", 409) - # Both desired and generated files may be ahead of the running services. - # Common apply/start records this address only after successful convergence. - receipt = Path(root) / "ingress/status.json" - if not receipt.exists(): - raise DomainError("installation_not_ready", "Run oac apply before configuring the domain", 409) - applied = json.loads(oac_cli.read_private(receipt, "domain setup status")) - config = dict(config, public_url=applied["public_url"]) - candidate = dict(config, public_url=target) - config_model.validate(candidate) - actual = oac_cli.observe(state) - if not all(actual.get(name, {}).get("running") for name in ("core", "web", "gateway", "installation")): - raise DomainError("installation_not_running", "Start the installation with oac start before configuring its domain", 409) - rendered, disk, old_settings = oac_cli.render_now(root, config, state) - if oac_cli.edited_files(state, disk, rendered): - raise DomainError("generated_files_edited", "Resolve hand-edited generated files with oac apply before configuring the domain", 409) - host = urlsplit(target).hostname - if not resolves(host): - raise DomainError("hostname_unresolved", f"{host} does not resolve. Add A/AAAA records for it that point at this server, then retry when DNS returns them.", 409) - # The gateway publishes 80 and 443 only for HTTPS; the ports it already publishes are its own. - busy = [str(listener.port) for listener in oac_cli.taken_listeners(candidate, oac_cli.own_listeners(config, old_settings, disk, actual))] - if busy: - named = f"Port {busy[0]} is" if len(busy) == 1 else "Ports " + " and ".join(busy) + " are" - raise DomainError("https_ports_unavailable", f"{named} already in use on this server. Automatic HTTPS cannot run beside another program on ports 80 and 443: free both and retry. Find the program with: sudo ss -ltnp '( sport = :80 or sport = :443 )'", 409) - _, base, answered = oac_cli.old_public_url(root, config, old_settings, disk, actual) - args = argparse.Namespace(dry_run=False, yes=False, confirm_public_url_change=confirmation) - try: - # execute returns Core to the applied address first, even after an interrupted switch, - # so the change is confirmed from there. - oac_cli.confirm_public_url(root, candidate, configuration.local_public_url(config), base, answered, args, False, lambda _: None) - except oac_cli.OacError: - raise DomainError("public_url_confirmation_required", "Changing this address can disconnect existing nodes and executors. Confirm the new URL to continue; retain the old route while existing work uses it.", 409) from None - # The final apply makes the change confirmed here. - args = argparse.Namespace(dry_run=False, yes=False, confirm_public_url_change=target) - job = {"state": "checking", "target_url": target, "public_url": config["public_url"], "message": None} - save(root, job) - return config, state, candidate, args, job - - -def resolves(host): - """Whether DNS answers with an A or AAAA record.""" - try: - # The trailing dot keeps the resolver from appending its search domains. - return bool(socket.getaddrinfo(host + ".", 443, type=socket.SOCK_STREAM)) - except (OSError, UnicodeError): - return False - - -def verify(target, installation_id, timeout=180): - """A trusted certificate and this gateway's identity must both answer at the public address.""" - host = urlsplit(target).hostname - deadline = time.monotonic() + timeout - while True: - try: - # No redirects, ambient proxy or credentials are used for this probe. - with closing(http.client.HTTPSConnection(host, timeout=8, context=ssl.create_default_context())) as client: - client.request("GET", "/_oac/installation/verify") - response = client.getresponse() - body = response.read(256) - if response.status == 200 and body == installation_id.encode(): - return - except (OSError, http.client.HTTPException): - pass - if time.monotonic() >= deadline: - raise DomainError("https_not_ready", f"HTTPS verification failed: {host} did not reach this installation with a trusted certificate. Check that its A/AAAA records point at this server, that no firewall or NAT blocks inbound ports 80 and 443, and the gateway logs for certificate errors. The previous address is kept; retry after the fix.") - time.sleep(2) - - -def execute(root, prepared): - config, state, candidate, args, job = prepared - # Core and Web keep the applied address until the candidate is verified. - keep = argparse.Namespace(dry_run=False, yes=False, confirm_public_url_change=configuration.local_public_url(config)) - try: - # Through common apply, the gateway publishes 80 and 443 and serves the candidate. - oac_cli.write_private(Path(root) / "config.json", json.dumps(config, indent=2) + "\n") - try: - oac_cli._apply(Path(root), keep, False, False, False, lambda _: None, candidate=candidate["public_url"]) - except oac_cli.OacError: - raise DomainError("domain_setup_failed", FAILED) from None # Its message addresses oac apply users. - verify(candidate["public_url"], state["installation_id"]) - job["state"] = "applying" - save(root, job) - # Persist desired inputs before apply. An interrupted operation can be retried - # with the same hostname, or completed by the ordinary oac apply command. - oac_cli.write_private(Path(root) / "config.json", json.dumps(candidate, indent=2) + "\n") - oac_cli._apply(Path(root), args, False, False, False, lambda _: None) - job.update(state="ready", public_url=candidate["public_url"], message=None) - save(root, job) - except Exception as error: - recovery_failed = False - try: - # Restore through common apply: the gateway without the candidate, and without - # 80 and 443 unless HTTPS was already on, and any partially applied services. - oac_cli.write_private(Path(root) / "config.json", json.dumps(config, indent=2) + "\n") - oac_cli._apply(Path(root), keep, False, False, False, lambda _: None) - gateway.reload(root, gateway.caddyfile(config, state)) - except Exception: - recovery_failed = True - message = str(error) if isinstance(error, (DomainError, oac_cli.OacError)) else FAILED - if recovery_failed: - message += " Recovery was incomplete; run oac apply and oac status on the server." - job.update(state="failed", message=message) - save(root, job) - raise DomainError("domain_setup_failed", message) from None - - -def configure(root, name, confirmation=None, out=print): - with oac_cli.locked(root): - prepared = prepare(root, name, confirmation) - out("Requesting and verifying HTTPS. This can take a few minutes.") - execute(root, prepared) - out("HTTPS ready: " + prepared[2]["public_url"] + ". Sign in again with the Core key.") - - -class Server(socketserver.ThreadingMixIn, socketserver.UnixStreamServer): - daemon_threads = True - - -class Handler(BaseHTTPRequestHandler): - def log_message(self, *args): - pass # Headers and requests can carry credentials. - - def reply(self, code, value): - payload = json.dumps(value).encode() - self.send_response(code) - self.send_header("Content-Type", "application/json") - self.send_header("Cache-Control", "no-store") - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - - def authorized(self): - expected = "Bearer " + configuration.read_core_key(self.server.root) - if len(self.headers.get_all("Authorization", [])) != 1 or not hmac.compare_digest(self.headers.get("Authorization", ""), expected): - self.reply(401, {"error": {"code": "unauthorized", "message": "Installation authentication failed"}}) - return False - return True - - def do_GET(self): - if not self.authorized(): - return - if self.path != "/domain": - self.send_error(404) - return - self.reply(200, status(self.server.root)) - - def do_POST(self): - if not self.authorized(): - return - if self.path != "/domain": - self.send_error(404) - return - guard = None - try: - length = int(self.headers.get("Content-Length", "0")) - if not 0 < length <= 2048 or self.headers.get("Transfer-Encoding"): - raise DomainError("invalid_request", "A small JSON request body is required") - data = json.loads(self.rfile.read(length)) - if not isinstance(data, dict) or set(data) - {"hostname", "confirm_public_url_change"}: - raise DomainError("invalid_request", "Expected hostname and optional confirm_public_url_change") - guard = oac_cli.locked(self.server.root) - guard.__enter__() - prepared = prepare(self.server.root, data.get("hostname"), data.get("confirm_public_url_change")) - held, guard = guard, None - # Start independently of the response writer: a browser disconnect must - # not leak the installation lock or abandon a reserved operation. - def work(): - try: - execute(self.server.root, prepared) - except DomainError: - pass - finally: - held.__exit__(None, None, None) - threading.Thread(target=work, daemon=True).start() - self.reply(202, dict(prepared[-1], supported=True)) - except (DomainError, oac_cli.OacError, ValueError) as error: - code = error.code if isinstance(error, DomainError) else "installation_busy" if isinstance(error, oac_cli.OacError) else "invalid_request" - self.reply(error.status if isinstance(error, DomainError) else 409 if isinstance(error, oac_cli.OacError) else 400, - {"error": {"code": code, "message": str(error) if isinstance(error, oac_cli.OacError) else "Invalid JSON request"}}) - finally: - if guard is not None: - guard.__exit__(None, None, None) - - def setup(self): - self.request.settimeout(15) - super().setup() - - -def serve(root): - root = Path(root) - path = Path("/control/api.sock") - path.unlink(missing_ok=True) - import os - previous = os.umask(0o077) - try: - with Server(str(path), Handler) as server: - server.root = root - os.chmod(path, 0o600) - server.serve_forever() - finally: - os.umask(previous) diff --git a/deploy/install/ingress_config.py b/deploy/install/ingress_config.py deleted file mode 100644 index f3d90715d..000000000 --- a/deploy/install/ingress_config.py +++ /dev/null @@ -1,174 +0,0 @@ -"""The installer's managed HTTPS gateway; Core and Web retain no Docker authority.""" -import http.client -from contextlib import closing -import ipaddress -import json -import os -from pathlib import Path -import re -import socket -import stat -import subprocess -from urllib.parse import urlsplit - - -def enabled(config): - return config.get("ingress") == "managed" - - -def hostname(value): - if not isinstance(value, str): - raise ValueError("Enter a DNS hostname, without a scheme, port or path") - value = value.strip().lower() - if (len(value) > 253 or "." not in value or - not all(re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) for label in value.split("."))): - raise ValueError("Enter a DNS hostname, without a scheme, port or path") - try: - ipaddress.ip_address(value) - except ValueError: - if value.endswith((".localhost", ".local", ".internal")): - raise ValueError("Use a publicly registered DNS hostname") from None - return value - raise ValueError("Use a DNS hostname rather than an IP address") - - -def preflight(): - endpoint = os.environ.get("DOCKER_HOST") - if not endpoint: - result = subprocess.run(["docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"], - check=True, capture_output=True, text=True) - endpoint = result.stdout.strip() - if not endpoint.startswith("unix://"): - raise RuntimeError("Managed HTTPS requires a local Docker Unix socket; use --ingress external with a remote Docker engine") - path = Path(endpoint.removeprefix("unix://")).resolve() - info = path.stat() - if not path.is_absolute() or not stat.S_ISSOCK(info.st_mode): - raise RuntimeError("Managed HTTPS requires access to the local Docker socket") - return {"docker_socket": str(path), "docker_gid": info.st_gid} - - -def prepare(root): - if len(str(Path(root) / "ingress/admin/caddy.sock").encode()) >= 108: - raise RuntimeError("Installation path is too long for managed HTTPS Unix sockets; choose a shorter --install-dir") - for name in ("ingress", "ingress/api", "ingress/admin", "ingress/data"): - path = Path(root) / name - if path.is_symlink(): - raise RuntimeError("Managed HTTPS directories must not be symlinks") - path.mkdir(mode=0o700, exist_ok=True) - info = path.stat() - if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700: - raise RuntimeError("Managed HTTPS directories must be private and owned by the installation account") - - -def caddyfile(config, state, candidate=None): - origin = candidate or config["public_url"] - bootstrap = "handle /healthz {\n reverse_proxy web:8080\n}\n" - if config["public_url"]: - bootstrap += f'handle {{\n redir {config["public_url"]}{{uri}} 308\n}}\n' - else: - bootstrap += "handle {\n reverse_proxy web:8080\n}\n" - result = "{\n admin unix//control/caddy.sock\n persist_config off\n}\n\n" - result += "http://:8080 {\n" + bootstrap + "}\n" - for origin in dict.fromkeys(value for value in (config["public_url"], origin) if value): - # config_model also applies these rules to manual config.json edits. - parsed = urlsplit(origin) - if origin != "https://" + hostname(parsed.hostname) or parsed.port is not None: - raise ValueError("Managed HTTPS requires https:// followed by a DNS hostname, without a port") - result += (f"{origin} {{\n" - ' handle /_oac/installation/verify {\n' - f' respond "{state["installation_id"]}" 200\n' - ' }\n' - " @api path /v1 /v1/* /api/v1 /api/v1/*\n" - " handle @api {\n reverse_proxy core:8091\n }\n" - " handle {\n reverse_proxy web:8080\n }\n}\n") - return result - - -class UnixHTTP(http.client.HTTPConnection): - def __init__(self, path, timeout=15): - super().__init__("localhost", timeout=timeout) - self.path = str(path) - - def connect(self): - self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - self.sock.settimeout(self.timeout) - self.sock.connect(self.path) - - -def reload(root, document): - try: - with closing(UnixHTTP(Path(root) / "ingress/admin/caddy.sock")) as client: - client.request("POST", "/load", document.encode(), {"Content-Type": "text/caddyfile"}) - response = client.getresponse() - response.read(65536) - if response.status != 200: - raise RuntimeError("HTTPS gateway refused the configuration; inspect gateway logs and retry") - except (OSError, http.client.HTTPException): - raise RuntimeError("HTTPS gateway is unavailable; inspect gateway logs and retry") from None - - -def published(config, candidate=None): - """(host port, gateway port) of each port the gateway publishes on config["host"]. - - Automatic HTTPS needs ports 80 and 443, so the gateway publishes them only once - public_url is set, or while domain setup verifies a candidate address. - """ - https = [(80, 80), (443, 443)] if config["public_url"] or candidate else [] - return [(config["ports"]["web"], 8080)] + https - - -def written_listeners(compose): - """(address, port) of each host port the gateway publishes in a written compose.json.""" - try: - ports = json.loads(compose)["services"]["gateway"]["ports"] - return {(ipaddress.ip_address(host.strip("[]")), int(port)) - for host, port, _ in (item.rsplit(":", 2) for item in ports)} - except (TypeError, ValueError, KeyError, AttributeError): - return set() - - -def services(root, config, state, bind, candidate=None): - root = Path(root) - identity = f'{state["uid"]}:{state["gid"]}' - common = {"image": state["images"]["ingress"], "user": identity, "restart": "unless-stopped", - "read_only": True, "tmpfs": ["/tmp"], "security_opt": ["no-new-privileges:true"]} - host = config["host"] - if ":" in host: - host = "[" + host + "]" - gateway = dict(common, command=["caddy", "run", "--config", "/generated/Caddyfile", "--adapter", "caddyfile"], - ports=[f"{host}:{port}:{target}" for port, target in published(config, candidate)], - environment={"XDG_DATA_HOME": "/data", "XDG_CONFIG_HOME": "/data/config", - "NO_PROXY": "core,web,localhost,127.0.0.1,::1", - "no_proxy": "core,web,localhost,127.0.0.1,::1"}, - volumes=[bind(root / "generated", "/generated"), bind(root / "ingress/admin", "/control", False), - bind(root / "ingress/data", "/data", False)]) - manager = dict(common, command=["python3", str(root / "oac").replace("$", "$$"), "domain-server"], network_mode="host", - environment={"DOCKER_HOST": "unix:///docker.sock"}, group_add=[str(state["ingress"]["docker_gid"])], - volumes=[bind(root, str(root).replace("$", "$$"), False), bind(root / "ingress/api", "/control", False), - bind(state["ingress"]["docker_socket"], "/docker.sock", False)]) - for service, path in ((gateway, "/control/caddy.sock"), (manager, "/control/api.sock")): - service["healthcheck"] = { - "test": ["CMD", "python3", "-c", "import socket; s=socket.socket(socket.AF_UNIX); s.connect(" + repr(path) + "); s.close()"], - "interval": "2s", "timeout": "5s", "retries": 30, - } - return {"gateway": gateway, "installation": manager} - - -def console_origin(config): - """A useful initial URL; NAT users may substitute their reachable server IP.""" - if config["public_url"]: - return config["public_url"] - host = config["host"] - address = ipaddress.ip_address(host) - if address.is_unspecified: - try: - family = socket.AF_INET6 if address.version == 6 else socket.AF_INET - destination = "2001:db8::1" if address.version == 6 else "192.0.2.1" - with socket.socket(family, socket.SOCK_DGRAM) as probe: - probe.connect((destination, 80)) # Route lookup only; no packet is sent. - host = probe.getsockname()[0] - except OSError: - return f'http://:{config["ports"]["web"]}' - if ":" in host: - host = "[" + host + "]" - return f'http://{host}:{config["ports"]["web"]}' diff --git a/deploy/install/install.py b/deploy/install/install.py deleted file mode 100644 index e579ad6fb..000000000 --- a/deploy/install/install.py +++ /dev/null @@ -1,647 +0,0 @@ -#!/usr/bin/env python3 -"""Install one matched Core distribution, repair it, without changing versions. - -A new installation's flags seed /config.json. Afterwards, edit that file -and run /oac apply; rerunning this installer only repairs. A new installation -that fails before its services first start removes what it created; rerun the same command. -""" -import argparse -import base64 -import contextlib -import errno -import hashlib -import ipaddress -import json -import os -from pathlib import Path -import platform -import re -import secrets -import shutil -import signal -import subprocess -import sys -import tempfile -import traceback -import uuid -from urllib.parse import urlsplit - -import config_model -import configuration -import ingress_config -from configuration import valid_core_origin -import native_installers -import oac_cli -import sandbox_setup -import install_output -import install_display -from install_display import step -from distribution import DistributionError, artifact, image_identities, ensure_docker_image - -SETTING_ARGUMENTS = { - config_model.annotation(node, "install_flag"): (key, node) - for key, node in config_model.leaves() - if config_model.annotation(node, "install_flag") -} -SETTING_FLAGS = tuple(flag.removeprefix("--").replace("-", "_") for flag in SETTING_ARGUMENTS) -AVOID = 20 # An omitted Core or Web port moves at most this far above its default. - - -class InstallError(Exception): - pass - - -REPORTED = (InstallError, oac_cli.OacError, config_model.ConfigError, sandbox_setup.SandboxSetupError, - DistributionError, RuntimeError) -NOTHING_KEPT = "Nothing was kept; fix the problem and rerun the same command." - - -def error_text(error): - """What the installer prints for an error, then what became of a new installation. - - It never includes generated configuration or command output. - """ - if isinstance(error, REPORTED): - text = str(error) - elif isinstance(error, KeyboardInterrupt): - text = "interrupted" - elif isinstance(error, OSError) and error.errno in (errno.ENOSPC, errno.EDQUOT): - text = "Disk space or quota exhausted; free space on the installation filesystem and rerun" - elif isinstance(error, PermissionError): - text = "Permission denied; use a directory writable by your current account (--install-dir)" - else: - text = "inspect prerequisites and private deployment files" - removal = getattr(error, "removal", None) - return text + ("\n" + removal if removal else "") - - -def run(args, **kwargs): - # Never print a generated Compose file, process environment or secret value. - return subprocess.run(args, **dict({"check": True}, **kwargs)) - - -def digest(path): - result = hashlib.sha256() - with path.open("rb") as stream: - for block in iter(lambda: stream.read(1024 * 1024), b""): - result.update(block) - return result.hexdigest() - - -def verify_bundle(bundle): - covered = set() - for line in (bundle / "SHA256SUMS").read_text().splitlines(): - expected, name = line.split(" ", 1) - if name in covered: - raise InstallError("Duplicate distribution checksum entry") - covered.add(name) - path = bundle / name - if not path.resolve().is_relative_to(bundle.resolve()) or path.is_symlink() or not path.is_file(): - raise InstallError("Invalid distribution path") - if digest(path) != expected: - raise InstallError("Distribution checksum mismatch: " + name) - required = {"manifest.json", "install.sh", "install.py", "configuration.py", "config_model.py", "ingress_config.py", "ingress.py", - "config.schema.json", "oac_cli.py", "oac.pyz", - "sandbox_setup.py", "install_output.py", "install_display.py", "standard-sizes.json", "node_spec.py", "node-install.pyz", - "distribution.py", "runtime/seccomp.json"} - required.update(f"images/{name}.tar" for name in ("core", "web", "database", "ingress")) - if not required.issubset(covered): - raise InstallError("Distribution checksum list is incomplete") - manifest = json.loads((bundle / "manifest.json").read_text()) - for name in ("core", "web", "database", "runtime", "ingress"): - image_identities(manifest, name) - for name in ("images/runtime.tar.gz", "native/bin/oac-node", - "native/bin/oac-microsandbox-provider", "native/microsandbox/msb", - "native/microsandbox/libkrunfw.so.5.6.1"): - artifact(manifest, name) - return manifest - - -def public_origin(value): - # Normalize case and a trailing slash, then apply Core's exact origin rule. - try: - parsed = urlsplit(value.strip()) - if parsed.path == "/": - parsed = parsed._replace(path="") - value = parsed._replace(scheme=parsed.scheme.lower(), netloc=parsed.netloc.lower()).geturl() - except ValueError: - value = "" - if not valid_core_origin(value): - raise argparse.ArgumentTypeError("Public URL must be an HTTPS origin such as https://core.example, " - "without path, credentials, query or fragment; plain HTTP only for a loopback host") - return value - - -def arguments(argv=None): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--install-dir", type=Path) - for flag, (_, node) in SETTING_ARGUMENTS.items(): - value_type = int if node.get("type") == "integer" else public_origin if config_model.annotation(node, "check") == "origin" else str - parser.add_argument(flag, type=value_type, help=node["description"]) - parser.add_argument("--config", type=Path, help="Seed a new installation's config.json from this file") - args = parser.parse_args(argv) - args.install_dir = args.install_dir or Path.home() / ".oac/core" - if not args.install_dir.is_absolute(): - parser.error("--install-dir must be absolute") - args.given = [name for name, value in vars(args).items() - if name not in ("install_dir", "given") and value not in (None, False)] - return args - - -def seed_document(args): - """The --config file, which replaces the setting flags.""" - if args.config is None: - return None - if any(getattr(args, name) is not None for name in SETTING_FLAGS): - raise InstallError("--config replaces the setting flags; put those settings in the file") - try: - document = json.loads(args.config.read_text()) - except (OSError, ValueError): - raise InstallError("--config must name a readable JSON file") from None - if not isinstance(document, dict): - raise InstallError("--config must hold a JSON object") - return document - - -def seed_config(args, document): - """config.json for a new installation, from flags or from --config.""" - if document is not None: - document.setdefault("$schema", "generated/config.schema.json") - return config_model.validate(document) - values = {key: getattr(args, flag.removeprefix("--").replace("-", "_")) - for flag, (key, _) in SETTING_ARGUMENTS.items()} - values["ingress"] = values["ingress"] or "managed" - if values["ingress"] == "managed" and values["host"] is None: - values["host"] = "0.0.0.0" - return config_model.initial(**values) - - -def check_listeners(args, document, config): - """Check every listener of a new installation, before anything slow runs. - - A taken port that the flags or the --config file set fails, and so does one that a - loopback public_url names. An omitted Core or Web port moves to the first free port - above its default that no other listener uses. Returns the config and - (purpose, taken port, chosen port) for each move. - """ - if document is None: - names, where = {key: flag for flag, (key, _) in SETTING_ARGUMENTS.items()}, "" - given = {key for key, flag in names.items() if getattr(args, flag.removeprefix("--").replace("-", "_")) is not None} - else: - names, where = {}, " in the --config file" - given = {key for key in ("ports.core", "ports.web") if config_model.lookup(document, key) is not None} - if not oac_cli.address_available(config["host"]): - raise InstallError(f"{config['host']} ({names.get('host', 'host')}{where}) is not an address of this machine; " - "use one of its addresses") - public_url, moved = config["public_url"], [] - for listener in configuration.listeners(config): - if oac_cli.port_free(listener.host, listener.port): - continue - if listener.purpose == "HTTPS": - remedy = "--ingress external" if document is None else '"ingress": "external" in the --config file' - raise InstallError(f"Automatic HTTPS needs ports 80 and 443, and port {listener.port} is already in use on " - f"{listener.host}. Free it, or use an existing reverse proxy with {remedy}; " - f"find the process with: sudo ss -ltnp 'sport = :{listener.port}'") - name = names.get(listener.setting, listener.setting) - # Moving the port would leave a loopback public_url pointing at the old one. - pinned = bool(public_url) and loopback_origin(public_url) and origin_port(public_url) == listener.port - if pinned: - name += " and " + names.get("public_url", "public_url") - if listener.setting in given or pinned or listener.purpose not in ("Core", "Web"): - raise InstallError(oac_cli.port_in_use(listener, name + where)) - taken = {other.port for other in configuration.listeners(config)} - port = next((port for port in range(listener.port + 1, listener.port + AVOID + 1) - if port not in taken and oac_cli.port_free(listener.host, port)), None) - if port is None: - raise InstallError(f"Ports {listener.port} to {listener.port + AVOID} are in use on {listener.host}; " - f"set a free port with {name}{where}") - config["ports"][listener.setting.removeprefix("ports.")] = port - moved.append((listener.purpose, listener.port, port)) - return config, moved - - -def loopback_origin(value): - hostname = urlsplit(value or "").hostname - try: - return ipaddress.ip_address(hostname).is_loopback - except ValueError: - return hostname == "localhost" - - -def origin_port(value): - parsed = urlsplit(value) - return parsed.port or (443 if parsed.scheme == "https" else 80) - - -def nodes_reach(public_url): - """Nodes and their sandboxes need an HTTPS public URL that is not loopback.""" - return urlsplit(public_url or "").scheme == "https" and not loopback_origin(public_url) - - -def check_compose(): - try: - version = run(["docker", "compose", "version", "--short"], capture_output=True, text=True, - timeout=30).stdout.strip() - except (OSError, subprocess.SubprocessError): - raise InstallError("Docker with the Compose plugin is required; check docker compose version") from None - match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?", version) - if not match or tuple(map(int, match.groups())) < (2, 26, 0): - raise InstallError("Docker Compose 2.26.0 or newer is required for literal Core environment values") - - -def check_host(): - if platform.system() != "Linux" or platform.machine() not in ("x86_64", "amd64"): - raise InstallError("Core installation requires Linux amd64 with Docker access") - check_compose() - try: - run(["docker", "info", "--format", "{{.ServerVersion}}"], capture_output=True, timeout=30) - except (OSError, subprocess.SubprocessError): - raise InstallError("Cannot reach Docker as the current account. Check docker info, the daemon and this " - "account's Docker access; the installer does not require root or invoke sudo") from None - - -def image_names(managed=False): - return ["core", "database", "web"] + (["ingress"] if managed else []) - - -def image_loader(manifest, bundle): - def load(names): - images = {} - for name in names: - message = "Preparing " + {"database": "PostgreSQL", "core": "Core", "web": "Web"}.get(name, name) + " image" - step(message) - with install_display.busy(message): - images[name] = ensure_docker_image(manifest, name, lambda name=name: bundle / f"images/{name}.tar") - return images - return load - - -def prepare_node_payload(root, state, bundle): - destination = root / "node-payload" - # Each release remains immutable and addressable while old nodes retain it. - # The only mutable publication is a small, atomically replaced active pointer. - def publish(source): - manifest = json.loads((source / "manifest.json").read_text()) - revision = manifest.get("source_commit", "") - if not re.fullmatch(r"[0-9a-f]{40}", revision): - raise InstallError("Invalid node payload release identity") - metadata_names = ("node-install.pyz", "manifest.json", "SHA256SUMS", "runtime/seccomp.json") - names = list(metadata_names) - for logical in manifest.get("artifacts", {}): - entry = artifact(manifest, logical) - name = "artifacts/" + entry["filename"] - path = source / name - if path.exists(): - if (path.is_symlink() or not path.is_file() - or not path.resolve().is_relative_to(source.resolve()) - or path.stat().st_size != entry["size"] or digest(path) != entry["sha256"]): - raise InstallError("Offline artifact verification failed: " + logical) - names.append(name) - target = destination / "releases" / revision - if target.is_symlink() or target.parent.is_symlink(): - raise InstallError("Installed node payload differs; preserve it and inspect the distribution") - target.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - if target.exists(): - # Validate the complete published metadata and every existing declared - # artifact before filling any absence. Existing bytes are immutable. - for name in metadata_names: - previous = target / name - if (previous.parent.is_symlink() or previous.is_symlink() or not previous.is_file() - or digest(previous) != digest(source / name)): - raise InstallError("Installed node payload differs; preserve it and inspect the distribution") - artifacts = target / "artifacts" - if artifacts.is_symlink() or artifacts.exists() and not artifacts.is_dir(): - raise InstallError("Installed node artifact directory differs") - missing = [] - for logical in manifest.get("artifacts", {}): - entry = artifact(manifest, logical) - name = "artifacts/" + entry["filename"] - previous = target / name - if previous.is_symlink() or previous.exists() and (not previous.is_file() - or previous.stat().st_size != entry["size"] or digest(previous) != entry["sha256"]): - raise InstallError("Installed node artifact differs; refusing repair") - if not previous.exists() and name in names: - missing.append((name, entry)) - if missing: - artifacts.mkdir(mode=0o700, exist_ok=True) - for name, entry in missing: - descriptor, temporary = tempfile.mkstemp(prefix=".payload-", dir=artifacts) - try: - with os.fdopen(descriptor, "wb") as outgoing, (source / name).open("rb") as incoming: - shutil.copyfileobj(incoming, outgoing) - outgoing.flush() - os.fsync(outgoing.fileno()) - if Path(temporary).stat().st_size != entry["size"] or digest(Path(temporary)) != entry["sha256"]: - raise InstallError("Node artifact changed during repair") - # Publish without replacing bytes introduced concurrently. - os.link(temporary, target / name) - finally: - os.unlink(temporary) - for directory in (artifacts, target): - descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - return revision - with tempfile.TemporaryDirectory(prefix=".payload-", dir=target.parent) as temporary: - stage = Path(temporary) / "release" - stage.mkdir(mode=0o700) - for name in names: - path = source / name - if path.is_symlink() or not path.is_file(): - raise InstallError("Invalid node payload source file") - copied = stage / name - copied.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - with path.open("rb") as incoming, copied.open("xb") as outgoing: - os.chmod(copied, 0o600) - shutil.copyfileobj(incoming, outgoing) - outgoing.flush() - os.fsync(outgoing.fileno()) - os.rename(stage, target) - return revision - - if destination.is_symlink(): - raise InstallError("Invalid node payload directory") - destination.mkdir(mode=0o700, exist_ok=True) - revision = publish(bundle) - pointer = destination / "active.json" - if pointer.is_symlink(): - raise InstallError("Invalid active node payload pointer") - if pointer.exists(): - if json.loads(pointer.read_text()) != {"source_commit": revision}: - raise InstallError("Installed node payload differs; preserve it and inspect the distribution") - return - descriptor, temporary = tempfile.mkstemp(prefix=".active-", dir=destination) - try: - with os.fdopen(descriptor, "w") as stream: - json.dump({"source_commit": revision}, stream) - stream.write("\n") - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, pointer) - descriptor = os.open(destination, os.O_RDONLY | os.O_DIRECTORY) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - finally: - if os.path.exists(temporary): - os.unlink(temporary) - - -def install_oac(root, bundle): - """Copy the oac command into the installation; replace a missing or different copy.""" - target = root / "oac" - source = bundle / "oac.pyz" - if target.is_file() and not target.is_symlink() and digest(target) == digest(source): - os.chmod(target, 0o700) - return - descriptor, temporary = tempfile.mkstemp(prefix=".oac-", dir=root) - os.close(descriptor) - try: - shutil.copyfile(source, temporary) - os.chmod(temporary, 0o700) - os.replace(temporary, target) - finally: - if os.path.exists(temporary): - os.unlink(temporary) - - -def layout(root): - if not root.exists() or not any(path.name != ".oac.lock" for path in root.iterdir()): - return "empty" - if (root / "state.json").exists(): - try: - complete = json.loads((root / "state.json").read_text()).get("complete") - except (OSError, ValueError, AttributeError): - complete = None - # create() writes state.json before anything else, with complete: false; the first - # successful start sets it. - if complete is not True: - return "incomplete" - return "config" if (root / "config.json").exists() else "missing-config" - # Without state.json nothing here is known to be the installer's, so nothing is taken over or removed. - return "config" if (root / "config.json").exists() else "other" - - -def written_state(root): - """state.json, or None before create() wrote it; then the directory holds only the lock.""" - return oac_cli.load_state(root) if (root / "state.json").exists() else None - - -def remove_created(root, state, created): - """Remove what this run created; returns the line printed after its error.""" - try: - if state is not None: - oac_cli.remove(root, state, keep_root=not created) - elif created: - # Never remove anything else from a directory without this installation's state.json. - with contextlib.suppress(OSError): - (root / ".oac.lock").unlink() - root.rmdir() - except oac_cli.OacError as left: - return str(left) - return NOTHING_KEPT - - -def create(root, config, manifest, images): - """Write the new installation's state.json, secrets and config.json, in that order.""" - token = secrets.token_hex(32) - if root.parent == Path.home() / ".oac": - oac_cli.private_parent(root) - root.mkdir(mode=0o700, parents=True, exist_ok=True) - os.chmod(root, 0o700) - state = {"format": 2, "installation_id": str(uuid.uuid4()), "project": "oac-" + secrets.token_hex(5), - "uid": os.getuid(), "gid": os.getgid(), - "source_commit": manifest["source_commit"], "images": images, "secrets_sha256": {}, - "generated": {}, "complete": False} - if ingress_config.enabled(config): - state["ingress"] = ingress_config.preflight() - # state.json first, written whole: it marks everything after it as this installation's. - oac_cli.save_state(root, state) - for name in ("secrets", "generated", "state", "state/e2b"): - (root / name).mkdir(mode=0o700) - write = oac_cli.create_private - write(root / "secrets/core.key", token) - write(root / "secrets/credential.key", base64.b64encode(secrets.token_bytes(32)).decode()) - write(root / "secrets/database.password", secrets.token_hex(32)) - if ingress_config.enabled(config): - ingress_config.prepare(root) - digests = configuration.secret_digests(root) - digests.pop("core.key") - oac_cli.save_state(root, dict(state, secrets_sha256=digests)) - # config.json last: whenever it exists, the installation can be repaired. - write(root / "config.json", json.dumps(config, indent=2) + "\n") - - -def finish(root, bundle, manifest, fresh=False, selection=None, moved=()): - """Repair and start this release while the installer holds the installation lock.""" - state = oac_cli.load_state(root) - step("Preparing service files") - prepare_node_payload(root, state, bundle) - native_installers.prepare(root, state, bundle) - install_oac(root, bundle) - if ingress_config.enabled(oac_cli.load_config(root)): - ingress_config.prepare(root) - args = argparse.Namespace(dry_run=False, confirm_public_url_change=None) - step("Applying settings and starting services as needed") - try: - oac_cli._apply(root, args, False, True, sys.stdin.isatty(), - lambda message: print(message, flush=True), retry=f"rerun ./install.sh --install-dir {root}") - except oac_cli.ApplyFailed as error: - if not fresh: - raise - # The installer removes what it created and says how to retry. - raise InstallError(f"The services did not start: {error.cause}") from None - config = oac_cli.load_config(root) - if fresh: - # The first start finished: from now on the installation is kept. - oac_cli.save_state(root, dict(oac_cli.load_state(root), complete=True)) - deployment = failure = None - if selection: - step("Configuring sandbox backend") - try: - deployment = sandbox_setup.initialize(root, config, state, selection) - except sandbox_setup.SandboxSetupError as error: - failure = error - summary(root, config, fresh, selection, deployment, incomplete=failure is not None, moved=moved) - if failure: - raise InstallError(f"{str(failure).rstrip('.')}. Services are installed and running; " - "choose the sandbox backend on the Nodes page in Web") - - -def summary(root, config, fresh, selection=None, deployment=None, incomplete=False, moved=()): - public_url, ports = config["public_url"], config["ports"] - addresses = [] - # Web accepts only its configured origin. - console = ingress_config.console_origin(config) if ingress_config.enabled(config) else configuration.web_origin(config) - addresses.append("Console: " + console + (" (local only)" if loopback_origin(console) else "")) - api = configuration.service_origin(config, "core") + "/v1" - if public_url and not loopback_origin(public_url): - label = "Local-only API on this host: " if configuration.loopback_listener(config["host"]) else "Direct API on this host: " - addresses += ["API base URL: " + public_url + "/v1", label + api] - elif public_url and origin_port(public_url) != ports.get("web"): - addresses.append("API base URL: " + public_url + "/v1 (local only)") - else: - # The loopback Web port does not serve the public API. - addresses.append("API base URL: " + api + " (local only)") - install_output.summary(root, config, addresses, fresh, selection, deployment, - nodes_reach(public_url), incomplete, moved) - - -def main(argv=None): - args = arguments(argv) - root = args.install_dir - if root.is_symlink() or root.resolve() != root: - raise InstallError("Installation directory must be canonical and not a symlink") - bundle = Path(__file__).resolve().parent - # Settings and listeners take seconds to check, so they come before hashing the bundle. - prepared = None - if layout(root) == "empty": - step("Checking installation settings") - prepared = prepare_fresh(args) - step("Verifying installation files") - with install_display.busy("Verifying installation files"): - manifest = verify_bundle(bundle) - # Refuse foreign state before even creating a lock; repeat under the lock to - # protect against another current installer finishing between these reads. - check_release(root, manifest) - if root.parent == Path.home() / ".oac": - root.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - created = not root.exists() - root.mkdir(mode=0o700, parents=True, exist_ok=True) - with oac_cli.locked(root): - check_release(root, manifest) - install_locked(args, root, bundle, manifest, prepared, created) - - -def check_release(root, manifest): - if (root / "state.json").exists(): - state = oac_cli.load_state(root) - if state.get("complete") is True and state.get("source_commit") != manifest["source_commit"]: - raise InstallError(oac_cli.UNSUPPORTED_VERSION) - - -def prepare_fresh(args): - document = seed_document(args) - config = seed_config(args, document) - config, moved = check_listeners(args, document, config) - return config, moved - - -def install_locked(args, root, bundle, manifest, prepared, created): - kind = layout(root) - if kind == "config": - if args.given: - raise InstallError(f"This installation is configured by {root / 'config.json'}. Edit it and run " - f"{root / 'oac'} apply; install.sh accepts only --install-dir to repair it") - state = oac_cli.load_state(root) - step("Checking host requirements for repair") - check_host() - images = image_loader(manifest, bundle)(list(state["images"])) - if images != state["images"]: - oac_cli.save_state(root, dict(state, images=images)) - finish(root, bundle, manifest) - return - if kind == "missing-config": - raise InstallError(f"{root / 'config.json'} is missing. Restore it from a backup; " - f"{root / 'generated/settings.json'} lists the last applied values. The secrets and " - "database belong to this installation, so keep the directory. Nothing was changed") - if kind == "other": - raise InstallError("Installation directory is not empty; refusing to overwrite existing state") - if kind == "incomplete": - # A first installation stopped without its cleanup, such as by kill -9 or power loss. - step("Removing an incomplete earlier installation") - oac_cli.remove(root, oac_cli.load_state(root), keep_root=True) - if prepared is None: - # Checked only now that the earlier installation is gone, so the ports it held count as free. - step("Checking installation settings") - prepared = prepare_fresh(args) - try: - install_fresh(args, root, bundle, manifest, prepared) - except (Exception, KeyboardInterrupt) as error: - state = written_state(root) - if state and state.get("complete") is True: - raise - # A first installation that did not finish removes what it created, before printing - # anything, so the same command can run again. The error goes on with the outcome. - error.removal = remove_created(root, state, created) - raise - - -def install_fresh(args, root, bundle, manifest, prepared): - """Check the host, load images, create the installation and start it for the first time.""" - config, moved = prepared - step("Checking host requirements") - check_host() - if ingress_config.enabled(config): - ingress_config.preflight() - selection = sandbox_setup.selection(bundle, manifest, "microsandbox") - images = image_loader(manifest, bundle)(image_names(ingress_config.enabled(config))) - step("Creating installation settings and credentials") - create(root, config, manifest, images) - finish(root, bundle, manifest, fresh=True, selection=selection, moved=moved) - - -def interrupted(signum, frame): - raise KeyboardInterrupt - - -if __name__ == "__main__": - # SIGTERM and SIGHUP, such as from a dropped SSH session, stop the installer as Ctrl-C does, - # so a new installation still removes what it created. - for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): - signal.signal(signum, interrupted) - try: - main() - except (*REPORTED, OSError, ValueError, KeyError, subprocess.CalledProcessError, KeyboardInterrupt) as error: - with contextlib.suppress(OSError): # The terminal may be gone. - install_display.error(error_text(error)) - sys.exit(130 if isinstance(error, KeyboardInterrupt) else 1) - except Exception as error: - # An unexpected error keeps its traceback, followed by what became of a new installation. - traceback.print_exc() - if getattr(error, "removal", None): - print(error.removal, file=sys.stderr) - sys.exit(1) diff --git a/deploy/install/install.sh b/deploy/install/install.sh deleted file mode 100755 index 6802833bc..000000000 --- a/deploy/install/install.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -exec python3 "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/install.py" "$@" diff --git a/deploy/install/install_output.py b/deploy/install/install_output.py deleted file mode 100644 index 40bb30e28..000000000 --- a/deploy/install/install_output.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Human-readable completion guidance for the Core/Web installer.""" -import shlex - -import sandbox_setup -import ingress_config -from install_display import color, heading, paragraph - - -def choose_where(): - return "on the Nodes page in Web" - - -def size(resources): - memory = resources["memory_mib"] - return f'{resources["cpus"]} CPUs, ' + (f"{memory // 1024} GiB" if memory % 1024 == 0 else f"{memory} MiB") - - -def sandbox_lines(config, selection, deployment, reachable): - if selection is None: - return [f"Sandboxes: none chosen. Choose a sandbox backend {choose_where()}."] - if deployment is None: - return [] - if selection["provider"] == "e2b": - resources = (deployment.get("specification") or {}).get("resources") or {} - built = f" ({size(resources)})" if {"cpus", "memory_mib"} <= set(resources) else "" - return [f'Sandboxes: E2B template {selection["e2b"]["template"]}{built}. E2B runs them; no nodes are needed.'] - lines = [f'Sandboxes: {sandbox_setup.NAMES[selection["provider"]]}, Standard ({size(selection["resources"])}).'] - if selection["provider"] == "microsandbox": - lines.append("Execution nodes need KVM (/dev/kvm). This host needs KVM only if you add it as a node.") - if not reachable: - lines.append("Before adding nodes, configure a reachable HTTPS address" + - (" in Web under System → Domain and HTTPS." if ingress_config.enabled(config) else - " with your reverse proxy, set public_url in config.json, then run the Apply command below.")) - add = "in Web, open Nodes and choose Add node" - lines.append(f"Add nodes: {add}, then run the command on each execution host.") - return lines - - -def summary(root, config, addresses, fresh, selection, deployment, reachable, incomplete, moved=()): - status = ("Services are running; sandbox setup needs attention." if incomplete else - "Installation complete." if fresh else "Installation settings checked. Use Status below to inspect service health.") - print("\n" + color(status, "33" if incomplete else "32")) - heading("Access") - for address in addresses: - print(" " + address) - for purpose, taken, port in moved: - print(f" Port {taken} was in use; {purpose} uses {port}.") - heading("Sign in") - print(f" Core key file: {root / 'secrets/core.key'}") - paragraph("Use this key to sign in to Web. Keep it private.") - heading("Next") - if ingress_config.enabled(config) and not config["public_url"]: - paragraph("Open Web at the server IP and sign in. In System → Domain and HTTPS, enter your DNS hostname; the installation requests and renews its certificate. HTTPS then uses ports 80 and 443: DNS must point to this server, no other program on it may use those ports, and they must be reachable from the internet. Web checks DNS and the ports before it starts.") - paragraph("Create a Project and its API key on the Projects and keys page.") - if fresh: - for line in sandbox_lines(config, selection, deployment, reachable): - paragraph(line) - heading("Manage") - print(f" Settings: {root / 'config.json'}") - command = shlex.quote(str(root / "oac")) - for label, action in (("Apply settings", "apply"), ("Status", "status"), ("Start", "start"), ("Stop", "stop"), ("Uninstall", "uninstall")): - print(f" {label}: {command} {action}") - print("\nNo model request was made. Quickstart: docs/getting-started/quickstart.md", flush=True) diff --git a/deploy/install/installer_fakes.py b/deploy/install/installer_fakes.py deleted file mode 100644 index a79471f7b..000000000 --- a/deploy/install/installer_fakes.py +++ /dev/null @@ -1,367 +0,0 @@ -"""A fake Docker, systemd and HTTP host for installer tests. Nothing real is started. - -Compose is modelled by its observable contract: `up` recreates a container exactly -when its resolved configuration (including env_file content) changed, each container -keeps the labels it was created with and holds the host ports it publishes while it -runs, and Core loads its Core key digests when it starts. systemd runs the unit it -last loaded; its process keeps the environment it started with. -""" -import errno -import hashlib -import ipaddress -import json -from pathlib import Path -import re -import subprocess -from types import SimpleNamespace -from unittest import mock - -import oac_cli -import sandbox_setup - -LABEL = "io.oac.inputs" -STANDARD_SIZES = Path(__file__).resolve().parents[2] / "apps/web/src/features/sandbox/standard-sizes.json" - - -def sha256(data): - return hashlib.sha256(data.encode() if isinstance(data, str) else data).hexdigest() - - -class FakeHost: - def __init__(self, test): - self.commands, self.requests = [], [] - self.containers = {} # service -> {hash, inputs, running} - self.project = None # the Compose project the containers belong to - self.recreated = [] - self.native = {"active": False, "enabled": False, "starts": 0, "restarts": 0, "reloads": 0, "addr": None, - "digests": [], "inputs": None, "loaded": None, "environment": ""} - # fails: Core never starts; rejects(core.env text): Core refuses that configuration. - self.core = {"port": None, "digests": [], "fails": False, "log": "", "rejects": lambda environment: False} - self.web_port = None - self.native_root = None # the installation whose native unit systemctl manages - self.missing_images = set() - self.other_containers = {} # image ID -> containers of other installations that use it - self.core_installation_id = "11111111-2222-4333-8444-555555555555" - self.bindings = {"nodes": 0, "nodes_on_other_address": 0, "hosted_sandboxes": 0, "self_hosted_executors": 0} - self.nodes = [] - self.remote_core = {} # web-only: origin -> (status, installation_id) - self.deployment_core_url = "" # what an old Core reports for its sandbox deployment - self.deployment = {"provider": "", "generation": 0, "reset": None, "resources": {"allocations": 0, "pending": 0}} # what sandbox_setup reads and posts - self.deployment_posts = [] - self.deployment_refusal = None # Core's message when it refuses the POST - self.busy = set() # (address, port) of listening sockets besides the ports running containers publish - self.unassigned = set() # addresses this host does not have - for target, name, value in ((subprocess, "run", mock.Mock(side_effect=self.run)), - (oac_cli, "http", self.http), - (oac_cli, "bind_error", self.bind_error), - (oac_cli, "tcp_listeners", lambda: [(ipaddress.ip_address(host), port) - for host, port in self.listening()]), - (oac_cli, "time", SimpleNamespace(sleep=lambda seconds: None)), - (sandbox_setup, "send", self.sandbox_send)): - patcher = mock.patch.object(target, name, value) - patcher.start() - test.addCleanup(patcher.stop) - - def bind_error(self, host, port): - if host in self.unassigned: - return errno.EADDRNOTAVAIL - address = ipaddress.ip_address(host) - return errno.EADDRINUSE if any(held == port and oac_cli.overlaps(address, ipaddress.ip_address(other)) - for other, held in self.listening()) else 0 - - def running(self): - return {name for name, item in self.containers.items() if item["running"]} - - def listening(self): - """(address, port) of every listening socket: busy, and the ports running containers publish.""" - return self.busy | {tuple(pair) for item in self.containers.values() if item["running"] - for pair in item.get("ports", ())} - - def run_container(self, name, port=None, digests=None): - """A container of an installation made outside the test, such as an earlier release.""" - self.containers[name] = {"hash": "external", "inputs": None, "running": True} - if name == "core": - self.core.update(port=port, digests=digests or []) - - # Commands --------------------------------------------------------------- - def run(self, args, check=False, **kwargs): - args = [str(item) for item in args] - self.commands.append(args) - code, stdout = 0, "" - if args[:2] == ["docker", "compose"] and args[2:3] == ["-f"]: - code, stdout = self.compose(Path(args[3]), args[4:]) - elif args[:2] == ["docker", "compose"] and args[2:3] == ["-p"]: - # Without a project file Compose acts on the named project's labels alone. - if args[4:5] == ["down"] and args[3] == self.project: - self.containers.clear() - elif args[:2] == ["docker", "compose"]: - stdout = "2.30.0" - elif args[:3] == ["docker", "image", "inspect"]: - code = 1 if args[3] in self.missing_images else 0 - stdout = "" if code else "[]" if "{{json .RepoTags}}" in args else args[3] + " linux/amd64" - elif args[:3] == ["docker", "image", "rm"]: - self.missing_images.add(args[3]) - elif args[:3] == ["docker", "image", "ls"]: - stdout = "\n".join(sorted(set(MANIFEST["images"].values()) - self.missing_images)) - elif args[:2] == ["docker", "ps"] and args[-1].startswith("ancestor="): - stdout = "\n".join(self.other_containers.get(args[-1].removeprefix("ancestor="), [])) - elif args[:2] == ["docker", "ps"]: - stdout = "\n".join(name for name, item in self.containers.items() if item["running"]) if "--format" in args else "\n".join("id-" + name for name in self.containers) - elif args[:2] == ["docker", "inspect"]: - stdout = "\n".join(f'{name}\t{self.containers[name]["inputs"] or ""}\t' - f'{"running" if self.containers[name]["running"] else "exited"}\t' - for name in (item[3:] for item in args[4:]) if name in self.containers) - elif args[0] == "systemctl": - code, stdout = self.systemctl(args[2:]) - elif args[0] == "loginctl": - stdout = "yes" - elif args[0] == "journalctl": - stdout = self.core["log"] - if check and code: - raise subprocess.CalledProcessError(code, args) - text = kwargs.get("text") or kwargs.get("universal_newlines") - return subprocess.CompletedProcess(args, code, stdout if text else stdout.encode(), "" if text else b"") - - def service_hash(self, document, name): - service = document["services"][name] - text = json.dumps(service, sort_keys=True) - for path in service.get("env_file", []): - text += Path(path.replace("$$", "$")).read_text() - return sha256(text) - - def compose(self, path, args): - document = json.loads(path.read_text()) if path.exists() else {"services": {}} - services = document["services"] - self.project = document.get("name", self.project) - if args[:1] == ["down"]: - self.containers.clear() - return 0, "" - if args[:1] == ["stop"]: - for name in args[1:] or services: - if name in self.containers: - self.containers[name]["running"] = False - return 0, "" - if args[:1] == ["restart"]: - for name in args[1:]: - self.containers[name]["running"] = True - if name == "core": - self.load_core(path.parent.parent, services[name]) - return 0, "" - if args[:1] == ["logs"]: - return 0, self.core["log"] - if args[:1] == ["up"]: - targets = [item for item in args[1:] if not item.startswith("-") and not item.isdigit()] - names = list(services) if not targets else [ - name for name in ("database", "migrate", *targets) if name in services and - (name in targets or name in ("database", "migrate") and "core" in targets)] - for name in names: - digest = self.service_hash(document, name) - current = self.containers.get(name) - if current is None or current["hash"] != digest: - self.containers[name] = {"hash": digest, "inputs": services[name].get("labels", {}).get(LABEL), - "running": False, - "ports": [[host.strip("[]"), int(port)] for host, port, _ in - (item.rsplit(":", 2) for item in services[name].get("ports", []))]} - self.recreated.append(name) - if name == "core": - self.load_core(path.parent.parent, services[name]) - if name != "migrate": - self.containers[name]["running"] = True - if "web" in names: - web = services["web"] - if "gateway" in services: - web = services["gateway"] - self.web_port = int(web["ports"][0].rsplit(":", 2)[1]) if "ports" in web else int( - web["environment"]["OAC_WEB_ADDR"].rsplit(":", 1)[1]) - environment = path.parent / "core.env" - if "core" in names and (self.core["fails"] or - self.core["rejects"](environment.read_text() if environment.exists() else "")): - self.containers["core"]["running"] = False - self.core["failed"] = True - return 1, "" - return 0, "" - - def load_core(self, root, service): - if "ports" in service: - self.core["port"] = int(service["ports"][0].rsplit(":", 2)[1]) - digests = root / "generated/core-key-digests.json" - self.core["digests"] = json.loads(digests.read_text()) - environment = root / "generated/core.env" - self.core["environment"] = environment.read_text() if environment.exists() else "" - - def unit_file(self): - found = sorted(self.native_root.glob("generated/oac-*-core.service")) - return found[0].read_text() if found else "" - - def systemctl(self, args): - native = self.native - if args[0] == "daemon-reload": - native["reloads"] += 1 - native["loaded"] = self.unit_file() - elif args[0] in ("enable", "restart", "start"): - native["enabled"] = native["enabled"] or args[0] == "enable" - if args[0] != "restart" and native["active"]: - return 0, "" # systemd leaves an active unit alone on start and enable --now - native["starts" if args[0] != "restart" else "restarts"] += 1 - if native["loaded"] is None: - native["loaded"] = self.unit_file() - environment = self.native_root / "generated/core.env" - refused = self.core["fails"] or self.core["rejects"](environment.read_text() if environment.exists() else "") - native["active"] = not refused - self.core["failed"] = self.core.get("failed") or refused - match = re.search(r"^Environment=OAC_INPUTS=(\w+)$", native["loaded"], re.M) - native["inputs"] = match[1] if match and native["active"] else None - root = self.native_root - environment = root / "generated/core.env" - if environment.exists(): - address = next(line for line in environment.read_text().splitlines() if line.startswith("OAC_ADDR=")) - native["addr"] = int(address.rsplit(":", 1)[1].rstrip('"')) - native["digests"] = json.loads((root / "generated/core-key-digests.json").read_text()) - native["environment"] = environment.read_text() - elif args[0] == "stop": - native["active"], native["inputs"] = False, None - elif args[0] == "disable": - if not native["enabled"]: - return 1, "" # the unit file was never linked - native["enabled"] = False - if "--now" in args: - native["active"], native["inputs"] = False, None - elif args[0] == "is-active": - return (0 if native["active"] else 3), "" - elif args[0] == "show" and "--property=MainPID" in args: - return 0, "4242" if native["active"] else "0" - elif args[0] == "show" and "--property=LoadState" in args: - return 0, "loaded" if native["enabled"] or native["active"] else "not-found" - elif args[0] == "show": - return 0, "252" - return 0, "" - - def process_environment(self, pid): - return {"OAC_INPUTS": self.native["inputs"]} if self.native["inputs"] else {} - - # HTTP ------------------------------------------------------------------- - def core_listening(self, port): - if self.containers.get("core", {}).get("running") and self.core["port"] == port: - return self.core["digests"] - if self.native["active"] and self.native["addr"] == port: - return self.native["digests"] - return None - - def http(self, url, headers=None, timeout=5): - self.requests.append(url) - headers = headers or {} - origin, _, path = url.partition("://")[2].partition("/") - path = "/" + path - key = headers.get("Authorization", "").removeprefix("Bearer ") - for remote, (status, installation) in self.remote_core.items(): - if url.startswith(remote + "/"): - return status, json.dumps({"installation_id": installation}).encode() - port = int(origin.rsplit(":", 1)[1]) - digests = self.core_listening(port) - web_up = self.containers.get("web", {}).get("running") and self.web_port == port - if path == "/healthz": - return (200, b"{}") if digests is not None or web_up else (0, b"") - if path == "/console/auth": - return (200, b"{}") if web_up else (0, b"") - if digests is None: - return 0, b"" - if sha256(key) not in digests: - return 401, b"" - if path == "/core/v1/installation": - native = self.native["active"] and self.native["addr"] == port - environment = self.native["environment"] if native else self.core.get("environment", "") - match = re.search(r'^OAC_PUBLIC_URL="([^"]+)"$', environment, re.M) - public = match[1] if match else None - return 200, json.dumps({"installation_id": self.core_installation_id, "public_url": public, - "address_bindings": self.bindings}).encode() - if path == "/core/v1/sandbox/nodes": - return 200, json.dumps({"data": self.nodes}).encode() - if path == "/core/v1/sandbox/deployment": - return 200, json.dumps(dict(self.deployment, core_url=self.deployment_core_url, - installation_id=self.core_installation_id)).encode() - return 404, b"" - - def sandbox_send(self, req): - origin, _, path = req.full_url.partition("://")[2].partition("/") - port = int(origin.rsplit(":", 1)[1]) - digests = self.core_listening(port) - if digests is None: - raise ConnectionRefusedError() - if sha256(req.get_header("Authorization", "").removeprefix("Bearer ")) not in digests: - return 401, b'{"error": {"message": "Invalid Core key"}}' - if path != "core/v1/sandbox/deployment": - return 404, b"" - if req.get_method() in ("POST", "PUT"): - selection = json.loads(req.data) - self.deployment_posts.append(selection) - if selection.get("expected_generation") != self.deployment.get("generation", 0): - return 409, b'{"error":{"code":"generation_stale","message":"Deployment generation changed"}}' - if self.deployment_refusal: - return 409, json.dumps({"error": {"message": self.deployment_refusal}}).encode() - # E2B adopts the template build's size. - self.deployment = {"provider": selection["provider"], "generation": self.deployment.get("generation", 0) + 1, - "reset": None, "resources": {"allocations": 0, "pending": 0}, - "specification": {"runtime": selection.get("runtime"), "resources": selection.get("resources", {"cpus": 2, "memory_mib": 2048})}} - native = self.native["active"] and self.native["addr"] == port - environment = self.native["environment"] if native else self.core.get("environment", "") - installation = re.search(r'^OAC_INSTALLATION_ID="([^"]+)"$', environment, re.M) - return 200, json.dumps(dict(self.deployment, installation_id=installation[1] if installation else self.core_installation_id)).encode() - - -MANIFEST = { - "source_commit": "a" * 40, - "images": {name: "sha256:" + digit * 64 for name, digit in ( - ("core", "1"), ("runtime", "2"), ("database", "3"), ("web", "4"), ("ingress", "7"))}, - "image_manifest_digests": {name: "sha256:" + digit * 64 for name, digit in ( - ("core", "a"), ("runtime", "b"), ("database", "c"), ("web", "d"), ("ingress", "e"))}, - "runtime_ref": "oac-runtime@sha256:" + "b" * 64, - "microsandbox": {"runtime_sha256": "5" * 64, "firmware_sha256": "6" * 64}, -} -MODULES = ("install.py", "install_output.py", "install_display.py", "configuration.py", "config_model.py", "ingress.py", "ingress_config.py", "config.schema.json", "oac_cli.py", - "sandbox_setup.py", "node_spec.py", "distribution.py", "install.sh") - - -def write_checksums(bundle): - files = sorted(path for path in bundle.rglob("*") if path.is_file() and path.name != "SHA256SUMS") - (bundle / "SHA256SUMS").write_text("".join( - sha256(path.read_bytes()) + " " + str(path.relative_to(bundle)) + "\n" for path in files)) - - -def make_bundle(directory, manifest, commit=None): - """A synthetic distribution with this checkout's installer modules.""" - manifest = json.loads(json.dumps(manifest)) - if commit: - manifest["source_commit"] = commit - bundle = Path(directory) - (bundle / "images").mkdir(parents=True) - (bundle / "runtime").mkdir() - (bundle / "runtime/seccomp.json").write_text('{"defaultAction":"SCMP_ACT_ERRNO"}') - for name in MODULES: - (bundle / name).write_bytes(Path(__file__).with_name(name).read_bytes()) - (bundle / "standard-sizes.json").write_bytes(STANDARD_SIZES.read_bytes()) - (bundle / "node-install.pyz").write_bytes(b"synthetic verified Python bootstrap") - (bundle / "oac.pyz").write_bytes(b"synthetic oac command " + manifest["source_commit"].encode()) - manifest["artifacts"] = {} - for name in ("images/runtime.tar.gz", "native/bin/oac-node", - "native/bin/oac-microsandbox-provider", "native/microsandbox/msb", - "native/microsandbox/libkrunfw.so.5.6.1"): - manifest["artifacts"][name] = {"filename": "oac-" + manifest["source_commit"] + "-" + name.replace("/", "-"), - "sha256": "a" * 64, "size": 1} - (bundle / "manifest.json").write_text(json.dumps(manifest)) - for name in manifest["images"]: - (bundle / "images" / (name + ".tar")).write_bytes(("synthetic " + name).encode()) - for name in ("bin/oac-microsandbox-provider", - "bin/oac-node", "microsandbox/msb", "microsandbox/libkrunfw.so.5.6.1"): - path = bundle / "native" / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(b"\x7fELFsynthetic native file " + manifest["source_commit"].encode()) - write_checksums(bundle) - return bundle, manifest - - -def run_installer(install, bundle, argv): - """install.main from the bundle on a Linux amd64 host.""" - with mock.patch.object(install, "__file__", str(bundle / "install.py")), \ - mock.patch.object(install.platform, "system", return_value="Linux"), \ - mock.patch.object(install.platform, "machine", return_value="x86_64"): - return install.main([str(item) for item in argv]) diff --git a/deploy/install/native_installers.py b/deploy/install/native_installers.py deleted file mode 100644 index 5a7194efd..000000000 --- a/deploy/install/native_installers.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Retain matched catalog metadata and explicitly supplied offline installers.""" - -import json -import os -from pathlib import Path -import re -import shutil -import tempfile - - -from distribution import digest - - -def prepare(root, state, bundle): - source, target = Path(bundle) / "native-installers", Path(root) / "native-installers" - if not source.exists(): - return - if source.is_symlink() or not source.is_dir() or target.is_symlink(): - raise RuntimeError("Invalid native installer directory") - catalog_path = source / "catalog.json" - if catalog_path.is_symlink(): - raise RuntimeError("Invalid native installer catalog") - catalog = json.loads(catalog_path.read_text()) - if catalog["version"] != state["source_commit"]: - raise RuntimeError("Native installer catalog does not match Core") - expected = {"catalog.json": digest(catalog_path)} - for platform, artifact in catalog["artifacts"].items(): - if not re.fullmatch(r"(linux|darwin|windows)-(amd64|arm64)", platform): - raise RuntimeError("Invalid native installer platform") - expected[platform + ".tar.gz"] = artifact["sha256"] - # Validate both directories before filling any missing files. Offline content - # already installed stays available when repairing with a thin bundle. - for directory in (source, target): - if not directory.exists(): - continue - for path in directory.iterdir(): - if (path.name not in expected or path.is_symlink() or not path.is_file() - or digest(path) != expected[path.name]): - raise RuntimeError("Native installer files differ; preserve them and inspect the distribution") - target.mkdir(mode=0o700, exist_ok=True) - for name in expected: - incoming, installed = source / name, target / name - if not incoming.exists() or installed.exists(): - continue - descriptor, temporary = tempfile.mkstemp(prefix=".native-installer-", dir=root) - try: - with os.fdopen(descriptor, "wb") as outgoing, incoming.open("rb") as stream: - shutil.copyfileobj(stream, outgoing) - outgoing.flush() - os.fsync(outgoing.fileno()) - os.replace(temporary, installed) - finally: - if os.path.exists(temporary): - os.unlink(temporary) diff --git a/deploy/install/oac_cli.py b/deploy/install/oac_cli.py deleted file mode 100644 index 0c8923308..000000000 --- a/deploy/install/oac_cli.py +++ /dev/null @@ -1,1102 +0,0 @@ -"""oac: status, start, stop, apply, rotate-core-key and uninstall for one installation. - -The installation directory is the directory that holds the command. The bundle it -was installed from is never needed. config.json is the only file an operator edits; -apply renders generated/ from it and converges the running services on that render. -What runs is the truth: each Compose container carries the inputs digest it was -created with (label io.oac.inputs), so an interrupted apply, rotation or -rollback is finished by the next apply. -""" -import argparse -import contextlib -import datetime -import errno -import fcntl -from http.client import HTTPException -import ipaddress -import json -import os -from pathlib import Path -import re -import secrets -import shlex -import shutil -import signal -import socket -import stat -import subprocess -import sys -import tempfile -import time -import urllib.error -import urllib.request -from urllib.parse import urlsplit - -import config_model -import ingress_config -import configuration - - -SOURCE_COMMIT = None # Set by the packaged entrypoint from its build revision. -UNSUPPORTED_VERSION = ("This installation version is not supported; " - "preserve its data and reinstall into a new empty directory. Nothing was changed.") -INCOMPLETE = ("This installation did not finish installing. Rerun the installer command, which removes what is " - "left and installs again, or remove it with oac uninstall.") - - -class OacError(Exception): - pass - - -class ApplyFailed(OacError): - """apply wrote the files, but the services did not converge on them; cause says why.""" - def __init__(self, message, cause): - super().__init__(message) - self.cause = cause - - -def run(args, **kwargs): - # Never print a generated Compose file, process environment or secret value. - return subprocess.run(args, **dict({"check": True}, **kwargs)) - - -def now(): - return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - -# Private files --------------------------------------------------------------- - -def private_parent(target): - """Create the canonical installation parent and keep the default home private.""" - parent = target.parent - if parent.is_symlink() or parent.resolve() != parent: - raise OacError("The destination's parent must be canonical and not a symlink") - parent.mkdir(mode=0o700, parents=True, exist_ok=True) - if parent == Path.home() / ".oac": - os.chmod(parent, 0o700) - - -def check_private(path, what): - try: - info = os.lstat(path) - except FileNotFoundError: - raise OacError(f"{what} is missing") from None - if (not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) & 0o077 - or info.st_uid != os.geteuid() or info.st_nlink != 1): - raise OacError(f"{what} must be a regular file with mode 0600, owned by you, and not a link") - - -def check_directories(root): - """The installation, secrets/ and generated/ are real private directories of this user.""" - for path, what in ((root, str(root)), (root / "secrets", "secrets/"), (root / "generated", "generated/")): - try: - info = os.lstat(path) - except FileNotFoundError: - raise OacError(f"{what} is missing") from None - if (not stat.S_ISDIR(info.st_mode) or stat.S_IMODE(info.st_mode) & 0o077 - or info.st_uid != os.geteuid()): - raise OacError(f"{what} must be a directory with mode 0700, owned by you, and not a link") - - -def read_private(path, what): - check_private(path, what) - descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) - with os.fdopen(descriptor, "rb") as stream: - return stream.read() - - -def create_private(path, data): - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) - with os.fdopen(descriptor, "wb") as stream: - stream.write(data.encode() if isinstance(data, str) else data) - - -def write_private(path, data): - """Replace a file atomically with a 0600 copy in the same directory.""" - path = Path(path) - descriptor, temporary = tempfile.mkstemp(prefix="." + path.name + ".", dir=path.parent) - try: - with os.fdopen(descriptor, "wb") as stream: - stream.write(data.encode() if isinstance(data, str) else data) - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, path) - directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) - try: - os.fsync(directory) - finally: - os.close(directory) - finally: - if os.path.exists(temporary): - os.unlink(temporary) - - -def load_config(root): - raw = read_private(root / "config.json", "config.json") - try: - document = json.loads(raw) - except ValueError as error: - line = getattr(error, "lineno", "?") - raise OacError(f"config.json is not valid JSON (line {line})") from None - return config_model.validate(document) - - -def load_state(root): - state = json.loads(read_private(root / "state.json", "state.json")) - if (state.get("format") != 2 - or SOURCE_COMMIT is not None and state.get("source_commit") != SOURCE_COMMIT): - raise OacError(UNSUPPORTED_VERSION) - return state - - -def save_state(root, state): - write_private(root / "state.json", json.dumps(state, indent=2) + "\n") - - -def check_complete(state): - """Only the installer uses an installation before its first start has finished.""" - if state.get("complete") is not True: - raise OacError(INCOMPLETE) - - -@contextlib.contextmanager -def locked(root): - if (root / "state.json").exists(): - load_state(root) - descriptor = os.open(root / ".oac.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) - try: - try: - fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - raise OacError("Another oac command is running for this installation") from None - try: - current = os.stat(root / ".oac.lock", follow_symlinks=False) - except FileNotFoundError: - current = None - if current is None or not os.path.samestat(current, os.fstat(descriptor)): - # The holder removed the installation, lock file included, after this command opened it. - raise OacError("Another oac command is running for this installation") - if (root / "state.json").exists(): - load_state(root) - yield - finally: - os.close(descriptor) - - -# What runs --------------------------------------------------------------------- - -def compose(root, *args, **kwargs): - return run(["docker", "compose", "-f", str(root / "generated/compose.json"), *args], **kwargs) - - -INSPECT = ('{{index .Config.Labels "com.docker.compose.service"}}\t{{index .Config.Labels "' + configuration.LABEL - + '"}}\t{{.State.Status}}\t{{if .State.Health}}{{.State.Health.Status}}{{end}}') - - -def observe(state): - """{service: {running, inputs, health}} of this installation's containers.""" - result = {} - ids = run(["docker", "ps", "-aq", "--filter", f'label=com.docker.compose.project={state["project"]}', - "--filter", "label=com.docker.compose.oneoff=False"], capture_output=True, text=True).stdout.split() - if ids: - for line in run(["docker", "inspect", "--format", INSPECT, *ids], capture_output=True, text=True).stdout.splitlines(): - service, inputs, status, health = (line.split("\t") + ["", "", "", ""])[:4] - if service: - result[service] = {"running": status == "running", "inputs": inputs or None, "health": health} - return result - - -def tcp_listeners(): - """(address, port) of each listening TCP socket in this network namespace.""" - for table in ("/proc/net/tcp", "/proc/net/tcp6"): - try: - lines = Path(table).read_text().splitlines()[1:] - except OSError: - continue - for line in lines: - fields = line.split() - if fields[3] != "0A": # TCP_LISTEN - continue - raw, _, port = fields[1].partition(":") - # The kernel prints each 32-bit word of the address in host byte order, little-endian on amd64. - packed = b"".join(bytes.fromhex(raw[index:index + 8])[::-1] for index in range(0, len(raw), 8)) - yield ipaddress.ip_address(packed), int(port, 16) - - -def overlaps(first, second): - if first.version != second.version: - # Of two families, only a dual-stack IPv6 wildcard also takes IPv4 addresses. - return (first if first.version == 6 else second).is_unspecified - return first.is_unspecified or second.is_unspecified or first == second - - -def bind_error(host, port): - """The errno of binding host:port as the services do, or 0 when the bind succeeds. - - SO_REUSEADDR, which Go and Docker listeners set, lets connections in TIME_WAIT pass. - """ - try: - with socket.socket(socket.AF_INET6 if ipaddress.ip_address(host).version == 6 else socket.AF_INET) as probe: - probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - probe.bind((host, port)) - except OSError as error: - return error.errno - return 0 - - -def address_available(host): - """Whether host is an address of this machine or a wildcard, so services can bind to it.""" - return bind_error(host, 0) not in (errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT) - - -def port_free(host, port, own=()): - """Whether a listener could bind host:port now. - - own holds this installation's (address, port) listeners, which do not count. Where - one overlaps host:port, or the account may not bind a port below 1024, the kernel's - table of listening sockets decides instead of a bind. - """ - address = ipaddress.ip_address(host) - own = {(other, held) for other, held in own if held == port and overlaps(address, other)} - if not own: - error = bind_error(host, port) - if error not in (errno.EACCES, errno.EPERM): - return error != errno.EADDRINUSE - return not any(held == port and overlaps(address, other) and (other, held) not in own - for other, held in tcp_listeners()) - - -def port_in_use(listener, name, outcome=""): - """The message for a port that another program holds; name is its flag or config.json key.""" - remedy = "Free it" if listener.purpose == "HTTPS" else "Free it or choose another port" - return (f"Port {listener.port} ({name}) is already in use on {listener.host}.{outcome} {remedy}; " - f"find the process with: sudo ss -ltnp 'sport = :{listener.port}'") - - -def stale(actual, desired, will_run): - """Services that should run but don't, or run with other inputs.""" - return {name for name in will_run if name != "migrate" and ( - not actual.get(name, {}).get("running") or actual[name]["inputs"] != desired.get(name))} - - -def converge(root, state, desired, will_run, force=()): - """Bring every service in will_run to the desired inputs; Core first, then the rest. - - Compose recreates exactly the containers whose configuration (and so label) - differs. - """ - actual = observe(state) - todo = stale(actual, desired, will_run) | set(force) - if not todo: - return set() - up = ["up", "--detach", "--wait", "--wait-timeout", "300"] - if "core" in todo: - if "core" in force and not stale(actual, desired, {"core"}): - compose(root, "restart", "core") - else: - compose(root, *up, "core") - if will_run: - compose(root, *up) - for name in sorted(set(force) & will_run - {"core"}): - if not stale(actual, desired, {name}): - compose(root, "restart", name) - return todo - - -def core_error_line(root): - """Core's single startup failure line. Core logs no environment values.""" - try: - output = compose(root, "logs", "--no-log-prefix", "--tail", "200", "core", - capture_output=True, text=True).stdout - except (subprocess.CalledProcessError, OSError): - return None - lines = [line.strip() for line in output.splitlines() if "oac-core startup failed" in line] - return lines[-1] if lines else None - - -def describe(error): - """A failure message without command paths, output or environment.""" - if isinstance(error, subprocess.CalledProcessError): - # A command's own path shows as its name; other paths and options are left out. - words = [Path(word).name if index == 0 else word for index, word in enumerate(error.cmd) - if index == 0 or not word.startswith(("/", "-"))][:3] - return f"`{' '.join(words)}` failed" - if isinstance(error, KeyboardInterrupt): - return "it was interrupted" - return str(error) - - -# HTTP ------------------------------------------------------------------------ - -class _NoRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, *args, **kwargs): - return None - - -def http(url, headers=None, timeout=5): - """(status, body). Credentials go only to this URL: no redirects, no ambient proxy.""" - opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect()) - try: - with opener.open(urllib.request.Request(url, headers=headers or {}), timeout=timeout) as response: - return response.status, response.read(1024 * 1024) - except urllib.error.HTTPError as error: - return error.code, b"" - except (urllib.error.URLError, OSError, ValueError): - return 0, b"" - - -def wait_status(url, headers=None, expect=200, attempts=60): - for attempt in range(attempts): - status, body = http(url, headers) - if status == expect: - return body - if attempt + 1 < attempts: - time.sleep(1) - return None - - -def bearer(key): - return {"Authorization": "Bearer " + key} - - -def core_base(config): - return configuration.service_origin(config, "core") - - -def health(root, config, expected): - """config needs public_url and ports; it may be the view of what was last written.""" - if "core" in expected: - base = core_base(config) - if wait_status(base + "/healthz") is None: - raise OacError("Core did not become healthy") - if wait_status(base + "/core/v1/installation", bearer(configuration.read_core_key(root)), attempts=10) is None: - raise OacError("Core did not accept the Core key at /core/v1/installation") - if "web" in expected: - url = configuration.service_origin(config, "web") - path = "/healthz" if ingress_config.enabled(config) else "/console/auth" - if wait_status(url + path, {"Host": urlsplit(config["public_url"] or url).netloc}) is None: - raise OacError("Web sign-in is unavailable") - - -# Files ------------------------------------------------------------------------- - -def check_fixed(config, state): - """state.json records the ingress images at installation; it wins over config.json.""" - if ingress_config.enabled(config) != ("ingress" in state["images"]): - raise OacError("ingress is fixed after installation; install separately to change it") - - -def check_secrets(root, state): - reasons = {"credential.key": "Stored credentials can only be read with the original key.", - "database.password": "PostgreSQL keeps the password it was initialized with."} - for name, reason in reasons.items(): - data = read_private(root / "secrets" / name, "secrets/" + name) - if configuration.sha256(data) != state["secrets_sha256"][name]: - raise OacError(f"secrets/{name} changed since installation. {reason} " - "Restore the original file; nothing was applied.") - check_private(root / "secrets/core.key", "secrets/core.key") - configuration.read_core_key(root) - - -def read_generated(root, names): - result = {} - for name in names: - path = root / "generated" / name - result[name] = path.read_bytes() if path.is_file() and not path.is_symlink() else None - return result - - -def comparable(name, data): - """File content as compared for edits; the snapshot's applied_at is not an edit.""" - if data is not None and name == "settings.json": - try: - document = json.loads(data) - document.pop("applied_at", None) - return json.dumps(document, sort_keys=True).encode() - except (ValueError, AttributeError): - return data - return data.encode() if isinstance(data, str) else data - - -def edited_files(state, disk, rendered): - """Generated files that match neither a digest oac wrote nor the current render. - - state.json keeps the last few digests written for each file, so a run - interrupted between writing files and state.json is not taken for an edit. A - missing file is simply written again. - """ - edited = [] - for name, digests in sorted((state.get("generated") or {}).items()): - data = disk.get(name) - if data is None or configuration.sha256(data) in digests: - continue - if name in rendered.files and comparable(name, data) == comparable(name, rendered.files[name]): - continue - edited.append(name) - return edited - - -def record_digests(state, files): - generated = dict(state.get("generated") or {}) - for name, text in files.items(): - digest = configuration.sha256(text) - generated[name] = ([item for item in generated.get(name, []) if item != digest] + [digest])[-3:] - return dict(state, generated=generated) - - -def disk_view(disk): - """The settings last written, by key, and the snapshot's stamp. - - Sensitive values are not in the snapshot; the one sensitive setting is read back - from the runtime-history.json that carries it. - """ - try: - document = json.loads(disk.get("settings.json") or b"") - values = {item["key"]: item["value"] for item in document["settings"]} - except (ValueError, KeyError, TypeError): - return None, None - if "core.runtime_history.headers" in values: - history = disk.get("runtime-history.json") - try: - values["core.runtime_history.headers"] = json.loads(history).get("headers") if history else None - except (ValueError, AttributeError): - values["core.runtime_history.headers"] = None - return values, document.get("applied_at") - - -def render_now(root, config, state, candidate=None): - """The render of config.json, keeping the written stamp unless something changed.""" - names = set((state.get("generated") or {})) - disk = read_generated(root, names | {"settings.json", "runtime-history.json"}) - previous, stamp = disk_view(disk) - rendered = configuration.render(root, config, state, stamp or now(), candidate) - disk = read_generated(root, names | set(rendered.files) | {"runtime-history.json"}) - if any(comparable(name, disk.get(name)) != comparable(name, text) for name, text in rendered.files.items()) \ - or any(disk.get(name) is not None for name in names - set(rendered.files)): - rendered = configuration.render(root, config, state, now(), candidate) - return rendered, disk, previous - - -# Apply ----------------------------------------------------------------------- - -def old_public_url(root, config, previous, disk, actual): - """The public URL things are bound to: Core's own answer, else the written core.env.""" - old_config = applied_view(previous) if previous else config - base = core_base(old_config) - if actual.get("core", {}).get("running"): - status, body = http(base + "/core/v1/installation", bearer(configuration.read_core_key(root))) - if status == 200: - return json.loads(body).get("public_url"), base, True - try: - written = configuration.read_environment((disk.get("core.env") or b"").decode()) - except RuntimeError: - written = {} - return written.get("OAC_PUBLIC_URL"), base, False - - -def confirm_public_url(root, config, old, base, core_answered, args, interactive, out): - """Changing the public URL strands what is bound to the old one; list it and confirm. - - old is None when it can't be read; then the change always needs confirmation. - """ - new = configuration.local_public_url(config) - if old == new: - return - counts, nodes = None, [] - if core_answered: - key = configuration.read_core_key(root) - status, body = http(base + "/core/v1/installation", bearer(key)) - if status == 200: - counts = json.loads(body).get("address_bindings") or {} - # The node list only names them; the count comes from Core's own bindings. - status, body = http(base + "/core/v1/sandbox/nodes", bearer(key)) - nodes = [node for node in (json.loads(body).get("data", []) if status == 200 else []) - if node.get("core_url") == old] - if old is None: - out(f"The public URL in use can't be read, so the change to {new} needs confirmation.") - else: - out(f"The public URL changes from {old} to {new}.") - if counts is not None: - bound = max(0, counts.get("nodes", 0) - counts.get("nodes_on_other_address", 0)) - out(f'Bound to the current address: {bound} node(s), {counts.get("hosted_sandboxes", 0)} hosted sandbox(es), ' - f'{counts.get("self_hosted_executors", 0)} self-hosted executor credential(s).') - for node in nodes: - out(f' node {node.get("name")}: {"online" if node.get("online") else "offline"}') - if not (bound or counts.get("hosted_sandboxes") or counts.get("self_hosted_executors")): - return - elif old is not None: - out("Core is not running, so the nodes, sandboxes and executors bound to the address can't be counted.") - out("After the change, nodes on the old address get no new sandboxes; remove them in Web and add them again.\n" - "Existing sandboxes and executors keep working only while the old address still reaches this Core, so\n" - "keep the old route until they are replaced. Self-hosted executors must restart with the new remote_url.") - if args.dry_run: - out("Applying this change needs confirmation.") - elif args.confirm_public_url_change is not None: - if args.confirm_public_url_change != new: - raise OacError(f"--confirm-public-url-change must equal the new public URL {new}; nothing was applied") - elif interactive: - if input("Type the new public URL to continue: ").strip() != new: - raise OacError("The public URL change was not confirmed; nothing was applied") - else: - raise OacError(f"Confirm with --confirm-public-url-change {new}; nothing was applied") - - -def own_listeners(config, previous, disk, actual): - """(address, port) of this installation's listeners: those of the settings last written, and - those of the gateway, which domain setup widens before public_url changes, while it runs as written.""" - applied = applied_view(previous) - own = {(ipaddress.ip_address(listener.host), listener.port) for listener in configuration.listeners(applied)} - gateway = actual.get("gateway", {}) - if gateway.get("running") and gateway.get("inputs") == configuration.rendered_inputs(disk).get("gateway"): - own |= ingress_config.written_listeners(disk.get("compose.json")) - return own - - -def taken_listeners(config, own, candidate=None): - """The listeners of config, and of a domain candidate, that another program holds.""" - return [listener for listener in configuration.listeners(config, candidate) - if (ipaddress.ip_address(listener.host), listener.port) not in own - and not port_free(listener.host, listener.port, own)] - - -def check_new_listeners(config, previous, disk, actual, candidate=None): - """Each listener this change adds must be free; this installation's own listeners do not count.""" - if previous is None: - return - applied = applied_view(previous) - if config["host"] != applied["host"] and not address_available(config["host"]): - raise OacError(f"{config['host']} (host) is not an address of this machine; use one of its addresses. " - "Nothing was applied.") - taken = taken_listeners(config, own_listeners(config, previous, disk, actual), candidate) - if taken: - raise OacError(port_in_use(taken[0], taken[0].setting, " Nothing was applied.")) - - -def finish_apply(root, config, state, gateway_document, will_run, candidate=None, keep_unfinished=False): - """With a candidate, domain setup verifies it and records its own status. keep_unfinished - leaves an unfinished domain setup recorded, so its status reports the interruption.""" - managed = ingress_config.enabled(config) and "gateway" in will_run - if managed: - import ingress - # Container input labels cannot prove which configuration Caddy loaded. - ingress_config.reload(root, gateway_document) - if config["public_url"] and not candidate: - ingress.verify(config["public_url"], state["installation_id"]) - health(root, config, will_run) - if managed and not candidate and not (keep_unfinished and ingress.unfinished(root)): - ingress.save(root, {"state": "ready" if config["public_url"] else "unconfigured", - "public_url": config["public_url"], "target_url": config["public_url"], "message": None}) - - -def apply(root, dry_run=False, discard_edits=False, confirm_public_url_change=None, - start=False, interactive=None, out=print, retry=None): - root = Path(root) - args = argparse.Namespace(dry_run=dry_run, confirm_public_url_change=confirm_public_url_change) - interactive = sys.stdin.isatty() if interactive is None else interactive - with locked(root): - check_complete(load_state(root)) - return _apply(root, args, discard_edits, start, interactive, out, retry=retry) - - -def _apply(root, args, discard_edits, start, interactive, out, rollback=True, retry=None, candidate=None): - """candidate: see configuration.render.""" - retry = retry or f"run {root / 'oac'} apply again" - check_directories(root) - config = load_config(root) - state = load_state(root) - check_fixed(config, state) - check_secrets(root, state) - rendered, disk, previous = render_now(root, config, state, candidate) - edited = edited_files(state, disk, rendered) - if edited and not discard_edits: - raise OacError("\n".join(f"generated/{name} was edited by hand." for name in edited) - + "\nPut the change in config.json and run oac apply --discard-edits, which keeps the" - " edited copy as generated/.edited-