diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 769bda9b4..889a15cf6 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -22,9 +22,9 @@ const LOCKOUT_SECONDS = 30; let state; const hex = (c) => c.repeat(64); -/** The Runtime release this fixture's distribution manifest describes. */ -const release = { source_commit: "c0ffee".padEnd(40, "0"), image_id: `sha256:${hex("1")}`, image_manifest_digest: `sha256:${hex("2")}`, microsandbox_ref: `oac-runtime@sha256:${hex("3")}`, runtime_sha256: hex("4"), firmware_sha256: hex("5") }; -const manifest = { platform: "linux/amd64", source_commit: release.source_commit, images: { runtime: release.image_id }, image_manifest_digests: { runtime: release.image_manifest_digest }, runtime_ref: release.microsandbox_ref, microsandbox: { runtime_sha256: release.runtime_sha256, firmware_sha256: release.firmware_sha256 }, artifacts: {} }; +/** The fixture distribution includes each provider's native identities. */ +const manifest = { platform: "linux/amd64", source_commit: "c0ffee".padEnd(40, "0"), images: { runtime: `sha256:${hex("1")}` }, image_manifest_digests: { runtime: `sha256:${hex("2")}` }, runtime_ref: `oac-runtime@sha256:${hex("3")}`, microsandbox: { runtime_sha256: hex("4"), firmware_sha256: hex("5") }, artifacts: {} }; +const release = { source_commit: manifest.source_commit, artifacts: { image_id: manifest.images.runtime, image_manifest_digest: manifest.image_manifest_digests.runtime } }; /** * config.json's public_url. "public": an HTTPS address, so applications get an API base URL; diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 6c9bb2c4d..165dccda5 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -236,6 +236,12 @@ test("preselects microsandbox and asks once before switching to Docker", async ( await docker.click(); await expect(sizeStep).toBeVisible(); await expect(confirm).toHaveCount(0); + await page.getByRole("button", { name: /^Standard/ }).click(); + await page.getByRole("button", { name: "Advanced settings", exact: true }).click(); + await expect(page.getByLabel("Image ID", { exact: true })).toHaveValue(`sha256:${"1".repeat(64)}`); + await expect(page.getByLabel("Image manifest digest", { exact: true })).toHaveValue(`sha256:${"2".repeat(64)}`); + await expect(page.getByLabel("Firmware SHA-256", { exact: true })).toHaveCount(0); + await expect(page.getByLabel("microsandbox reference", { exact: true })).toHaveCount(0); }); test("saves E2B without opening Add node, as it has no machines", async ({ page, request }) => { @@ -300,8 +306,7 @@ test("shows the retained E2B build while a replacement key is checked", async ({ }); test("edits only the saved backend, preserving a custom size and Runtime", async ({ page, request }) => { - const runtime = { source_commit: "0".repeat(40), image_id: `sha256:${"a".repeat(64)}`, image_manifest_digest: `sha256:${"b".repeat(64)}`, - microsandbox_ref: `oac-runtime@sha256:${"b".repeat(64)}`, runtime_sha256: "c".repeat(64), firmware_sha256: "d".repeat(64) }; + const runtime = { source_commit: "0".repeat(40), artifacts: { image_id: `sha256:${"a".repeat(64)}`, image_manifest_digest: `sha256:${"b".repeat(64)}` } }; const current = { resources: { cpus: 7, memory_mib: 8192 }, runtime }; let deployment = { configuration: {}, metadata: {}, credential_configured: false, installation_id: "94be54a1-138c-4f30-bc87-b13686272dbe", provider: "docker", core_url: "https://core.example", reset: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: { ready: 0, preparing: 0, failed: 0, update_required: 0, unknown: 0 } }, owner_epoch: 1, generation: 1, mode: "nodes", resources: { allocations: 0, pending: 0 }, specification: current, specification_digest: "e".repeat(64), diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 4f7761a43..a9d43e540 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -15,7 +15,7 @@ import { formatBytes } from "../../lib/format"; import { installationQuery } from "../../lib/installation"; import type { ParseKeys } from "i18next"; import { sandboxConfigurationRejection, sandboxProviderLabel } from "../../lib/sandbox-labels"; -import { defaultSandboxResources, distributionRuntime, isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS, savedSpecification, validSandboxResources } from "./deployment-specification"; +import { defaultSandboxResources, distributionRuntime, isRuntimeRelease, isRuntimeReleaseField, runtimeReleaseFields, savedSpecification, validSandboxResources } from "./deployment-specification"; import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update"; import { sandboxAdmin } from "./sandbox-queries"; import "./sandbox-wizard.css"; @@ -81,7 +81,7 @@ function presets(provider: SandboxProvider): Record | return { small: scale(0.5), standard, large: scale(2) }; } -const releaseLabels: Record> = { +const releaseLabels: Record> = { source_commit: "Source commit", image_id: "Image ID", image_manifest_digest: "Image manifest digest", @@ -195,11 +195,11 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab // A release the administrator entered comes first, then the saved one of the same backend, // then the one this console distributes (the release its node installer verifies). - const matched = useQuery({ queryKey: ["sandbox-runtime-release"], queryFn: ({ signal }) => distributionRuntime(signal).catch(() => null), staleTime: Infinity, retry: false }); + const needsRuntime = policy !== null && Object.keys(policy.artifacts).length > 0; + const matched = useQuery({ queryKey: ["sandbox-runtime-release", provider], queryFn: ({ signal }) => distributionRuntime(provider!, signal).catch(() => null), enabled: needsRuntime, staleTime: Infinity, retry: false }); const release: Partial = Object.keys(runtime).length ? runtime : saved?.runtime ?? matched.data ?? {}; - const needsRuntime = policy?.runtime ?? false; - const runtimeReady = !needsRuntime || isRuntimeRelease(release); + const runtimeReady = !needsRuntime || (provider !== null && isRuntimeRelease(provider, release)); // Initial setup requires a key; an update may retain the committed key. const keyReady = e2bKeyReady(Boolean(editing), replacementRequested, apiKey); const connectionChanged = Boolean(editing && (apiURL.trim() !== (current?.e2bAPIURL || E2B_PRESETS.official.apiURL) || domain.trim() !== (current?.e2bDomain || E2B_PRESETS.official.domain))); @@ -437,11 +437,11 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
{t("Runtime release")}{t("Filled in from this console's distribution when it serves one. Otherwise copy these from the distribution manifest that matches your nodes; image configuration IDs and manifest digests are different values.")} {fieldError("runtime") ? : null} - {RUNTIME_RELEASE_FIELDS.map((field) => { - const value = release[field] ?? ""; + {runtimeReleaseFields(provider!).map((field) => { + const value = (field === "source_commit" ? release.source_commit : release.artifacts?.[field]) ?? ""; return ( - - { setRuntime({ ...release, [field]: event.target.value.trim() }); setFieldRejection(null); }} /> + + { setRuntime(field === "source_commit" ? { ...release, source_commit: event.target.value.trim() } : { ...release, artifacts: { ...release.artifacts, [field]: event.target.value.trim() } }); setFieldRejection(null); }} /> ); })} diff --git a/apps/web/src/features/sandbox/deployment-specification.test.ts b/apps/web/src/features/sandbox/deployment-specification.test.ts index 89f333bc8..340c81431 100644 --- a/apps/web/src/features/sandbox/deployment-specification.test.ts +++ b/apps/web/src/features/sandbox/deployment-specification.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { defaultSandboxResources, distributionRuntime, isRuntimeReleaseField, sandboxesThatFit, savedSpecification, validSandboxResources } from "./deployment-specification"; -import { deploymentContract, type SandboxProvider, type SandboxSpecification } from "@oac/agents-client"; +import { defaultSandboxResources, distributionRuntime, isRuntimeRelease, isRuntimeReleaseField, sandboxesThatFit, savedSpecification, validSandboxResources } from "./deployment-specification"; +import { deploymentContract, type SandboxProvider, type SandboxRuntimeRelease, type SandboxSpecification } from "@oac/agents-client"; + +import deploymentContractFixture from "../../../../../services/core/internal/sandbox/testdata/deployment-contract.json"; const manifest = { platform: "linux/amd64", source_commit: "0".repeat(40), images: { runtime: `sha256:${"a".repeat(64)}` }, image_manifest_digests: { runtime: `sha256:${"b".repeat(64)}` }, runtime_ref: `oac-runtime@sha256:${"b".repeat(64)}`, @@ -8,18 +10,29 @@ const manifest = { platform: "linux/amd64", source_commit: "0".repeat(40), image afterEach(() => vi.unstubAllGlobals()); describe("deployment resources and Runtime", () => { - it("maps the exact six identities from one matched distribution", async () => { - const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify(manifest))); + it.each(deploymentContractFixture.filter((entry) => entry.provider !== "e2b" && (entry.valid || /release|artifact|newline|crlf/.test(entry.name))))("checks the shared release contract: $name", (entry) => { + expect(isRuntimeRelease(entry.provider as SandboxProvider, entry.specification.runtime as unknown as Partial)).toBe(entry.valid); + }); + it("maps only each provider's declared identities from one matched distribution", async () => { + const fetcher = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify(manifest)))); vi.stubGlobal("fetch", fetcher); - expect(await distributionRuntime(new AbortController().signal)).toEqual({ source_commit: manifest.source_commit, - image_id: manifest.images.runtime, image_manifest_digest: manifest.image_manifest_digests.runtime, - microsandbox_ref: manifest.runtime_ref, runtime_sha256: manifest.microsandbox.runtime_sha256, firmware_sha256: manifest.microsandbox.firmware_sha256 }); - expect(fetcher.mock.calls.map((call) => call[0])).toEqual(["/node-install/manifest.json"]); + expect(await distributionRuntime("docker", new AbortController().signal)).toEqual({ source_commit: manifest.source_commit, + artifacts: { image_id: manifest.images.runtime, image_manifest_digest: manifest.image_manifest_digests.runtime } }); + expect(await distributionRuntime("microsandbox", new AbortController().signal)).toEqual({ source_commit: manifest.source_commit, + artifacts: { microsandbox_ref: manifest.runtime_ref, runtime_sha256: manifest.microsandbox.runtime_sha256, firmware_sha256: manifest.microsandbox.firmware_sha256 } }); + await expect(distributionRuntime("e2b", new AbortController().signal)).rejects.toThrow(); + expect(fetcher.mock.calls.every((call) => call[0] === "/node-install/manifest.json")).toBe(true); + }); + it("requires only the selected provider's manifest identities", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ platform: manifest.platform, source_commit: manifest.source_commit, images: manifest.images, image_manifest_digests: manifest.image_manifest_digests })))); + const release = await distributionRuntime("docker", new AbortController().signal); + expect(isRuntimeRelease("docker", release)).toBe(true); + expect(isRuntimeRelease("microsandbox", release)).toBe(false); + expect(isRuntimeRelease("docker", { ...release, artifacts: { ...release.artifacts, extra: "x" } })).toBe(false); }); it("preserves saved resources and Runtime only for the same provider", () => { const current: SandboxSpecification = { resources: { cpus: 7, memory_mib: 8192 }, runtime: { source_commit: manifest.source_commit, - image_id: manifest.images.runtime, image_manifest_digest: manifest.image_manifest_digests.runtime, - microsandbox_ref: manifest.runtime_ref, runtime_sha256: manifest.microsandbox.runtime_sha256, firmware_sha256: manifest.microsandbox.firmware_sha256 } }; + artifacts: { image_id: manifest.images.runtime, image_manifest_digest: manifest.image_manifest_digests.runtime } } }; expect(savedSpecification("docker", "docker", current)).toEqual(current); expect(savedSpecification("docker", "docker", current)).not.toBe(current); expect(savedSpecification("microsandbox", "docker", current)).toBeNull(); @@ -49,20 +62,20 @@ describe("deployment resources and Runtime", () => { const digest = "b".repeat(64); const runtime_ref = `oac-runtime@sha256:${digest}`; vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...manifest, runtime_ref })))); - expect((await distributionRuntime(new AbortController().signal)).microsandbox_ref).toBe(runtime_ref); - expect(isRuntimeReleaseField("microsandbox_ref", runtime_ref)).toBe(true); - for (const runtime_ref of [`custom-runtime@sha256:${digest}`, `oac-runtime:${digest}`, `oac-runtime@sha256:${"b".repeat(63)}`, `oac-runtime@sha256:${"B".repeat(64)}`, `@sha256:${digest}`]) { + expect((await distributionRuntime("microsandbox", new AbortController().signal)).artifacts.microsandbox_ref).toBe(runtime_ref); + expect(isRuntimeReleaseField("microsandbox", "microsandbox_ref", runtime_ref)).toBe(true); + for (const runtime_ref of [`custom-runtime@sha256:${digest}`, `oac-runtime:${digest}`, `oac-runtime@sha256:${"b".repeat(63)}`, `oac-runtime@sha256:${"B".repeat(64)}`, `@sha256:${digest}`, `oac-runtime@sha256:${digest}\n`]) { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...manifest, runtime_ref })))); - await expect(distributionRuntime(new AbortController().signal)).rejects.toThrow(); - expect(isRuntimeReleaseField("microsandbox_ref", runtime_ref)).toBe(false); + await expect(distributionRuntime("microsandbox", new AbortController().signal)).rejects.toThrow(); + expect(isRuntimeReleaseField("microsandbox", "microsandbox_ref", runtime_ref)).toBe(false); } }); it("rejects unavailable, mutable or incomplete release identities", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 404 }))); - await expect(distributionRuntime(new AbortController().signal)).rejects.toThrow(); - for (const invalid of [{ ...manifest, platform: "linux/arm64" }, { ...manifest, source_commit: "main" }, { ...manifest, runtime_ref: "oac-runtime:latest" }, { ...manifest, microsandbox: {} }]) { + await expect(distributionRuntime("microsandbox", new AbortController().signal)).rejects.toThrow(); + for (const invalid of [{ ...manifest, platform: "linux/arm64" }, { ...manifest, source_commit: "main" }, { ...manifest, source_commit: manifest.source_commit + "\n" }, { ...manifest, runtime_ref: "oac-runtime:latest" }, { ...manifest, microsandbox: {} }]) { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(invalid)))); - await expect(distributionRuntime(new AbortController().signal)).rejects.toThrow(); + await expect(distributionRuntime("microsandbox", new AbortController().signal)).rejects.toThrow(); } }); }); diff --git a/apps/web/src/features/sandbox/deployment-specification.ts b/apps/web/src/features/sandbox/deployment-specification.ts index af0d3393c..93882602f 100644 --- a/apps/web/src/features/sandbox/deployment-specification.ts +++ b/apps/web/src/features/sandbox/deployment-specification.ts @@ -1,14 +1,5 @@ import { deploymentContract, type SandboxDeployment, type SandboxE2BTemplateBuild, type SandboxProvider, type SandboxResources, type SandboxRuntimeRelease, type SandboxSpecification } from "@oac/agents-client"; -interface Manifest { - platform?: string; - source_commit?: string; - images?: { runtime?: string }; - image_manifest_digests?: { runtime?: string }; - runtime_ref?: string; - microsandbox?: { runtime_sha256?: string; firmware_sha256?: string }; -} - /** The size the Provider declares for setup to propose; null when its configuration selects the size. */ export function defaultSandboxResources(provider: SandboxProvider): SandboxResources | null { const size: SandboxResources | null = deploymentContract.providers[provider].default_resources; @@ -24,16 +15,23 @@ export function validSandboxResources(provider: SandboxProvider, resources: Sand }); } -const releasePatterns = Object.fromEntries(deploymentContract.runtime.map(({ name, pattern }) => [name, new RegExp(`^(?:${pattern})$`)])) as Record; - -export const RUNTIME_RELEASE_FIELDS = deploymentContract.runtime.map(({ name }) => name); +export function runtimeReleaseFields(provider: SandboxProvider): string[] { + return ["source_commit", ...Object.keys(deploymentContract.providers[provider].artifacts)]; +} -export function isRuntimeReleaseField(field: keyof SandboxRuntimeRelease, value: string): boolean { - return releasePatterns[field].test(value); +export function isRuntimeReleaseField(provider: SandboxProvider, field: string, value: string): boolean { + const rules: Record = deploymentContract.providers[provider].artifacts; + const pattern = field === "source_commit" ? deploymentContract.source_commit_pattern : rules[field]?.pattern; + return pattern !== undefined && new RegExp(`^(?:${pattern})(?![\\s\\S])`).test(value); } -export function isRuntimeRelease(value: Partial): value is SandboxRuntimeRelease { - return RUNTIME_RELEASE_FIELDS.every((field) => typeof value[field] === "string" && releasePatterns[field].test(value[field])); +export function isRuntimeRelease(provider: SandboxProvider, value: Partial): value is SandboxRuntimeRelease { + const fields = Object.keys(deploymentContract.providers[provider].artifacts); + return fields.length > 0 && typeof value.source_commit === "string" && isRuntimeReleaseField(provider, "source_commit", value.source_commit) + && value.artifacts !== null && typeof value.artifacts === "object" && !Array.isArray(value.artifacts) + && Object.keys(value).every((key) => key === "source_commit" || key === "artifacts") + && Object.keys(value.artifacts).length === fields.length + && fields.every((field) => typeof value.artifacts?.[field] === "string" && isRuntimeReleaseField(provider, field, value.artifacts[field])); } export function savedSpecification(provider: SandboxProvider, savedProvider?: SandboxProvider | "", specification?: SandboxSpecification): SandboxSpecification | null { @@ -68,12 +66,14 @@ export function sandboxesThatFit(host: { cpus: number | null; memoryBytes: numbe } /** The paired console serves one matched distribution; Core persists approval. */ -export async function distributionRuntime(signal: AbortSignal): Promise { +export async function distributionRuntime(provider: SandboxProvider, signal: AbortSignal): Promise { const response = await fetch("/node-install/manifest.json", { signal, credentials: "include", redirect: "error" }); if (!response.ok) throw new Error("distribution unavailable"); - const manifest = await response.json() as Manifest; - const release = { source_commit: manifest.source_commit, image_id: manifest.images?.runtime, image_manifest_digest: manifest.image_manifest_digests?.runtime, - microsandbox_ref: manifest.runtime_ref, runtime_sha256: manifest.microsandbox?.runtime_sha256, firmware_sha256: manifest.microsandbox?.firmware_sha256 }; - if (manifest.platform !== "linux/amd64" || !isRuntimeRelease(release)) throw new Error("invalid distribution"); - return release; + const manifest: unknown = await response.json(); + const at = (path: readonly string[]): unknown => path.reduce((value, key) => + value !== null && typeof value === "object" && !Array.isArray(value) && Object.hasOwn(value, key) ? (value as Record)[key] : undefined, manifest); + const artifacts = Object.fromEntries(Object.entries(deploymentContract.providers[provider].artifacts).map(([name, rule]) => [name, at(rule.manifest_path)])); + const release = { source_commit: at(["source_commit"]), artifacts }; + if (at(["platform"]) !== "linux/amd64" || !isRuntimeRelease(provider, release as Partial)) throw new Error("invalid distribution"); + return release as SandboxRuntimeRelease; } diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index cfb5e94a0..d7435b8ed 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -1806,24 +1806,14 @@ definitions: type: object sandbox.RuntimeRelease: properties: - firmware_sha256: - type: string - image_id: - type: string - image_manifest_digest: - type: string - microsandbox_ref: - type: string - runtime_sha256: - type: string + artifacts: + additionalProperties: + type: string + type: object source_commit: type: string required: - - firmware_sha256 - - image_id - - image_manifest_digest - - microsandbox_ref - - runtime_sha256 + - artifacts - source_commit type: object sessions.AdminAssetCounts: diff --git a/contracts/agents-api/node-generation-protocol.md b/contracts/agents-api/node-generation-protocol.md index 2d6969653..572bb5ed9 100644 --- a/contracts/agents-api/node-generation-protocol.md +++ b/contracts/agents-api/node-generation-protocol.md @@ -8,6 +8,8 @@ A sandbox node runs the Docker or microsandbox Provider on its host and connects Every frame is one JSON text message whose `version` equals `node.ProtocolVersion`; both peers reject any other version, and there is no fallback decoder. Member names are exact and unique: unknown members, case aliases, duplicates and unexpected nulls are rejected. Control frames (`hello`, `welcome`, `heartbeat`, `heartbeat_ack`, `retention`, `retention_ack`) are at most 32 KiB; `request` and `response` frames at most 1 MiB. An invalid frame closes the connection. +The connection carries generation numbers and opaque specification digests; it does not carry the [Runtime release object](./sandbox-deployment.md#runtime-release). HTTP configuration supplies the specification. Local provider configurations and preparation, collection, drop and lease-identity journals bind that exact specification and digest. Loading a configuration rejects an incompatible release shape before publishing a provider, changing a journal or deleting files; it never adopts or rewrites another installation release's state. See the [installation version policy](../../docs/getting-started/operations.md#installation-version-policy). + ## Connection 1. The node dials `/api/v1/sandbox-node/connect?node_id=` on its stored Core origin (`wss` for `https`) with its node credential as a Bearer header. Core answers 401 to a rejected credential, which the node treats as permanent; any other failure, including a 403 from a proxy, is retried with bounded backoff. Core refuses a second connection for a node identity while one is opening, live or closing, with 409. diff --git a/contracts/agents-api/runtime.openapi.yaml b/contracts/agents-api/runtime.openapi.yaml index 2c27bacc4..713b07526 100644 --- a/contracts/agents-api/runtime.openapi.yaml +++ b/contracts/agents-api/runtime.openapi.yaml @@ -138,24 +138,14 @@ definitions: type: object sandbox.RuntimeRelease: properties: - firmware_sha256: - type: string - image_id: - type: string - image_manifest_digest: - type: string - microsandbox_ref: - type: string - runtime_sha256: - type: string + artifacts: + additionalProperties: + type: string + type: object source_commit: type: string required: - - firmware_sha256 - - image_id - - image_manifest_digest - - microsandbox_ref - - runtime_sha256 + - artifacts - source_commit type: object v1.APIError: diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 4c404e6f3..6db2023c2 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -58,18 +58,21 @@ microsandbox configures the CPUs, memory, a managed root disk and a separate own ### Runtime release -Docker and microsandbox use every field of one verified distribution: +`runtime` contains exactly `source_commit` and `artifacts`. `source_commit` is a lowercase 40-character commit SHA identifying the distribution. `artifacts` is a string map containing exactly the identities declared by the selected adapter; missing, extra, empty, null or malformed entries are rejected. E2B omits `runtime` and selects its immutable build through `configuration.template`. -| Field | Identity | -| --- | --- | -| `source_commit` | Lowercase 40-character commit SHA | -| `image_id` | Docker image configuration ID: `sha256:` and 64 lowercase hex characters | -| `image_manifest_digest` | OCI image manifest digest, in the same form | -| `microsandbox_ref` | `oac-runtime@sha256:` and 64 lowercase hex characters | -| `runtime_sha256` | SHA-256 of the native microsandbox runtime binary | -| `firmware_sha256` | SHA-256 of the matching firmware | +| Adapter | Artifact key | Identity | +| --- | --- | --- | +| Docker | `image_id` | Docker image configuration ID: `sha256:` and 64 lowercase hex characters | +| Docker | `image_manifest_digest` | OCI image manifest digest, in the same form | +| microsandbox | `microsandbox_ref` | `oac-runtime@sha256:` and 64 lowercase hex characters | +| microsandbox | `runtime_sha256` | SHA-256 of the native microsandbox runtime binary, as 64 lowercase hex characters | +| microsandbox | `firmware_sha256` | SHA-256 of the matching firmware, as 64 lowercase hex characters | + +The adapter's deployment policy declares each key, its validation pattern and its selector in the distribution manifest. The generated installer and client contracts use that declaration to copy and validate the identities from the matching distribution. An image configuration ID and an OCI manifest digest identify different objects and never substitute for each other. The node installer verifies the saved release against its payload before registration and keeps the exact local image identity it imports. + +The specification digest is SHA-256 over canonical JSON: `provider`, then `resources`, then `runtime` when present; the Runtime fields are `source_commit`, then `artifacts`, whose keys are sorted lexicographically. An artifact map's insertion order does not change the digest. -Copy these identities from the matching distribution manifest. An image configuration ID and an OCI manifest digest identify different objects and never substitute for each other. The node installer verifies the saved release against its payload before registration and keeps the exact local image identity it imports. +Stored specifications and their node pins follow the [installation version policy](../../docs/getting-started/operations.md#installation-version-policy). Schema changes reject an incompatible Runtime release in either the current selection or any retained generation, and rollback rejects artifact specifications that the target schema cannot interpret. Rejection preserves specifications, digests, nodes and generation history; it never rewrites these identities. ### E2B configuration @@ -190,7 +193,7 @@ Some fields keep one name across providers but differ in meaning, or do not appl | Field | E2B | Docker | microsandbox | | --- | --- | --- | --- | | Deployment `specification.resources` | `cpus` and `memory_mib`, equal to the ready template build's and taken from it when omitted; no disk fields | `cpus` and `memory_mib`; no disk quota | `cpus`, `memory_mib`, `root_disk_mib` and `environment_disk_mib` | -| Deployment `specification.runtime` | Absent; `configuration.template` selects the build | The full [release](#runtime-release); nodes match `image_id` or `image_manifest_digest` | The full [release](#runtime-release); nodes match `microsandbox_ref`, `runtime_sha256` and `firmware_sha256` | +| Deployment `specification.runtime` | Absent; `configuration.template` selects the build | The Docker [release artifacts](#runtime-release); nodes match `image_id` or `image_manifest_digest` | The microsandbox [release artifacts](#runtime-release); nodes match `microsandbox_ref`, `runtime_sha256` and `firmware_sha256` | | Deployment `metadata.template_build` | The build as Core read it when the selection was saved | Absent: `metadata` is empty | Absent: `metadata` is empty | | Deployment `suspension` | `null`; Core does not suspend E2B sandboxes | `null` | `{idle_seconds, retention_seconds}` | | Deployment `resources.allocations`, `resources.pending` | Core's unreleased E2B sandboxes, and hosted Environments waiting for one | Totals across all nodes | Totals across all nodes | diff --git a/contracts/agents-api/zh/node-generation-protocol.md b/contracts/agents-api/zh/node-generation-protocol.md index 5c0fb651f..daf9244bf 100644 --- a/contracts/agents-api/zh/node-generation-protocol.md +++ b/contracts/agents-api/zh/node-generation-protocol.md @@ -1,7 +1,7 @@ --- title: "沙箱节点协议" source: contracts/agents-api/node-generation-protocol.md -source_hash: 4c74de8583bc8f0b85b91a338357899a1bcc03d8b2bd7063e6c71dd0430e717b +source_hash: fb06cedf8bb6172b5864589d6a2148c8f75252520183481f3ceaca7bb97f3701 --- 沙箱节点在其主机上运行 Docker 或 microsandbox Provider,并通过一个 WebSocket 与 Core 相连。Core 通过该连接发送 Provider 操作;节点针对本地 Provider 执行这些操作,并报告就绪状态、主机测量值及其持有的部署代次。Core 始终是唯一的生命周期所有者:节点绝不重试变更操作或调度工作。帧和校验器位于 [`services/core/internal/sandbox/node`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/sandbox/node)(`wire.go`、`generation_wire.go`);节点用于注册和读取配置的 HTTP 路由位于[机器连接 API](machine-api.md#node-routes)。 @@ -10,6 +10,8 @@ source_hash: 4c74de8583bc8f0b85b91a338357899a1bcc03d8b2bd7063e6c71dd0430e717b 每个帧都是一个 JSON 文本消息,其 `version` 等于 `node.ProtocolVersion`;两端都会拒绝任何其他版本,并且没有回退解码器。成员名必须精确且唯一:未知成员、大小写别名、重复项和意外的空值都会被拒绝。控制帧(`hello`、`welcome`、`heartbeat`、`heartbeat_ack`、`retention`、`retention_ack`)最多为 32 KiB;`request` 和 `response` 帧最多为 1 MiB。无效帧会关闭连接。 +连接传递代次编号和不透明的规范摘要,不传递 [Runtime 发行版对象](./sandbox-deployment.md#runtime-release)。HTTP 配置提供规范。本地 Provider 配置及准备、回收、丢弃和租约身份日志绑定该精确规范及其摘要。加载配置时,在发布 Provider、修改日志或删除文件之前拒绝不兼容的发行版形状;绝不接管或改写另一安装版本的状态。参见[安装版本策略](../../../docs/zh/getting-started/operations.md#installation-version-policy)。 + ## 连接 {#connection} 1. 节点在其存储的 Core 源地址上发起对 `/api/v1/sandbox-node/connect?node_id=` 的连接(对于 `https` 使用 `wss`),并以 Bearer 请求头发送节点凭据。Core 对被拒绝的凭据返回 401,节点将其视为永久性拒绝;其他任何失败(包括代理返回的 403)都会使用有界退避进行重试。当某个节点身份已有一个连接正在建立、存活或关闭时,Core 会以 409 拒绝第二个连接。 diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index 3bacba333..5f4b66a8f 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: 72427ef3933ee3c9f7bde17f506c72945b33df9320508fc1a47d08cb89920922 +source_hash: e5a3c062ddffa395cb5832df3cbd2b30b30f04dfa0917911b375331cff070b76 --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择 Sandbox Provider、每个沙箱的资源以及不可变的 Runtime 发行版。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源或 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。 @@ -60,18 +60,21 @@ microsandbox 会配置 CPU、内存、托管根磁盘,以及位于 `/environme ### Runtime 发行版 {#runtime-release} -Docker 和 microsandbox 使用一个经过验证发行包中的每个字段: +`runtime` 恰好包含 `source_commit` 和 `artifacts`。`source_commit` 是标识发行包的 40 字符小写提交 SHA。`artifacts` 是字符串映射,恰好包含所选适配器声明的标识;缺失、多余、空字符串、null 或格式错误的条目均被拒绝。E2B 省略 `runtime`,通过 `configuration.template` 选择其不可变构建。 -| 字段 | 标识 | -| --- | --- | -| `source_commit` | 由 40 个字符组成的小写提交 SHA | -| `image_id` | Docker 镜像配置 ID:`sha256:` 加 64 个小写十六进制字符 | -| `image_manifest_digest` | OCI 镜像清单摘要,格式相同 | -| `microsandbox_ref` | `oac-runtime@sha256:` 加 64 个小写十六进制字符 | -| `runtime_sha256` | 原生 microsandbox Runtime 二进制文件的 SHA-256 | -| `firmware_sha256` | 匹配固件的 SHA-256 | +| 适配器 | 制品键 | 标识 | +| --- | --- | --- | +| Docker | `image_id` | Docker 镜像配置 ID:`sha256:` 加 64 个小写十六进制字符 | +| Docker | `image_manifest_digest` | OCI 镜像清单摘要,格式相同 | +| microsandbox | `microsandbox_ref` | `oac-runtime@sha256:` 加 64 个小写十六进制字符 | +| microsandbox | `runtime_sha256` | 原生 microsandbox Runtime 二进制文件的 SHA-256,表示为 64 个小写十六进制字符 | +| microsandbox | `firmware_sha256` | 匹配固件的 SHA-256,表示为 64 个小写十六进制字符 | + +适配器的部署策略声明每个键、其验证模式及其在发行清单中的选择路径。生成的安装器和客户端契约通过该声明从匹配的发行包复制并验证标识。镜像配置 ID 和 OCI 清单摘要标识不同的对象,绝不能相互替代。节点安装程序会在注册前根据载荷验证已保存的发行版,并保留其导入的精确本地镜像标识。 + +规范摘要是规范 JSON 的 SHA-256:依次为 `provider`、`resources` 和存在时的 `runtime`;Runtime 字段依次为 `source_commit`、`artifacts`,其中制品键按字典顺序排序。制品映射的插入顺序不会改变摘要。 -请从匹配的发行清单中复制这些标识。镜像配置 ID 和 OCI 清单摘要标识不同的对象,绝不能相互替代。节点安装程序会在注册前根据载荷验证已保存的发行版,并保留其导入的精确本地镜像标识。 +存储的规范及其节点固定标识遵循[安装版本策略](../../../docs/zh/getting-started/operations.md#installation-version-policy)。Schema 变更会拒绝当前选择或任何保留代次中不兼容的 Runtime 发行版,回滚会拒绝目标 schema 无法解释的制品规范。拒绝操作保留规范、摘要、节点和代次历史,绝不重写这些标识。 ### E2B 配置 {#e2b-configuration} @@ -192,7 +195,7 @@ POST 会在持久保存候选配置之前对其进行验证,并且不会创建 | 字段 | E2B | Docker | microsandbox | | --- | --- | --- | --- | | 部署 `specification.resources` | `cpus` 和 `memory_mib`,必须等于就绪模板构建中的值;省略时取自该构建;无磁盘字段 | `cpus` 和 `memory_mib`;无磁盘配额 | `cpus`、`memory_mib`、`root_disk_mib` 和 `environment_disk_mib` | -| 部署 `specification.runtime` | 不存在;`configuration.template` 用于选择构建 | 完整的[发行版](#runtime-release);节点必须与 `image_id` 或 `image_manifest_digest` 匹配 | 完整的[发行版](#runtime-release);节点必须与 `microsandbox_ref`、`runtime_sha256` 和 `firmware_sha256` 匹配 | +| 部署 `specification.runtime` | 不存在;`configuration.template` 用于选择构建 | Docker [发行制品](#runtime-release);节点必须与 `image_id` 或 `image_manifest_digest` 匹配 | microsandbox [发行制品](#runtime-release);节点必须与 `microsandbox_ref`、`runtime_sha256` 和 `firmware_sha256` 匹配 | | 部署 `metadata.template_build` | Core 保存选择时读取到的构建 | 不存在:`metadata` 为空 | 不存在:`metadata` 为空 | | 部署 `suspension` | `null`;Core 不暂停 E2B 沙箱 | `null` | `{idle_seconds, retention_seconds}` | | 部署 `resources.allocations`、`resources.pending` | Core 中尚未释放的 E2B 沙箱,以及正在等待沙箱的托管 Environment | 所有节点的总数 | 所有节点的总数 | diff --git a/deploy/node/node_generations.py b/deploy/node/node_generations.py index 70e61fe0d..55e743af0 100644 --- a/deploy/node/node_generations.py +++ b/deploy/node/node_generations.py @@ -167,7 +167,7 @@ def paths(configuration): or any(not isinstance(digest, str) or not re.fullmatch(r"[a-f0-9]{64}", digest) for digest in saved["sha256"].values())): raise installer.InstallError("Original Runtime artifact ownership differs") if value["provider"] == "microsandbox" and any( - saved["sha256"][name] != base["specification"]["runtime"][field] + saved["sha256"][name] != base["specification"]["runtime"]["artifacts"][field] for name, field in zip(installer.MICRO[1:], ("runtime_sha256", "firmware_sha256"))): raise installer.InstallError("Original Runtime artifact hashes differ from its specification") for path in candidates: @@ -230,7 +230,7 @@ def validate_preparation_plan(root, plan, base, installer): policy = Path(plan["native"]["seccomp_file"]) if policy not in (root / "runtime/seccomp.json", root / "releases" / source / "runtime/seccomp.json"): raise installer.InstallError("Preparation policy is outside its immutable release") - if plan["native"]["image"] not in (runtime["image_id"], runtime["image_manifest_digest"]): + if plan["native"]["image"] not in (runtime["artifacts"]["image_id"], runtime["artifacts"]["image_manifest_digest"]): raise installer.InstallError("Preparation image differs from the specification") installer.no_links(policy) else: @@ -252,7 +252,7 @@ def verify_plan_final(plan, final, installer): if plan["provider"] == "docker": image = final.get("native", {}).get("image") runtime = plan["specification"]["runtime"] - if image not in (runtime["image_id"], runtime["image_manifest_digest"]): + if image not in (runtime["artifacts"]["image_id"], runtime["artifacts"]["image_manifest_digest"]): raise installer.InstallError("Final Docker image differs from the preparation specification") expected["native"]["image"] = image if expected != final: @@ -275,9 +275,9 @@ def owned_root(args, installer): def generation_home(root, configuration, base, installer): runtime = configuration["specification"]["runtime"] previous = base["specification"]["runtime"] - if (runtime["runtime_sha256"], runtime["firmware_sha256"]) == (previous["runtime_sha256"], previous["firmware_sha256"]): + if (runtime["artifacts"]["runtime_sha256"], runtime["artifacts"]["firmware_sha256"]) == (previous["artifacts"]["runtime_sha256"], previous["artifacts"]["firmware_sha256"]): return Path(base["native"]["runtime_home"]) - material = ":".join((configuration["installation_id"], runtime["runtime_sha256"], runtime["firmware_sha256"])) + material = ":".join((configuration["installation_id"], runtime["artifacts"]["runtime_sha256"], runtime["artifacts"]["firmware_sha256"])) home = Path.home() / ".oac/m" / hashlib.sha256(material.encode()).hexdigest()[:12] if len(os.fsencode(home)) > 48: raise installer.InstallError("HOME is too long for versioned microsandbox socket paths") @@ -296,12 +296,12 @@ def image_available(value, installer): for key in ("helper_path", "runtime_path", "firmware_path"): if not installer.existing_file(Path(micro[key])): return False - if (installer.file_digest(Path(micro["runtime_path"])) != runtime["runtime_sha256"] - or installer.file_digest(Path(micro["firmware_path"])) != runtime["firmware_sha256"]): + if (installer.file_digest(Path(micro["runtime_path"])) != runtime["artifacts"]["runtime_sha256"] + or installer.file_digest(Path(micro["firmware_path"])) != runtime["artifacts"]["firmware_sha256"]): return False env = dict(os.environ, MSB_BACKEND="local", MSB_HOME=micro["runtime_home"], MSB_PATH=micro["runtime_path"], MSB_LIBKRUNFW_PATH=micro["firmware_path"]) - image = json.loads(installer.checked([micro["runtime_path"], "image", "inspect", runtime["microsandbox_ref"], "--format", "json"], "Cannot inspect pinned image", env=env)) - return image.get("digest") == runtime["microsandbox_ref"].split("@", 1)[1] and image.get("architecture") == "amd64" and image.get("os") == "linux" + image = json.loads(installer.checked([micro["runtime_path"], "image", "inspect", runtime["artifacts"]["microsandbox_ref"], "--format", "json"], "Cannot inspect pinned image", env=env)) + return image.get("digest") == runtime["artifacts"]["microsandbox_ref"].split("@", 1)[1] and image.get("architecture") == "amd64" and image.get("os") == "linux" except (installer.InstallError, OSError, ValueError): return False @@ -400,7 +400,8 @@ def prepare(args, installer): if args.provider == "microsandbox": args.runtime_home = Path(value["native"]["runtime_home"]) if value else generation_home(root, args.configuration, base, installer) if value is None: - value = installer.provider_config(root / "releases" / runtime["source_commit"], args, runtime["image_id"]) + runtime_image = runtime["artifacts"]["image_id"] if args.provider == "docker" else None + value = installer.provider_config(root / "releases" / runtime["source_commit"], args, runtime_image) if preparation is None: preparation = dict(marker_identity(args), import_started=False, configuration=copy.deepcopy(value)) atomic_json(directory / (str(args.generation) + ".preparing"), preparation) @@ -417,7 +418,7 @@ def prepare(args, installer): if finalized: installer.node_spec.verify_provider(value, args.configuration, runtime_image) elif args.provider == "docker": - if runtime_image not in (runtime["image_id"], runtime["image_manifest_digest"]): + if runtime_image not in (runtime["artifacts"]["image_id"], runtime["artifacts"]["image_manifest_digest"]): raise installer.InstallError("Resolved Docker image is outside the authorized specification") value = copy.deepcopy(value) value["native"]["image"] = runtime_image @@ -558,7 +559,7 @@ def collect(args, installer): def collect_image(args, value, others, installer): if value["provider"] == "microsandbox": - micro, image = value["native"], value["specification"]["runtime"]["microsandbox_ref"] + micro, image = value["native"], value["specification"]["runtime"]["artifacts"]["microsandbox_ref"] shared = [item for item in others if item["native"]["runtime_home"] == micro["runtime_home"]] home = Path(micro["runtime_home"]) installer.no_links(home) @@ -566,10 +567,10 @@ def collect_image(args, value, others, installer): raise installer.InstallError("Microsandbox store ownership differs") runtime_path = Path(micro["runtime_path"]) installer.no_links(runtime_path) - if not installer.existing_file(runtime_path) or installer.file_digest(runtime_path) != value["specification"]["runtime"]["runtime_sha256"]: + if not installer.existing_file(runtime_path) or installer.file_digest(runtime_path) != value["specification"]["runtime"]["artifacts"]["runtime_sha256"]: raise installer.InstallError("Cannot verify retained microsandbox executable") env = dict(os.environ, MSB_BACKEND="local", MSB_HOME=micro["runtime_home"], MSB_PATH=micro["runtime_path"], MSB_LIBKRUNFW_PATH=micro["firmware_path"]) - if not any(item["specification"]["runtime"]["microsandbox_ref"] == image for item in shared): + if not any(item["specification"]["runtime"]["artifacts"]["microsandbox_ref"] == image for item in shared): # A failed inspect/remove is not proof of absence. A successful full # inventory must contain only understood immutable references. raw = installer.checked([micro["runtime_path"], "image", "list", "--quiet"], "Cannot verify microsandbox image inventory", env=env) diff --git a/deploy/node/node_install.py b/deploy/node/node_install.py index 68d08fce1..24b8a431c 100644 --- a/deploy/node/node_install.py +++ b/deploy/node/node_install.py @@ -343,6 +343,9 @@ def unit_name(installation_id): def configure_node(root, args, token): """Read the Core specification and check the host under the installation lock.""" + stored = private_json(root / "provider.json") + if stored is not None: + node_spec.canonical_spec(stored["provider"], stored["specification"]) identity_file = root / "state/node/identity.json" retained = json.loads(identity_file.read_text()) if existing_file(identity_file) else None args.configuration = node_spec.fetch(args, token, retained, open_request, allow_enrollment=not (root / "registered.json").exists()) diff --git a/deploy/node/node_spec.py b/deploy/node/node_spec.py index ba28d64c4..80fbd2800 100644 --- a/deploy/node/node_spec.py +++ b/deploy/node/node_spec.py @@ -16,27 +16,30 @@ class SpecificationError(Exception): "Nodes page and run it on this host.") -def release(manifest): - return {"source_commit": manifest["source_commit"], - "image_id": manifest["images"]["runtime"], - "image_manifest_digest": manifest["image_manifest_digests"]["runtime"], - "microsandbox_ref": manifest["runtime_ref"], - "runtime_sha256": manifest["microsandbox"]["runtime_sha256"], - "firmware_sha256": manifest["microsandbox"]["firmware_sha256"]} +def release(provider, manifest): + rules = _CONTRACT["providers"][provider] + artifacts = {} + for name, rule in rules["artifacts"].items(): + value = manifest + for key in rule["manifest_path"]: + value = value[key] + artifacts[name] = value + runtime = {"source_commit": manifest["source_commit"], "artifacts": artifacts} + return canonical_release(provider, runtime) # BEGIN GENERATED DEPLOYMENT CONTRACT # Generated from sandbox/deployment_contract.go; do not edit. -_CONTRACT = json.loads("{\"resources\":[{\"name\":\"cpus\",\"min\":1,\"max\":255,\"omit_zero\":false},{\"name\":\"memory_mib\",\"min\":512,\"max\":1048576,\"omit_zero\":false},{\"name\":\"root_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true},{\"name\":\"environment_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true}],\"runtime\":[{\"name\":\"source_commit\",\"pattern\":\"[0-9a-f]{40}\"},{\"name\":\"image_id\",\"pattern\":\"sha256:[0-9a-f]{64}\"},{\"name\":\"image_manifest_digest\",\"pattern\":\"sha256:[0-9a-f]{64}\"},{\"name\":\"microsandbox_ref\",\"pattern\":\"oac-runtime@sha256:[0-9a-f]{64}\"},{\"name\":\"runtime_sha256\",\"pattern\":\"[0-9a-f]{64}\"},{\"name\":\"firmware_sha256\",\"pattern\":\"[0-9a-f]{64}\"}],\"providers\":{\"docker\":{\"mode\":\"nodes\",\"disk\":false,\"runtime\":true,\"default_resources\":{\"cpus\":2,\"memory_mib\":2048}},\"e2b\":{\"mode\":\"direct\",\"disk\":false,\"runtime\":false,\"default_resources\":null},\"microsandbox\":{\"mode\":\"nodes\",\"disk\":true,\"runtime\":true,\"default_resources\":{\"cpus\":2,\"memory_mib\":4096,\"root_disk_mib\":8192,\"environment_disk_mib\":8192}}},\"minimum_disk\":1024}") +_CONTRACT = json.loads("{\"resources\":[{\"name\":\"cpus\",\"min\":1,\"max\":255,\"omit_zero\":false},{\"name\":\"memory_mib\",\"min\":512,\"max\":1048576,\"omit_zero\":false},{\"name\":\"root_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true},{\"name\":\"environment_disk_mib\",\"min\":0,\"max\":4294967295,\"omit_zero\":true}],\"source_commit_pattern\":\"[0-9a-f]{40}\",\"providers\":{\"docker\":{\"mode\":\"nodes\",\"disk\":false,\"artifacts\":{\"image_id\":{\"pattern\":\"sha256:[0-9a-f]{64}\",\"manifest_path\":[\"images\",\"runtime\"]},\"image_manifest_digest\":{\"pattern\":\"sha256:[0-9a-f]{64}\",\"manifest_path\":[\"image_manifest_digests\",\"runtime\"]}},\"default_resources\":{\"cpus\":2,\"memory_mib\":2048}},\"e2b\":{\"mode\":\"direct\",\"disk\":false,\"artifacts\":{},\"default_resources\":null},\"microsandbox\":{\"mode\":\"nodes\",\"disk\":true,\"artifacts\":{\"firmware_sha256\":{\"pattern\":\"[0-9a-f]{64}\",\"manifest_path\":[\"microsandbox\",\"firmware_sha256\"]},\"microsandbox_ref\":{\"pattern\":\"oac-runtime@sha256:[0-9a-f]{64}\",\"manifest_path\":[\"runtime_ref\"]},\"runtime_sha256\":{\"pattern\":\"[0-9a-f]{64}\",\"manifest_path\":[\"microsandbox\",\"runtime_sha256\"]}},\"default_resources\":{\"cpus\":2,\"memory_mib\":4096,\"root_disk_mib\":8192,\"environment_disk_mib\":8192}}},\"minimum_disk\":1024}") # END GENERATED DEPLOYMENT CONTRACT -def canonical_spec(provider, specification, validate=True): +def canonical_spec(provider, specification): rules = _CONTRACT["providers"][provider] - if validate and (not isinstance(specification, dict) or set(specification) != ({"resources", "runtime"} if rules["runtime"] else {"resources"})): + if (not isinstance(specification, dict) or set(specification) != ({"resources", "runtime"} if rules["artifacts"] else {"resources"})): raise ValueError("Invalid specification fields") resources = specification["resources"] - if validate and (not isinstance(resources, dict) or set(resources) - {rule["name"] for rule in _CONTRACT["resources"]}): + if (not isinstance(resources, dict) or set(resources) - {rule["name"] for rule in _CONTRACT["resources"]}): raise ValueError("Invalid resource fields") ordered = {} for rule in _CONTRACT["resources"]: @@ -45,27 +48,39 @@ def canonical_spec(provider, specification, validate=True): minimum, maximum = rule["min"], rule["max"] if rule["omit_zero"]: minimum, maximum = (_CONTRACT["minimum_disk"], maximum) if rules["disk"] else (0, 0) - if validate and (type(value) is not int or not minimum <= value <= maximum): + if (type(value) is not int or not minimum <= value <= maximum): raise ValueError("Invalid resource value") if value or not rule["omit_zero"]: ordered[name] = value result = {"provider": provider, "resources": ordered} - if rules["runtime"]: - runtime = specification["runtime"] - if validate and (not isinstance(runtime, dict) or set(runtime) != {rule["name"] for rule in _CONTRACT["runtime"]}): - raise ValueError("Invalid release fields") - ordered_runtime = {} - for rule in _CONTRACT["runtime"]: - value = runtime[rule["name"]] - if validate and (not isinstance(value, str) or not re.fullmatch(rule["pattern"], value)): - raise ValueError("Invalid release identity") - ordered_runtime[rule["name"]] = value - result["runtime"] = ordered_runtime + if rules["artifacts"]: + result["runtime"] = canonical_release(provider, specification["runtime"]) return result +def canonical_release(provider, runtime): + rules = _CONTRACT["providers"][provider] + if not rules["artifacts"]: + raise ValueError("Provider does not accept a Runtime release") + if (not isinstance(runtime, dict) or set(runtime) != {"source_commit", "artifacts"}): + raise ValueError("Invalid release fields") + source = runtime["source_commit"] + if (not isinstance(source, str) or not re.fullmatch(_CONTRACT["source_commit_pattern"], source)): + raise ValueError("Invalid release source commit") + artifacts = runtime["artifacts"] + if (not isinstance(artifacts, dict) or set(artifacts) != set(rules["artifacts"])): + raise ValueError("Invalid release artifact fields") + ordered_artifacts = {} + for name in sorted(rules["artifacts"]): + value = artifacts[name] + if (not isinstance(value, str) or not re.fullmatch(rules["artifacts"][name]["pattern"], value)): + raise ValueError("Invalid release artifact identity") + ordered_artifacts[name] = value + return {"source_commit": source, "artifacts": ordered_artifacts} + + def digest(provider, specification): - raw = json.dumps(canonical_spec(provider, specification, validate=False), separators=(",", ":"), ensure_ascii=False).encode() + raw = json.dumps(canonical_spec(provider, specification), separators=(",", ":"), ensure_ascii=False).encode() return hashlib.sha256(raw).hexdigest() @@ -81,7 +96,6 @@ def validate(data, args): or type(data["max_active"]) is not int or type(data["max_retained"]) is not int or not 1 <= data["max_active"] <= data["max_retained"] <= 1000000): raise ValueError() - canonical_spec(provider, spec) if data["specification_digest"] != digest(provider, spec): raise ValueError() except (KeyError, ValueError, TypeError, AttributeError): @@ -147,7 +161,7 @@ def fetch(args, token, retained, open_request, allow_enrollment=False, generatio def verify_release(configuration, manifest): - if configuration["specification"]["runtime"] != release(manifest): + if configuration["specification"]["runtime"] != release(configuration["provider"], manifest): raise SpecificationError("Core Runtime release differs from this distribution; use the matched installation artifacts") diff --git a/deploy/node/test_node_generations.py b/deploy/node/test_node_generations.py index c83ff6f0d..c3bcc51f0 100644 --- a/deploy/node/test_node_generations.py +++ b/deploy/node/test_node_generations.py @@ -1,4 +1,5 @@ """Collection retains bytes until local helper ownership settles.""" +import copy import fcntl import os import json @@ -22,7 +23,7 @@ def setUp(self): self.directory = self.root / "state/node/generations" self.directory.mkdir(parents=True, mode=0o700) self.value = {"installation_id": "test-installation", "generation": 1, "provider": "docker", "native": {"image": "sha256:" + "a" * 64}, - "specification": {"resources": {"cpus": 1, "memory_mib": 1024}, "runtime": {"source_commit": "b" * 40, "image_id": "sha256:" + "a" * 64, "image_manifest_digest": "sha256:" + "c" * 64, "microsandbox_ref": "oac-runtime@sha256:" + "d" * 64, "runtime_sha256": "e" * 64, "firmware_sha256": "f" * 64}}} + "specification": {"resources": {"cpus": 1, "memory_mib": 1024}, "runtime": {"source_commit": "b" * 40, "artifacts": {"image_id": "sha256:" + "a" * 64, "image_manifest_digest": "sha256:" + "c" * 64}}}} self.args = SimpleNamespace(installation_id="test-installation", generation=1, specification_digest=node_spec.digest("docker", self.value["specification"])) self.release = self.root / "releases" / ("b" * 40) self.value["native"]["seccomp_file"] = str(self.release / "runtime/seccomp.json") @@ -40,6 +41,31 @@ def initialize_lease(self): node_generations.marker_identity(self.args), initialize=True): pass + def test_old_release_shape_rejects_before_preparation_or_collection_mutates_state(self): + old = copy.deepcopy(self.value) + release = old["specification"]["runtime"] + release.update(release.pop("artifacts")) + release.update(microsandbox_ref="oac-runtime@sha256:" + "d" * 64, + runtime_sha256="e" * 64, firmware_sha256="f" * 64) + for location in ("provider", "generation", "preparation"): + with self.subTest(location=location): + node_generations.atomic_json(self.root / "provider.json", self.value) + path = self.root / "provider.json" if location == "provider" else self.directory / ("1.json" if location == "generation" else "1.preparing") + content = old if location != "preparation" else dict( + node_generations.marker_identity(self.args), import_started=True, configuration=old) + node_generations.atomic_json(path, content) + before = {str(file.relative_to(self.root)): file.read_bytes() for file in self.root.rglob("*") if file.is_file()} + # The install lock is allowed to exist, but no generation journal, + # retained configuration or artifact may be created or changed. + for action in (node_generations.prepare, node_generations.collect): + with self.assertRaisesRegex(ValueError, "Invalid release fields"): + action(self.args, installer) + after = {str(file.relative_to(self.root)): file.read_bytes() for file in self.root.rglob("*") if file.is_file() and file.name != "install.lock"} + self.assertEqual(after, {name: raw for name, raw in before.items() if name != "install.lock"}) + installer.checked.assert_not_called() + if location != "provider": + path.unlink() + def test_busy_helper_refuses_all_mutations_then_same_inode_collects(self): lease = self.directory / "1.lease" descriptor = os.open(lease, os.O_CREAT | os.O_RDWR, 0o600) @@ -166,14 +192,18 @@ def test_never_imported_generation_can_collect_missing_executable(self): def micro_fixture(self): self.value["provider"] = "microsandbox" + self.value["specification"]["resources"].update(root_disk_mib=8192, environment_disk_mib=8192) + self.value["specification"]["runtime"]["artifacts"] = { + "microsandbox_ref": "oac-runtime@sha256:" + "d" * 64, + "runtime_sha256": "e" * 64, "firmware_sha256": "f" * 64} home = self.root / "micro-store" home.mkdir(mode=0o700) node_generations.atomic_json(home / "oac-installation.json", {"installation_id": self.args.installation_id}) runtime = self.release / "msb" runtime.write_bytes(b"verified native executable") runtime.chmod(0o700) - image = self.value["specification"]["runtime"]["microsandbox_ref"] - self.value["specification"]["runtime"]["runtime_sha256"] = hashlib.sha256(runtime.read_bytes()).hexdigest() + image = self.value["specification"]["runtime"]["artifacts"]["microsandbox_ref"] + self.value["specification"]["runtime"]["artifacts"]["runtime_sha256"] = hashlib.sha256(runtime.read_bytes()).hexdigest() self.value["native"] = {"helper_path": str(self.release / "helper"), "runtime_home": str(home), "runtime_path": str(runtime), "firmware_path": str(self.release / "firmware")} self.args.specification_digest = node_spec.digest("microsandbox", self.value["specification"]) node_generations.atomic_json(self.root / "provider.json", self.value) diff --git a/deploy/node/test_node_install.py b/deploy/node/test_node_install.py index 203db53bf..0a7aa235f 100644 --- a/deploy/node/test_node_install.py +++ b/deploy/node/test_node_install.py @@ -84,7 +84,7 @@ def configuration_response(self, request, **kwargs): resources = {"cpus": 3, "memory_mib": 6144} if self.args.provider == "microsandbox": resources.update(root_disk_mib=10240, environment_disk_mib=12288) - spec = {"resources": resources, "runtime": node_spec.release(self.manifest)} + spec = {"resources": resources, "runtime": node_spec.release(self.args.provider, self.manifest)} configuration = {"installation_id": self.args.installation_id, "provider": self.args.provider, "core_url": self.args.core_url, "generation": 1, "specification": spec, "max_active": 2, "max_retained": 8, "specification_digest": node_spec.digest(self.args.provider, spec)} @@ -198,7 +198,7 @@ def artifact_response(request, **_kwargs): self.install() self.assertEqual((self.root / installer.provider_assets.artifacts("docker", ("node",))[0]).read_bytes(), payloads[installer.provider_assets.artifacts("docker", ("node",))[0]]) config = json.loads((self.root / "provider.json").read_text()) - self.assertEqual(config["specification"]["runtime"], node_spec.release(self.manifest)) + self.assertEqual(config["specification"]["runtime"], node_spec.release(self.args.provider, self.manifest)) self.assertEqual(json.loads((self.root / "registered.json").read_text())["source_commit"], old_source) self.assertIn("releases/" + old_source + "/runtime/seccomp.json", fetched) if provider == "microsandbox": @@ -471,6 +471,22 @@ def test_retained_provider_image_cannot_bypass_verified_selection(self): self.install() self.assertFalse(any('register' in call or 'enable' in call for call, _ in self.calls)) + def test_old_provider_release_is_rejected_before_setup_mutation(self): + self.install() + path = self.root / "provider.json" + stored = installer.private_json(path) + release = stored["specification"]["runtime"] + release.update(release.pop("artifacts")) + release.update(microsandbox_ref=self.manifest["runtime_ref"], **self.manifest["microsandbox"]) + installer.node_generations.atomic_json(path, stored) + before = {str(file.relative_to(self.root)): file.read_bytes() for file in self.root.rglob("*") if file.is_file()} + self.calls.clear() + with self.assertRaisesRegex(ValueError, "Invalid release fields"): + installer.configure_node(self.root, self.args, "once") + after = {str(file.relative_to(self.root)): file.read_bytes() for file in self.root.rglob("*") if file.is_file()} + self.assertEqual(after, before) + self.assertEqual(self.calls, []) + def test_wrong_loaded_runtime_cannot_register_or_write_provider_config(self): for observed in ('sha256:' + 'f' * 64 + ' linux/amd64', self.manifest['images']['runtime'] + ' linux/arm64'): self.invalid_image = observed diff --git a/deploy/node/test_node_spec.py b/deploy/node/test_node_spec.py index ef6c7458f..eec79d357 100644 --- a/deploy/node/test_node_spec.py +++ b/deploy/node/test_node_spec.py @@ -15,10 +15,8 @@ class SpecificationTests(unittest.TestCase): def setUp(self): self.args = argparse.Namespace(core_url="https://core.example", installation_id="94be54a1-138c-4f30-bc87-b13686272dbe", provider=None) self.spec = {"resources": {"cpus": 2, "memory_mib": 4096}, "runtime": { - "source_commit": "a" * 40, "image_id": "sha256:" + "b" * 64, - "image_manifest_digest": "sha256:" + "c" * 64, - "microsandbox_ref": "oac-runtime@sha256:" + "d" * 64, - "runtime_sha256": "e" * 64, "firmware_sha256": "f" * 64}} + "source_commit": "a" * 40, "artifacts": {"image_id": "sha256:" + "b" * 64, + "image_manifest_digest": "sha256:" + "c" * 64}}} self.data = {"installation_id": self.args.installation_id, "provider": "docker", "generation": 3, "specification": self.spec, "specification_digest": node_spec.digest("docker", self.spec), "core_url": self.args.core_url, "max_active": 2, "max_retained": 8} @@ -104,9 +102,32 @@ def test_capacity_requires_approved_bounded_integers(self): result = node_spec.fetch(self.args, "", self.retained, mock.Mock(return_value=self.response())) self.assertEqual((result["max_active"], result["max_retained"]), (3, 9)) + def test_release_selects_only_declared_provider_artifacts(self): + manifest = {"source_commit": "a" * 40, + "images": {"runtime": "sha256:" + "b" * 64}, + "image_manifest_digests": {"runtime": "sha256:" + "c" * 64}, + "runtime_ref": "oac-runtime@sha256:" + "d" * 64, + "microsandbox": {"runtime_sha256": "e" * 64, "firmware_sha256": "f" * 64}} + self.assertEqual(node_spec.release("docker", manifest), self.spec["runtime"]) + micro = node_spec.release("microsandbox", manifest) + self.assertEqual(micro, {"source_commit": "a" * 40, "artifacts": { + "microsandbox_ref": manifest["runtime_ref"], **manifest["microsandbox"]}}) + del manifest["images"] + del manifest["image_manifest_digests"] + self.assertEqual(node_spec.release("microsandbox", manifest), micro) + with self.assertRaises(KeyError): + node_spec.release("docker", manifest) + for field in manifest["microsandbox"]: + for invalid in (None, "", "A" * 64, "e" * 64 + "\n"): + changed = copy.deepcopy(manifest) + changed["microsandbox"][field] = invalid + with self.subTest(field=field, invalid=invalid), self.assertRaises(ValueError): + node_spec.release("microsandbox", changed) + def test_digest_is_independent_of_response_object_key_order(self): reordered = copy.deepcopy(self.spec) reordered["runtime"] = dict(reversed(list(reordered["runtime"].items()))) + reordered["runtime"]["artifacts"] = dict(reversed(list(reordered["runtime"]["artifacts"].items()))) reordered["resources"] = dict(reversed(list(reordered["resources"].items()))) self.assertEqual(node_spec.digest("docker", reordered), node_spec.digest("docker", self.spec)) diff --git a/docs/configuration.md b/docs/configuration.md index 607b80ab0..4a5f5318d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -127,7 +127,7 @@ The node installer writes Docker’s host settings into the `native` object of t | Field | Installer value | Meaning | | --- | --- | --- | | `host` | `unix:///var/run/docker.sock` | Explicit Docker Engine socket | -| `image` | The sandbox image’s local ID after loading | The release’s `image_id` or `image_manifest_digest`. The host’s image store decides which digest names the loaded image, so the value is node-local; the adapter accepts only these two | +| `image` | The sandbox image’s local ID after loading | The release’s `artifacts.image_id` or `artifacts.image_manifest_digest`. The host’s image store decides which digest names the loaded image, so the value is node-local; the adapter accepts only these two | | `network` | `oac-node-` | Sandbox container network | | `seccomp_file` | `/runtime/seccomp.json` | Matched distribution’s seccomp profile | diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index f64ce2fab..253c7dffd 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -100,7 +100,7 @@ Hosted and self-hosted Environments use the same Runtime preparation; a provider ## Register the provider kind -`sandbox/providers/registry.go` is the only registration table. Each entry binds the adapter's specification and resource validators, its `sandbox.ConfigurationAdapter`, the deployment mode (`nodes` or `direct`), its `sandbox.DeploymentPolicy` (disk limits, the Runtime input and the default size setup proposes), the operation declaration and a node-local (`BuildLocal`) or direct (`BuildDirect`) constructor. `providers.Build` and `providers.BuildDirect` construct adapters without allocating compute. There is no init-time registration or plugin loading. +`sandbox/providers/registry.go` is the only registration table. Each entry binds the adapter's specification and resource validators, its `sandbox.ConfigurationAdapter`, the deployment mode (`nodes` or `direct`), its `sandbox.DeploymentPolicy` (disk limits, Runtime artifact declarations and the default size setup proposes), the operation declaration and a node-local (`BuildLocal`) or direct (`BuildDirect`) constructor. `providers.Build` and `providers.BuildDirect` construct adapters without allocating compute. There is no init-time registration or plugin loading. A new provider takes these steps: @@ -121,7 +121,7 @@ A node configuration, `sandbox.NodeConfig`, holds `provider`, `generation`, `ins - A `nodes` registration has only `BuildLocal`, and a `direct` registration only `BuildDirect`; missing, mixed or unknown modes are rejected. - The specification and resource validators, the configuration adapter and a complete operation declaration are mandatory, so an incomplete registration cannot publish a partial projection. A declared default size must pass the adapter's resource validator. -- The Runtime input policy either accepts the pinned Runtime or gives the adapter's fixed reason for rejecting it, never both. +- Runtime artifact declarations own the required names, validation patterns and distribution-manifest selectors. Nonempty declarations require the [Runtime release](../contracts/agents-api/sandbox-deployment.md#runtime-release); without declarations the adapter supplies its fixed rejection reason. There is no separate authored support boolean or shared vendor artifact table. - Checkpoint is admitted only for a `nodes` registration, because the common lifecycle suspends only node allocations; registration rejects a `direct` Provider that declares it. A registration carries no suspension values: Core applies its one [suspension policy](#suspension) to every Provider that declares checkpoint support. The configuration adapter must be non-nil, including its concrete value. Every `ConfigurationRequirements` field needs an explicit valid decision: `Credential` is `Required` or `NotRequired`, and `Discovery`, `SelectionDiscovery` and `CredentialVerification` use the shared supported or unsupported declaration with a safe reason. A new requirement field needs an explicit validation update and never inherits an existing decision. Requiring a credential does not promise the `VerifyCredential` operation. These checks establish complete registration, not correct native SDK behavior; constructor and adapter contract tests still apply. diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 7e3bfa550..e13860eec 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,7 +1,7 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: 879929d69336b8212a8421d604b18076325afcad5ff336e34aa0aed8a0d1fb8c +source_hash: 81c6eba549710a69817aad66ca1e3d519b2920e00de868532e0bf62fd58e11fb --- Core 安装的每项设置都恰好只有一个归属位置,分属以下三类: @@ -131,7 +131,7 @@ Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置 | 字段 | 安装程序设置的值 | 含义 | | --- | --- | --- | | `host` | `unix:///var/run/docker.sock` | 显式 Docker Engine 套接字 | -| `image` | 加载后沙箱镜像的本地 ID | 发行版本的 `image_id` 或 `image_manifest_digest`。主机的镜像存储决定由哪个 digest 指代已加载的镜像,因此该值属于节点本地;适配器只接受这两个值 | +| `image` | 加载后沙箱镜像的本地 ID | 发行版本的 `artifacts.image_id` 或 `artifacts.image_manifest_digest`。主机的镜像存储决定由哪个 digest 指代已加载的镜像,因此该值属于节点本地;适配器只接受这两个值 | | `network` | `oac-node-` | 沙箱容器网络 | | `seccomp_file` | `/runtime/seccomp.json` | 所匹配发行版的 seccomp 配置文件 | diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index c9a409626..0ab419f53 100644 --- a/docs/zh/sandbox-provider.md +++ b/docs/zh/sandbox-provider.md @@ -1,7 +1,7 @@ --- title: "添加 Sandbox Provider" source: docs/sandbox-provider.md -source_hash: 56570ea0d859bdfc26a2de3ea85ad8a002f03f7b5376010e9e4e195092b2a52f +source_hash: 6d82188e3b303276e2a3bc5ddf65e158794f8749b3ed0f210e61dd62a94a3039 --- **Sandbox Provider** 为 Core 管理的 Environment 提供计算资源,以及在其中启动 [Sandbox I/O 服务](#oac-sandbox-io)的有界引导流程;该服务是 Provider 启动的唯一进程。本指南说明如何添加 Provider,并作为 Core 驱动 Provider 的参考。接口为 [`SandboxProvider`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/sandbox/sandbox_provider.go)。 @@ -102,7 +102,7 @@ Checkpoint 支持增加 `Compute` generation、name、ID 和 `SnapshotIdentity` ## 注册 provider kind {#register-the-provider-kind} -`sandbox/providers/registry.go` 是唯一注册表。每项绑定 adapter 的 specification 与 resource validator、`sandbox.ConfigurationAdapter`、部署模式(`nodes` 或 `direct`)、`sandbox.DeploymentPolicy`(磁盘限制、Runtime 输入,以及 setup 推荐的默认大小)、operation 声明,以及 node-local(`BuildLocal`)或 direct(`BuildDirect`)constructor。`providers.Build` 和 `providers.BuildDirect` 构造 adapter,不分配计算资源。没有 init 时注册或 plugin 加载。 +`sandbox/providers/registry.go` 是唯一注册表。每项绑定 adapter 的 specification 与 resource validator、`sandbox.ConfigurationAdapter`、部署模式(`nodes` 或 `direct`)、`sandbox.DeploymentPolicy`(磁盘限制、Runtime 制品声明,以及 setup 推荐的默认大小)、operation 声明,以及 node-local(`BuildLocal`)或 direct(`BuildDirect`)constructor。`providers.Build` 和 `providers.BuildDirect` 构造 adapter,不分配计算资源。没有 init 时注册或 plugin 加载。 新 provider 执行以下步骤: @@ -123,7 +123,7 @@ node 配置 `sandbox.NodeConfig` 包含 `provider`、`generation`、`installatio - `nodes` 注册仅有 `BuildLocal`,`direct` 注册仅有 `BuildDirect`;缺失、混合或未知 mode 被拒绝。 - specification 和 resource validator、configuration adapter 与完整 operation 声明都是必需项,因此不完整注册不能发布部分投影。声明的默认大小必须通过 adapter 的 resource validator。 -- Runtime input policy 要么接受固定 Runtime,要么给出 adapter 拒绝它的固定原因,不能两者兼有。 +- Runtime 制品声明拥有必需名称、验证模式和发行清单选择路径。非空声明要求提供 [Runtime 发行版](../../contracts/agents-api/zh/sandbox-deployment.md#runtime-release);没有声明时,adapter 提供固定的拒绝原因。不另行编写支持布尔值,也不维护共享的厂商制品表。 - Checkpoint 仅准入 `nodes` 注册,因为公共 lifecycle 只暂停 node allocation;声明 checkpoint 的 `direct` Provider 会被注册拒绝。注册不携带 suspension 数值:Core 对每个声明 checkpoint 支持的 Provider 应用同一个 [suspension policy](#suspension)。 configuration adapter 必须非 nil,包括其具体值。每个 `ConfigurationRequirements` 字段都需要明确有效的决定:`Credential` 为 `Required` 或 `NotRequired`,`Discovery`、`SelectionDiscovery` 和 `CredentialVerification` 使用共享 supported 或 unsupported 声明并携带安全 reason。新增 requirement field 需要明确更新验证,不继承已有决定。要求凭据不承诺支持 `VerifyCredential` 操作。这些检查证明注册完整,不证明原生 SDK 行为正确;constructor 和 adapter 契约测试仍然适用。 diff --git a/packages/agents-client/src/deployment-contract.ts b/packages/agents-client/src/deployment-contract.ts index 1106413a9..14efd0ddc 100644 --- a/packages/agents-client/src/deployment-contract.ts +++ b/packages/agents-client/src/deployment-contract.ts @@ -1,3 +1,3 @@ // Code generated by services/core/cmd/specification-contract from sandbox/deployment_contract.go; DO NOT EDIT. -export const deploymentContract = {"resources":[{"name":"cpus","min":1,"max":255,"omit_zero":false},{"name":"memory_mib","min":512,"max":1048576,"omit_zero":false},{"name":"root_disk_mib","min":0,"max":4294967295,"omit_zero":true},{"name":"environment_disk_mib","min":0,"max":4294967295,"omit_zero":true}],"runtime":[{"name":"source_commit","pattern":"[0-9a-f]{40}"},{"name":"image_id","pattern":"sha256:[0-9a-f]{64}"},{"name":"image_manifest_digest","pattern":"sha256:[0-9a-f]{64}"},{"name":"microsandbox_ref","pattern":"oac-runtime@sha256:[0-9a-f]{64}"},{"name":"runtime_sha256","pattern":"[0-9a-f]{64}"},{"name":"firmware_sha256","pattern":"[0-9a-f]{64}"}],"providers":{"docker":{"mode":"nodes","disk":false,"runtime":true,"default_resources":{"cpus":2,"memory_mib":2048}},"e2b":{"mode":"direct","disk":false,"runtime":false,"default_resources":null},"microsandbox":{"mode":"nodes","disk":true,"runtime":true,"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192,"environment_disk_mib":8192}}},"minimum_disk":1024} as const; +export const deploymentContract = {"resources":[{"name":"cpus","min":1,"max":255,"omit_zero":false},{"name":"memory_mib","min":512,"max":1048576,"omit_zero":false},{"name":"root_disk_mib","min":0,"max":4294967295,"omit_zero":true},{"name":"environment_disk_mib","min":0,"max":4294967295,"omit_zero":true}],"source_commit_pattern":"[0-9a-f]{40}","providers":{"docker":{"mode":"nodes","disk":false,"artifacts":{"image_id":{"pattern":"sha256:[0-9a-f]{64}","manifest_path":["images","runtime"]},"image_manifest_digest":{"pattern":"sha256:[0-9a-f]{64}","manifest_path":["image_manifest_digests","runtime"]}},"default_resources":{"cpus":2,"memory_mib":2048}},"e2b":{"mode":"direct","disk":false,"artifacts":{},"default_resources":null},"microsandbox":{"mode":"nodes","disk":true,"artifacts":{"firmware_sha256":{"pattern":"[0-9a-f]{64}","manifest_path":["microsandbox","firmware_sha256"]},"microsandbox_ref":{"pattern":"oac-runtime@sha256:[0-9a-f]{64}","manifest_path":["runtime_ref"]},"runtime_sha256":{"pattern":"[0-9a-f]{64}","manifest_path":["microsandbox","runtime_sha256"]}},"default_resources":{"cpus":2,"memory_mib":4096,"root_disk_mib":8192,"environment_disk_mib":8192}}},"minimum_disk":1024} as const; diff --git a/packages/agents-client/src/generated/core-api.ts b/packages/agents-client/src/generated/core-api.ts index 1e26e408c..376141492 100644 --- a/packages/agents-client/src/generated/core-api.ts +++ b/packages/agents-client/src/generated/core-api.ts @@ -746,14 +746,10 @@ export type RuntimeObservationMode = (typeof runtimeObservationModeValues)[numbe export const runtimeObservationStatusValues = ["observed", "unsupported", "unavailable"] as const; export type RuntimeObservationStatus = (typeof runtimeObservationStatusValues)[number]; export interface RuntimeRelease { - firmware_sha256: string; - image_id: string; - image_manifest_digest: string; - microsandbox_ref: string; - runtime_sha256: string; + artifacts: Record; source_commit: string; } -export const runtimeReleaseFields = ["firmware_sha256", "image_id", "image_manifest_digest", "microsandbox_ref", "runtime_sha256", "source_commit"] as const; +export const runtimeReleaseFields = ["artifacts", "source_commit"] as const; export interface SandboxAllocationList { data: NodeAllocation[]; } diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts index ef627c5e8..c4a19dd35 100644 --- a/packages/agents-client/src/sandbox-client.test.ts +++ b/packages/agents-client/src/sandbox-client.test.ts @@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it, vi } from "vitest"; import { AgentCoreError, OpenAIAgentsClient } from "./client"; import { SandboxAdminClient, normalizeSandboxNodeDiagnostic, sandboxNodeDiagnostics, type SandboxNode } from "./sandbox-client"; +import deploymentContractFixture from "../../../services/core/internal/sandbox/testdata/deployment-contract.json"; import nodeDiagnosticFixture from "../../../services/core/internal/sandbox/testdata/node-diagnostics.json"; function response(value: unknown, status = 200) { return new Response(JSON.stringify(value), { status }); } @@ -40,7 +41,8 @@ const allocation = { session_id: "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", environment_id: "3d4e5f6a-7b8c-4d9e-8f1a-2b3c4d5e6f7a", state: "running", compute_phase: "running", compute_phase_changed_at: null, diagnostic: "", initialization: "ready", created_at: created, }; -const runtime = { source_commit: "a".repeat(40), image_id: "sha256:" + "b".repeat(64), image_manifest_digest: "sha256:" + "c".repeat(64), microsandbox_ref: "oac-runtime@sha256:" + "d".repeat(64), runtime_sha256: "e".repeat(64), firmware_sha256: "f".repeat(64) }; +const runtime = { source_commit: "a".repeat(40), artifacts: { image_id: "sha256:" + "b".repeat(64), image_manifest_digest: "sha256:" + "c".repeat(64) } }; +const microRuntime = { source_commit: runtime.source_commit, artifacts: { microsandbox_ref: "oac-runtime@sha256:" + "d".repeat(64), runtime_sha256: "e".repeat(64), firmware_sha256: "f".repeat(64) } }; const unconfigured = { credential_configured: false, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: null }, installation_id: "", provider: "", core_url: "https://core.example", reset: null, owner_epoch: 0, generation: 0, mode: "", resources: { allocations: 0, pending: 0 }, suspension: null }; const docker = { @@ -49,7 +51,7 @@ const docker = { resources: { allocations: 2, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 }, runtime }, specification_digest: "0".repeat(64), }; const microsandbox = { - ...docker, provider: "microsandbox", specification: { resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 8192, environment_disk_mib: 8192 }, runtime }, + ...docker, provider: "microsandbox", specification: { resources: { cpus: 2, memory_mib: 2048, root_disk_mib: 8192, environment_disk_mib: 8192 }, runtime: microRuntime }, suspension: { idle_seconds: 300, retention_seconds: 86400 }, }; /** An E2B selection saved before Core recorded its template build. */ @@ -73,6 +75,35 @@ const { specification_digest: _digest, ...undigested } = docker; const { compute_phase_changed_at: _changed, ...unphased } = allocation; describe("strict sandbox administration projections", () => { + it.each(deploymentContractFixture.filter((entry) => entry.valid || /release|artifact|newline|crlf/.test(entry.name)))("checks the shared release contract: $name", async (entry) => { + const base = entry.provider === "e2b" ? e2bDeployment : entry.provider === "microsandbox" ? microsandbox : docker; + const body = { ...base, specification: entry.specification }; + if (entry.valid) expect(await read("deployment", body)).toEqual(body); + else await expect(read("deployment", body)).rejects.toMatchObject({ code: "invalid_admin_response" }); + }); + + it.each([ + ["missing runtime", undefined], ["null runtime", null], + ["legacy release", { source_commit: runtime.source_commit, ...runtime.artifacts }], + ["missing artifacts", { source_commit: runtime.source_commit }], + ["null artifacts", { ...runtime, artifacts: null }], ["array artifacts", { ...runtime, artifacts: [] }], + ["missing identity", { ...runtime, artifacts: { image_id: runtime.artifacts.image_id } }], + ["foreign provider identities", microRuntime], + ["extra identity", { ...runtime, artifacts: { ...runtime.artifacts, runtime_sha256: "e".repeat(64) } }], + ["null identity", { ...runtime, artifacts: { ...runtime.artifacts, image_id: null } }], + ["numeric identity", { ...runtime, artifacts: { ...runtime.artifacts, image_id: 42 } }], + ["uppercase identity", { ...runtime, artifacts: { ...runtime.artifacts, image_id: runtime.artifacts.image_id.toUpperCase() } }], + ["identity trailing newline", { ...runtime, artifacts: { ...runtime.artifacts, image_id: runtime.artifacts.image_id + "\n" } }], + ["commit trailing newline", { ...runtime, source_commit: runtime.source_commit + "\n" }], + ["extra release field", { ...runtime, image_id: runtime.artifacts.image_id }], + ])("rejects a Docker deployment with %s", async (_, value) => { + await expect(read("deployment", { ...docker, specification: { ...docker.specification, runtime: value } })).rejects.toMatchObject({ code: "invalid_admin_response" }); + }); + + it("rejects a release for a provider that declares no artifacts", async () => { + await expect(read("deployment", { ...e2bDeployment, specification: { ...e2bDeployment.specification, runtime } })).rejects.toMatchObject({ code: "invalid_admin_response" }); + }); + it.each([ ["nodes", "ready and unready", { data: [node, unready] }], ["nodes", "empty", { data: [] }], ["detail", "observed", detail], ["unobserved", "never observed", unobserved], @@ -192,7 +223,7 @@ describe("Core sandbox credential boundaries", () => { it("forwards one specification with generation and preserves backend conflict details", async () => { const fetch = vi.fn().mockResolvedValue(response({ error: { code: "sandbox_specification_mismatch", message: "Node specification differs" } }, 409)); const admin = new SandboxAdminClient({ token: "admin-only", fetch }); - const input = { provider: "docker" as const, resources: { cpus: 2, memory_mib: 2048 }, runtime: { source_commit: "a".repeat(40), image_id: "sha256:" + "b".repeat(64), image_manifest_digest: "sha256:" + "c".repeat(64), microsandbox_ref: "oac-runtime@sha256:" + "d".repeat(64), runtime_sha256: "e".repeat(64), firmware_sha256: "f".repeat(64) }, expected_generation: 3 }; + const input = { provider: "docker" as const, resources: { cpus: 2, memory_mib: 2048 }, runtime, expected_generation: 3 }; await expect(admin.updateDeployment(input)).rejects.toMatchObject({ status: 409, code: "sandbox_specification_mismatch" }); expect(fetch).toHaveBeenCalledTimes(1); expect(JSON.parse(String(fetch.mock.calls[0]?.[1]?.body))).toEqual(input); diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index 9d3f08fa8..76eb983b9 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -96,14 +96,19 @@ const measure = (value: unknown) => typeof value === "number" && Number.isFinite const nullable = (test: (value: unknown) => boolean) => (value: unknown) => value === null || test(value); const strings = (value: Record, fields: readonly string[]) => fields.every((field) => typeof value[field] === "string"); -function projectSpecification(value: unknown): SandboxSpecification { +function projectSpecification(value: unknown, provider: SandboxProvider): SandboxSpecification { const specification = members(value, deploymentSpecFields, deploymentSpecRequired); const resources = members(specification.resources, sandboxResourcesFields, sandboxResourcesRequired); valid(Object.values(resources).every(isNonnegativeInteger)); + const rules = deploymentContract.providers[provider].artifacts; + valid(hasOwn(specification, "runtime") === (Object.keys(rules).length > 0)); if (!hasOwn(specification, "runtime")) return { resources: { ...resources } as unknown as SandboxResources }; const runtime = members(specification.runtime, runtimeReleaseFields); - valid(strings(runtime, runtimeReleaseFields)); - return { resources: { ...resources } as unknown as SandboxResources, runtime: { ...runtime } as unknown as SandboxRuntimeRelease }; + const matches = (value: unknown, pattern: string) => typeof value === "string" && new RegExp(`^(?:${pattern})(?![\\s\\S])`).test(value); + valid(matches(runtime.source_commit, deploymentContract.source_commit_pattern)); + const artifacts = members(runtime.artifacts, Object.keys(rules)); + valid(Object.entries(rules).every(([name, rule]) => matches(artifacts[name], rule.pattern))); + return { resources: { ...resources } as unknown as SandboxResources, runtime: { source_commit: runtime.source_commit as string, artifacts: { ...artifacts } as Record } }; } /** The adapter's public projection has no credential member. */ function projectE2B(configuration: unknown, metadata: unknown): Pick { @@ -165,9 +170,10 @@ function projectDeployment(value: unknown): SandboxDeployment { [deployment.owner_epoch, deployment.generation, resources.allocations, resources.pending].every(isNonnegativeInteger) && (suspension === null || [suspension.idle_seconds, suspension.retention_seconds].every(isNonnegativeInteger)) && configured === hasOwn(deployment, "specification_digest") && (!configured || (typeof deployment.specification_digest === "string" && deployment.specification_digest !== ""))); + valid(!configured || deployment.provider !== ""); return { ...deployment, rollout: projectRollout(deployment.rollout, deployment.mode, Number(resources.allocations) + Number(resources.pending)), reset: projectReset(deployment.reset, Number(resources.allocations) + Number(resources.pending)), resources: { ...resources } as unknown as SandboxDeployment["resources"], suspension: suspension && { ...suspension } as unknown as SandboxDeployment["suspension"], - ...(configured ? { specification: projectSpecification(deployment.specification) } : {}), + ...(configured ? { specification: projectSpecification(deployment.specification, deployment.provider as SandboxProvider) } : {}), ...(e2b ? projectE2B(deployment.configuration, deployment.metadata) : {}), } as unknown as SandboxDeployment; } diff --git a/scripts/core-distribution-manifest.py b/scripts/core-distribution-manifest.py index 5e54f020f..3821272b6 100644 --- a/scripts/core-distribution-manifest.py +++ b/scripts/core-distribution-manifest.py @@ -21,6 +21,7 @@ DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "deploy/node")) import provider_assets +import node_spec ARTIFACTS = {item["path"]: item["suffix"] for items in provider_assets.CATALOG.values() for item in items} @@ -329,6 +330,9 @@ def node_payload(bundle, stage, revision, source_tree, artifact_base_url="", off "firmware_sha256": sha256(stage / "core/microsandbox/libkrunfw.so.5.6.1"), }, } + for provider, policy in node_spec._CONTRACT["providers"].items(): + if policy["artifacts"]: + node_spec.release(provider, metadata) payload = stage / "ingress/node-payload" payload.mkdir(parents=True) (payload / "manifest.json").write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n") diff --git a/scripts/core-distribution-manifest.test.py b/scripts/core-distribution-manifest.test.py index 4273e356f..e47f70433 100644 --- a/scripts/core-distribution-manifest.test.py +++ b/scripts/core-distribution-manifest.test.py @@ -218,6 +218,15 @@ def test_init_payload_contains_only_verified_node_metadata(self): for name in ("source_commit", "platform", "artifacts", "runtime_ref", "microsandbox"): self.assertEqual(bundled[name], full[name]) self.assertEqual(list(bundled["images"]), ["runtime"]) + self.assertNotIn("runtime_release", bundled) + + def test_node_payload_checks_generated_provider_artifact_declarations(self): + policy = distribution.node_spec._CONTRACT["providers"]["docker"] + artifacts = dict(policy["artifacts"], required_extra={"pattern": "[0-9a-f]{64}", "manifest_path": ["unpublished_identity"]}) + with mock.patch.dict(policy, artifacts=artifacts): + with self.assertRaises(KeyError): + self.manifest() + self.assertFalse((self.stage / "ingress/node-payload/manifest.json").exists()) def test_oci_manifest_identity_is_distinct_from_docker_config_identity(self): self.manifest() diff --git a/services/core/cmd/sandbox-node/generations_test.go b/services/core/cmd/sandbox-node/generations_test.go index ae08f7ad0..056dd8fc9 100644 --- a/services/core/cmd/sandbox-node/generations_test.go +++ b/services/core/cmd/sandbox-node/generations_test.go @@ -147,10 +147,10 @@ func TestUnresolvedPreparationRemainsRecoveryOnly(t *testing.T) { // Preparation may resolve native state, such as the digest a containerd image // store names the loaded Runtime image by; the envelope stays fixed. func TestGenerationPlanFixesOnlyTheEnvelope(t *testing.T) { - release := sandbox.RuntimeRelease{ImageID: "sha256:" + strings.Repeat("b", 64), ImageManifestDigest: "sha256:" + strings.Repeat("c", 64)} - plan := sandbox.NodeConfig{InstallationID: "installation", Generation: 2, Provider: "docker", Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}, Runtime: &release}, Native: json.RawMessage(`{"image":"` + release.ImageID + `"}`)} + release := sandbox.RuntimeRelease{Artifacts: map[string]string{"image_id": "sha256:" + strings.Repeat("b", 64), "image_manifest_digest": "sha256:" + strings.Repeat("c", 64)}} + plan := sandbox.NodeConfig{InstallationID: "installation", Generation: 2, Provider: "docker", Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}, Runtime: &release}, Native: json.RawMessage(`{"image":"` + release.Artifacts["image_id"] + `"}`)} final := plan - final.Native = json.RawMessage(`{"image":"` + release.ImageManifestDigest + `"}`) + final.Native = json.RawMessage(`{"image":"` + release.Artifacts["image_manifest_digest"] + `"}`) if !sameGenerationPlan(final, plan) { t.Fatal("refused a resolved native image") } diff --git a/services/core/internal/deployment/service_test.go b/services/core/internal/deployment/service_test.go index efe8ecdb9..7bf9d043a 100644 --- a/services/core/internal/deployment/service_test.go +++ b/services/core/internal/deployment/service_test.go @@ -56,8 +56,9 @@ func operations(t *testing.T, publicURL string, tx *fakeDeploymentTx) *Execution // testSpecification is a valid deployment specification for provider. func testSpecification(provider string) sandbox.DeploymentSpec { s := sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}} - s.Runtime = &sandbox.RuntimeRelease{SourceCommit: strings.Repeat("a", 40), ImageID: "sha256:" + strings.Repeat("b", 64), ImageManifestDigest: "sha256:" + strings.Repeat("c", 64), MicrosandboxRef: "oac-runtime@sha256:" + strings.Repeat("d", 64), RuntimeSHA256: strings.Repeat("e", 64), FirmwareSHA256: strings.Repeat("f", 64)} + s.Runtime = &sandbox.RuntimeRelease{SourceCommit: strings.Repeat("a", 40), Artifacts: map[string]string{"image_id": "sha256:" + strings.Repeat("b", 64), "image_manifest_digest": "sha256:" + strings.Repeat("c", 64)}} if provider == "microsandbox" { + s.Runtime.Artifacts = map[string]string{"microsandbox_ref": "oac-runtime@sha256:" + strings.Repeat("d", 64), "runtime_sha256": strings.Repeat("e", 64), "firmware_sha256": strings.Repeat("f", 64)} s.Resources.RootDiskMiB = 8192 s.Resources.EnvironmentDiskMiB = 8192 } diff --git a/services/core/internal/persistence/postgres/deploymentpg/fixture_test.go b/services/core/internal/persistence/postgres/deploymentpg/fixture_test.go index 45c48d97a..9cd962e2e 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/fixture_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/fixture_test.go @@ -95,8 +95,9 @@ func testSpecification(provider string) sandbox.DeploymentSpec { if provider == "e2b" { return s } - s.Runtime = &sandbox.RuntimeRelease{SourceCommit: strings.Repeat("a", 40), ImageID: "sha256:" + strings.Repeat("b", 64), ImageManifestDigest: "sha256:" + strings.Repeat("c", 64), MicrosandboxRef: "oac-runtime@sha256:" + strings.Repeat("d", 64), RuntimeSHA256: strings.Repeat("e", 64), FirmwareSHA256: strings.Repeat("f", 64)} + s.Runtime = &sandbox.RuntimeRelease{SourceCommit: strings.Repeat("a", 40), Artifacts: map[string]string{"image_id": "sha256:" + strings.Repeat("b", 64), "image_manifest_digest": "sha256:" + strings.Repeat("c", 64)}} if provider == "microsandbox" { + s.Runtime.Artifacts = map[string]string{"microsandbox_ref": "oac-runtime@sha256:" + strings.Repeat("d", 64), "runtime_sha256": strings.Repeat("e", 64), "firmware_sha256": strings.Repeat("f", 64)} s.Resources.RootDiskMiB = 8192 s.Resources.EnvironmentDiskMiB = 8192 } diff --git a/services/core/internal/persistence/postgres/deploymentpg/nodes_test.go b/services/core/internal/persistence/postgres/deploymentpg/nodes_test.go index a3c75e268..92fb0e29e 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/nodes_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/nodes_test.go @@ -360,6 +360,17 @@ func TestNodeGenerationDowngradePreservesServingProtocol(t *testing.T) { for _, mode := range []string{"v2", "old_v1", "current_v1"} { t.Run(mode, func(t *testing.T) { f := newFixture(t) + db := sql.OpenDB(stdlib.GetConnector(*f.pool.Config().ConnConfig)) + defer db.Close() + migration, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../../../migrations"), goose.WithTableName("agents_api_schema_version")) + if err != nil { + t.Fatal(err) + } + // Keep this node-protocol migration test below the independent + // Runtime release guard, before installing any deployment fixtures. + if _, err := migration.DownTo(t.Context(), 98); err != nil { + t.Fatal(err) + } changes, _ := f.execution(t) input := sandbox.Selection{Provider: "docker", DeploymentSpec: testSpecification("docker")} installation, first := f.initialize(t, changes, input) @@ -382,12 +393,6 @@ func TestNodeGenerationDowngradePreservesServingProtocol(t *testing.T) { t.Fatal("target change replaced execution ownership", next) } } - db := sql.OpenDB(stdlib.GetConnector(*f.pool.Config().ConnConfig)) - defer db.Close() - migration, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../../../migrations"), goose.WithTableName("agents_api_schema_version")) - if err != nil { - t.Fatal(err) - } _, err = migration.DownTo(t.Context(), 81) if mode == "current_v1" { if err != nil { @@ -398,9 +403,9 @@ func TestNodeGenerationDowngradePreservesServingProtocol(t *testing.T) { t.Fatal("downgrade discarded required node protocol") } // DownTo may have removed later, reversible migrations before the - // node protocol migration refused the downgrade. Restore the current - // schema before using this version of the adapter to verify recovery. - if _, err = migration.Up(t.Context()); err != nil { + // node protocol migration refused the downgrade. Restore the test + // baseline before using the adapter to verify recovery. + if _, err = migration.UpTo(t.Context(), 98); err != nil { t.Fatal("refused downgrade could not restore current schema", err) } if _, err = f.service.NodeConfiguration(t.Context(), node.NodeID, node.Credential, 1); err != nil { @@ -413,7 +418,7 @@ func TestNodeGenerationDowngradePreservesServingProtocol(t *testing.T) { t.Fatal("removed node blocked downgrade", err) } } - if _, err = migration.Up(t.Context()); err != nil { + if _, err = migration.UpTo(t.Context(), 98); err != nil { t.Fatal("node schema could not upgrade again", err) } }) @@ -424,6 +429,16 @@ func TestNodeGenerationDowngradePreservesServingProtocol(t *testing.T) { // node's last report, are rewritten to their class. func TestNodeReadinessClassMigrationRewritesStoredCodes(t *testing.T) { f := newFixture(t) + db := sql.OpenDB(stdlib.GetConnector(*f.pool.Config().ConnConfig)) + defer db.Close() + migration, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../../../migrations"), goose.WithTableName("agents_api_schema_version")) + if err != nil { + t.Fatal(err) + } + // Seed the readiness fixture below the independent Runtime release guard. + if _, err := migration.DownTo(t.Context(), 98); err != nil { + t.Fatal(err) + } changes, _ := f.execution(t) _, view := f.initialize(t, changes, sandbox.Selection{Provider: "docker", DeploymentSpec: testSpecification("docker")}) node := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1}) @@ -432,12 +447,6 @@ func TestNodeReadinessClassMigrationRewritesStoredCodes(t *testing.T) { if err := f.service.HeartbeatGenerations(t.Context(), node.NodeID, connection, view.OwnerEpoch, deployment.NodeHealth{Diagnostic: "provider_unavailable"}, failed); err != nil { t.Fatal(err) } - db := sql.OpenDB(stdlib.GetConnector(*f.pool.Config().ConnConfig)) - defer db.Close() - migration, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../../../../migrations"), goose.WithTableName("agents_api_schema_version")) - if err != nil { - t.Fatal(err) - } var version int64 for _, source := range migration.ListSources() { if strings.HasSuffix(source.Path, "_node_readiness_classes.sql") { @@ -453,7 +462,7 @@ func TestNodeReadinessClassMigrationRewritesStoredCodes(t *testing.T) { if _, err := f.pool.Exec(t.Context(), "UPDATE runtime_node_generation_status SET diagnostic='microsandbox_artifacts_unavailable' WHERE node_id=$1", node.NodeID); err != nil { t.Fatal(err) } - if _, err := migration.Up(t.Context()); err != nil { + if _, err := migration.UpTo(t.Context(), 98); err != nil { t.Fatal(err) } detail, err := f.service.NodeDetail(t.Context(), node.NodeID, "1h") diff --git a/services/core/internal/persistence/postgres/sessionpg/creation_test.go b/services/core/internal/persistence/postgres/sessionpg/creation_test.go index 8e2495694..b0d62ec66 100644 --- a/services/core/internal/persistence/postgres/sessionpg/creation_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/creation_test.go @@ -473,9 +473,7 @@ func TestHostedCreationAdmitsAndPlacesUnderTheDeploymentLock(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() tenant := uuid.NewString() - nodes, err := json.Marshal(sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}, Runtime: &sandbox.RuntimeRelease{SourceCommit: strings.Repeat("a", 40), - ImageID: "sha256:" + strings.Repeat("b", 64), ImageManifestDigest: "sha256:" + strings.Repeat("c", 64), MicrosandboxRef: "oac-runtime@sha256:" + strings.Repeat("d", 64), - RuntimeSHA256: strings.Repeat("e", 64), FirmwareSHA256: strings.Repeat("f", 64)}}) + nodes, err := json.Marshal(sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}, Runtime: &sandbox.RuntimeRelease{SourceCommit: strings.Repeat("a", 40), Artifacts: map[string]string{"image_id": "sha256:" + strings.Repeat("b", 64), "image_manifest_digest": "sha256:" + strings.Repeat("c", 64)}}}) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/sandbox/deployment.go b/services/core/internal/sandbox/deployment.go index 42f9d38ca..d448870ac 100644 --- a/services/core/internal/sandbox/deployment.go +++ b/services/core/internal/sandbox/deployment.go @@ -63,23 +63,20 @@ func (r Resources) ValidatePolicy(provider string, rules DeploymentPolicy) error return nil } -// RuntimeRelease preserves the identities of one verified distribution. Docker -// may address the same archive by config ID or OCI manifest digest; microsandbox -// has its own imported OCI identity. These are not interchangeable hashes. +// RuntimeRelease pins the immutable artifact identities declared by an adapter. type RuntimeRelease struct { - SourceCommit string `json:"source_commit" binding:"required"` - ImageID string `json:"image_id" binding:"required"` - ImageManifestDigest string `json:"image_manifest_digest" binding:"required"` - MicrosandboxRef string `json:"microsandbox_ref" binding:"required"` - RuntimeSHA256 string `json:"runtime_sha256" binding:"required"` - FirmwareSHA256 string `json:"firmware_sha256" binding:"required"` + SourceCommit string `json:"source_commit" binding:"required"` + Artifacts map[string]string `json:"artifacts" binding:"required"` } -func (r RuntimeRelease) Validate() error { - values := reflect.ValueOf(r) - for i, rule := range runtimeContract { - if !regexp.MustCompile("^(?:" + rule.Pattern + ")$").MatchString(values.Field(i).String()) { - return &ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: Runtime must reference one immutable distribution", ErrInvalid)} +func (r RuntimeRelease) validate(artifacts map[string]ArtifactRule) error { + invalid := &ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: Runtime must reference one immutable distribution", ErrInvalid)} + if !regexp.MustCompile("^(?:"+sourceCommitPattern+")$").MatchString(r.SourceCommit) || len(r.Artifacts) != len(artifacts) { + return invalid + } + for name, rule := range artifacts { + if !regexp.MustCompile("^(?:" + rule.Pattern + ")$").MatchString(r.Artifacts[name]) { + return invalid } } return nil @@ -95,7 +92,7 @@ func (s DeploymentSpec) ValidatePolicy(provider string, policy DeploymentPolicy) if err := s.Resources.ValidatePolicy(provider, policy); err != nil { return err } - if !policy.Runtime { + if len(policy.Artifacts) == 0 { if s.Runtime != nil { return &ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: %s", ErrInvalid, policy.RuntimeError)} } @@ -104,7 +101,7 @@ func (s DeploymentSpec) ValidatePolicy(provider string, policy DeploymentPolicy) if s.Runtime == nil { return &ValidationError{Param: "runtime", Message: fmt.Sprintf("%s: managed nodes require a pinned Runtime release", ErrInvalid)} } - return s.Runtime.Validate() + return s.Runtime.validate(policy.Artifacts) } func (s DeploymentSpec) Digest(provider string) string { diff --git a/services/core/internal/sandbox/deployment_contract.go b/services/core/internal/sandbox/deployment_contract.go index ed929f458..621c840d0 100644 --- a/services/core/internal/sandbox/deployment_contract.go +++ b/services/core/internal/sandbox/deployment_contract.go @@ -11,6 +11,7 @@ import ( // The node installer (node_spec.py) and the TypeScript client // (deployment-contract.ts) consume its generated projections. const minimumDiskMiB uint32 = 1024 +const sourceCommitPattern = "[0-9a-f]{40}" type resourceRule struct { Name string `json:"name"` @@ -19,10 +20,6 @@ type resourceRule struct { OmitZero bool `json:"omit_zero"` Message string `json:"-"` } -type runtimeRule struct { - Name string `json:"name"` - Pattern string `json:"pattern"` -} var resourceContract = []resourceRule{ {"cpus", 1, 255, false, "cpus must be 1..255 and memory_mib must be 512..1048576"}, @@ -30,14 +27,6 @@ var resourceContract = []resourceRule{ {"root_disk_mib", 0, ^uint32(0), true, ""}, {"environment_disk_mib", 0, ^uint32(0), true, ""}, } -var runtimeContract = []runtimeRule{ - {"source_commit", "[0-9a-f]{40}"}, - {"image_id", "sha256:[0-9a-f]{64}"}, - {"image_manifest_digest", "sha256:[0-9a-f]{64}"}, - {"microsandbox_ref", "oac-runtime@sha256:[0-9a-f]{64}"}, - {"runtime_sha256", "[0-9a-f]{64}"}, - {"firmware_sha256", "[0-9a-f]{64}"}, -} // ProviderProjection is one registered Provider in the generated projections. type ProviderProjection struct { @@ -53,7 +42,7 @@ func DeploymentContract(providers map[string]ProviderProjection) (python, typesc value any names []string }{ - {Resources{}, resourceNames()}, {RuntimeRelease{}, runtimeNames()}, + {Resources{}, resourceNames()}, {RuntimeRelease{}, []string{"source_commit", "artifacts"}}, } { typ := reflect.TypeOf(item.value) if typ.NumField() != len(item.names) { @@ -66,11 +55,11 @@ func DeploymentContract(providers map[string]ProviderProjection) (python, typesc } } raw, _ := json.Marshal(struct { - Resources []resourceRule `json:"resources"` - Runtime []runtimeRule `json:"runtime"` - Providers map[string]ProviderProjection `json:"providers"` - MinimumDisk uint32 `json:"minimum_disk"` - }{resourceContract, runtimeContract, providers, minimumDiskMiB}) + Resources []resourceRule `json:"resources"` + SourceCommitPattern string `json:"source_commit_pattern"` + Providers map[string]ProviderProjection `json:"providers"` + MinimumDisk uint32 `json:"minimum_disk"` + }{resourceContract, sourceCommitPattern, providers, minimumDiskMiB}) return "# BEGIN GENERATED DEPLOYMENT CONTRACT\n# Generated from sandbox/deployment_contract.go; do not edit.\n_CONTRACT = json.loads(" + fmt.Sprintf("%q", string(raw)) + ")\n# END GENERATED DEPLOYMENT CONTRACT", "// Code generated by services/core/cmd/specification-contract from sandbox/deployment_contract.go; DO NOT EDIT.\n\nexport const deploymentContract = " + string(raw) + " as const;\n" } @@ -81,10 +70,3 @@ func resourceNames() []string { } return names } -func runtimeNames() []string { - var names []string - for _, r := range runtimeContract { - names = append(names, r.Name) - } - return names -} diff --git a/services/core/internal/sandbox/docker/node.go b/services/core/internal/sandbox/docker/node.go index 1b51ef530..480faabe9 100644 --- a/services/core/internal/sandbox/docker/node.go +++ b/services/core/internal/sandbox/docker/node.go @@ -32,7 +32,7 @@ func decodeNative(config sandbox.NodeConfig) (Native, error) { return entry, errors.New("invalid managed Docker node configuration") } release := config.Specification.Runtime - if entry.Image != release.ImageID && entry.Image != release.ImageManifestDigest { + if entry.Image != release.Artifacts["image_id"] && entry.Image != release.Artifacts["image_manifest_digest"] { return entry, errors.New("Docker Runtime image differs from the deployment release") } return entry, nil diff --git a/services/core/internal/sandbox/docker/node_test.go b/services/core/internal/sandbox/docker/node_test.go index 0d5f576c2..b6abea57d 100644 --- a/services/core/internal/sandbox/docker/node_test.go +++ b/services/core/internal/sandbox/docker/node_test.go @@ -14,15 +14,15 @@ import ( // The host's image store decides which release digest names the loaded image. func TestNativeImageIsAReleaseIdentity(t *testing.T) { - release := sandbox.RuntimeRelease{ImageID: "sha256:" + strings.Repeat("b", 64), ImageManifestDigest: "sha256:" + strings.Repeat("c", 64)} + release := sandbox.RuntimeRelease{Artifacts: map[string]string{"image_id": "sha256:" + strings.Repeat("b", 64), "image_manifest_digest": "sha256:" + strings.Repeat("c", 64)}} config := sandbox.NodeConfig{Specification: sandbox.DeploymentSpec{Runtime: &release}} - for _, image := range []string{release.ImageID, release.ImageManifestDigest} { + for _, image := range []string{release.Artifacts["image_id"], release.Artifacts["image_manifest_digest"]} { config.Native = json.RawMessage(`{"image":"` + image + `"}`) if entry, err := decodeNative(config); err != nil || entry.Image != image { t.Fatalf("rejected release image %s: %v", image, err) } } - for _, native := range []string{`{"image":"sha256:` + strings.Repeat("a", 64) + `"}`, `{"image":"` + release.ImageID + `","cpus":2}`, `{"image":null}`} { + for _, native := range []string{`{"image":"sha256:` + strings.Repeat("a", 64) + `"}`, `{"image":"` + release.Artifacts["image_id"] + `","cpus":2}`, `{"image":null}`} { config.Native = json.RawMessage(native) if _, err := decodeNative(config); err == nil { t.Fatalf("accepted native %s", native) diff --git a/services/core/internal/sandbox/docker/selection.go b/services/core/internal/sandbox/docker/selection.go index 3a7ec247a..d34b2e0ae 100644 --- a/services/core/internal/sandbox/docker/selection.go +++ b/services/core/internal/sandbox/docker/selection.go @@ -5,7 +5,10 @@ import ( ) func Policy() sandbox.DeploymentPolicy { - return sandbox.DeploymentPolicy{Runtime: true, DefaultResources: &sandbox.Resources{CPUs: 2, MemoryMiB: 2048}} + return sandbox.DeploymentPolicy{Artifacts: map[string]sandbox.ArtifactRule{ + "image_id": {Pattern: "sha256:[0-9a-f]{64}", ManifestPath: []string{"images", "runtime"}}, + "image_manifest_digest": {Pattern: "sha256:[0-9a-f]{64}", ManifestPath: []string{"image_manifest_digests", "runtime"}}, + }, DefaultResources: &sandbox.Resources{CPUs: 2, MemoryMiB: 2048}} } func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("docker", Policy()) } diff --git a/services/core/internal/sandbox/e2b/deployment.go b/services/core/internal/sandbox/e2b/deployment.go index 3391d3f3e..d02bd3a8b 100644 --- a/services/core/internal/sandbox/e2b/deployment.go +++ b/services/core/internal/sandbox/e2b/deployment.go @@ -12,7 +12,7 @@ import ( ) func Policy() sandbox.DeploymentPolicy { - return sandbox.DeploymentPolicy{RuntimeError: "E2B Runtime is selected by its immutable template build"} + return sandbox.DeploymentPolicy{Artifacts: map[string]sandbox.ArtifactRule{}, RuntimeError: "E2B Runtime is selected by its immutable template build"} } func ValidateResources(r sandbox.Resources) error { return r.ValidatePolicy("e2b", Policy()) } diff --git a/services/core/internal/sandbox/microsandbox/deployment.go b/services/core/internal/sandbox/microsandbox/deployment.go index a97091d9d..f4c099620 100644 --- a/services/core/internal/sandbox/microsandbox/deployment.go +++ b/services/core/internal/sandbox/microsandbox/deployment.go @@ -5,7 +5,11 @@ import ( ) func Policy() sandbox.DeploymentPolicy { - return sandbox.DeploymentPolicy{Disk: true, Runtime: true, + return sandbox.DeploymentPolicy{Disk: true, Artifacts: map[string]sandbox.ArtifactRule{ + "microsandbox_ref": {Pattern: "oac-runtime@sha256:[0-9a-f]{64}", ManifestPath: []string{"runtime_ref"}}, + "runtime_sha256": {Pattern: "[0-9a-f]{64}", ManifestPath: []string{"microsandbox", "runtime_sha256"}}, + "firmware_sha256": {Pattern: "[0-9a-f]{64}", ManifestPath: []string{"microsandbox", "firmware_sha256"}}, + }, DefaultResources: &sandbox.Resources{CPUs: 2, MemoryMiB: 4096, RootDiskMiB: 8192, EnvironmentDiskMiB: 8192}} } diff --git a/services/core/internal/sandbox/microsandbox/node.go b/services/core/internal/sandbox/microsandbox/node.go index 69b331df2..08afb2a08 100644 --- a/services/core/internal/sandbox/microsandbox/node.go +++ b/services/core/internal/sandbox/microsandbox/node.go @@ -55,7 +55,7 @@ func configureMicrosandbox(entry Native, spec sandbox.DeploymentSpec, caller *Pr release, resources := spec.Runtime, spec.Resources config := Config{ InstallationID: result.InstallationID, HelperPath: entry.HelperPath, RuntimeHome: entry.RuntimeHome, RuntimePath: entry.RuntimePath, FirmwarePath: entry.FirmwarePath, - RuntimeSHA256: release.RuntimeSHA256, FirmwareSHA256: release.FirmwareSHA256, Image: release.MicrosandboxRef, + RuntimeSHA256: release.Artifacts["runtime_sha256"], FirmwareSHA256: release.Artifacts["firmware_sha256"], Image: release.Artifacts["microsandbox_ref"], MemoryMiB: resources.MemoryMiB, CPUs: uint8(resources.CPUs), RootDiskMiB: resources.RootDiskMiB, EnvironmentDiskMiB: resources.EnvironmentDiskMiB, Network: network, } provider, err := NewWithCaller(config, caller) diff --git a/services/core/internal/sandbox/microsandbox/node_test.go b/services/core/internal/sandbox/microsandbox/node_test.go index 082bfd5a7..e77e66ac5 100644 --- a/services/core/internal/sandbox/microsandbox/node_test.go +++ b/services/core/internal/sandbox/microsandbox/node_test.go @@ -28,7 +28,7 @@ func TestMicrosandboxConstructionSelectsGenerationReadiness(t *testing.T) { } script := []byte("#!/bin/sh\nprintf '%s' '{}'\n") digest := sha256.Sum256(script) - release := sandbox.RuntimeRelease{MicrosandboxRef: "oac-runtime@sha256:" + strings.Repeat("d", 64), RuntimeSHA256: hex.EncodeToString(digest[:]), FirmwareSHA256: hex.EncodeToString(digest[:])} + release := sandbox.RuntimeRelease{Artifacts: map[string]string{"microsandbox_ref": "oac-runtime@sha256:" + strings.Repeat("d", 64), "runtime_sha256": hex.EncodeToString(digest[:]), "firmware_sha256": hex.EncodeToString(digest[:])}} config := sandbox.NodeConfig{Provider: "microsandbox", Generation: 9, Specification: sandbox.DeploymentSpec{Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048, RootDiskMiB: 8192, EnvironmentDiskMiB: 8192}, Runtime: &release}, Native: raw} for _, path := range []string{native.RuntimePath, native.FirmwarePath, native.HelperPath} { if err := os.WriteFile(path, script, 0700); err != nil { diff --git a/services/core/internal/sandbox/providers/config_test.go b/services/core/internal/sandbox/providers/config_test.go index 33695ac33..dda35524f 100644 --- a/services/core/internal/sandbox/providers/config_test.go +++ b/services/core/internal/sandbox/providers/config_test.go @@ -10,7 +10,7 @@ import ( ) func TestNodeRejectsCoreConfigurationAndUnknownProvider(t *testing.T) { - for _, field := range []string{`"nodes":{"local":false}`, `"maintenance":true`, `"docker":{}`} { + for _, field := range []string{`"nodes":{"local":false}`, `"maintenance":true`, `"docker":{}`, `"specification":{"runtime":{"source_commit":"old","image_id":"old"}}`} { path := filepath.Join(t.TempDir(), "config.json") if err := os.WriteFile(path, []byte(`{"provider":"docker",`+field+`}`), 0600); err != nil { t.Fatal(err) diff --git a/services/core/internal/sandbox/providers/configuration_flow_test.go b/services/core/internal/sandbox/providers/configuration_flow_test.go index a07aed807..4a4731e27 100644 --- a/services/core/internal/sandbox/providers/configuration_flow_test.go +++ b/services/core/internal/sandbox/providers/configuration_flow_test.go @@ -95,6 +95,8 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) { t.Fatal(err) } adapter.Configuration = regionalCodec{} + adapter.Policy.Artifacts = map[string]sandbox.ArtifactRule{"regional_image": {Pattern: "sha256:[0-9a-f]{64}", ManifestPath: []string{"regional", "image"}}} + adapter.ValidateSpecification = func(s sandbox.DeploymentSpec) error { return s.ValidatePolicy(kind, adapter.Policy) } registry := providers.FixtureRegistry(t, kind, adapter) // The deployment reaches the registered configuration only through the // registry it is built with. @@ -166,19 +168,22 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) { if err != nil { t.Fatal(err) } - runtime := sandbox.RuntimeRelease{SourceCommit: strings.Repeat("a", 40), ImageID: "sha256:" + strings.Repeat("b", 64), ImageManifestDigest: "sha256:" + strings.Repeat("c", 64), MicrosandboxRef: "oac-runtime@sha256:" + strings.Repeat("d", 64), RuntimeSHA256: strings.Repeat("e", 64), FirmwareSHA256: strings.Repeat("f", 64)} + runtime := sandbox.RuntimeRelease{SourceCommit: strings.Repeat("a", 40), Artifacts: map[string]string{"regional_image": "sha256:" + strings.Repeat("b", 64)}} body, _ := json.Marshal(map[string]any{"provider": kind, "expected_generation": 0, "resources": sandbox.Resources{CPUs: 2, MemoryMiB: 2048}, "runtime": runtime, "configuration": map[string]string{"zone": "west"}}) request := httptest.NewRequest("POST", "/core/v1/sandbox/deployment", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer fixture-admin") response := httptest.NewRecorder() h.ServeHTTP(response, request) - if response.Code != 200 || !strings.Contains(response.Body.String(), `"zone":"west"`) { + if response.Code != 200 || !strings.Contains(response.Body.String(), `"zone":"west"`) || !strings.Contains(response.Body.String(), `"artifacts":{"regional_image":"`+runtime.Artifacts["regional_image"]+`"}`) { t.Fatal(response.Code, response.Body.String()) } saved, err := deployments().Setup(t.Context()) if err != nil || saved.Configuration.(regionalConfiguration).Zone != "west" { t.Fatal("configuration did not roundtrip", err) } + if saved.Specification.Runtime.SourceCommit != runtime.SourceCommit || len(saved.Specification.Runtime.Artifacts) != 1 || saved.Specification.Runtime.Artifacts["regional_image"] != runtime.Artifacts["regional_image"] { + t.Fatal("adapter artifact did not roundtrip", saved.Specification.Runtime) + } var raw []byte if err = pool.QueryRow(t.Context(), "SELECT provider_config FROM runtime_deployment").Scan(&raw); err != nil || !strings.Contains(string(raw), `"zone": "west"`) { t.Fatal("native fields not persisted", err) diff --git a/services/core/internal/sandbox/providers/generation_test.go b/services/core/internal/sandbox/providers/generation_test.go index 0bacbe40d..c3ce19e3d 100644 --- a/services/core/internal/sandbox/providers/generation_test.go +++ b/services/core/internal/sandbox/providers/generation_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "syscall" "testing" "time" @@ -20,6 +21,7 @@ func generationConfig(t *testing.T) (sandbox.NodeConfig, sandboxmicro.Native) { t.Helper() dir := t.TempDir() spec := validRegistrationSpec() + spec.Runtime.Artifacts = map[string]string{"microsandbox_ref": "oac-runtime@sha256:" + strings.Repeat("d", 64), "runtime_sha256": strings.Repeat("e", 64), "firmware_sha256": strings.Repeat("f", 64)} spec.Resources.RootDiskMiB, spec.Resources.EnvironmentDiskMiB = 8192, 8192 native := sandboxmicro.Native{HelperPath: filepath.Join(dir, "helper"), RuntimeHome: dir, RuntimePath: filepath.Join(dir, "msb"), FirmwarePath: filepath.Join(dir, "firmware"), Network: sandboxmicro.Network{DefaultEgress: "allow", DefaultIngress: "deny"}} @@ -153,7 +155,7 @@ func TestMicrosandboxGenerationRejectsUnpinnedImage(t *testing.T) { for _, image := range []string{"latest", "oac-runtime@sha256:bad", "oac-runtime@sha256:"} { t.Run(image, func(t *testing.T) { config, _ := generationConfig(t) - config.Specification.Runtime.MicrosandboxRef = image + config.Specification.Runtime.Artifacts["microsandbox_ref"] = image built, closeProvider, err := registry.Build(config, sandbox.LocalOptions{GenerationStateDirectory: t.TempDir()}) closeProvider() if err == nil || built != nil { diff --git a/services/core/internal/sandbox/providers/registration.go b/services/core/internal/sandbox/providers/registration.go index a4d68dea2..97971e268 100644 --- a/services/core/internal/sandbox/providers/registration.go +++ b/services/core/internal/sandbox/providers/registration.go @@ -2,6 +2,7 @@ package providers import ( "fmt" + "regexp" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" @@ -41,9 +42,20 @@ func ValidateRegistration(a Adapter) error { if err := validateConfigurationAdapter(a.Configuration); err != nil { return err } - if a.Policy.Runtime == (a.Policy.RuntimeError != "") { + if (len(a.Policy.Artifacts) != 0) == (a.Policy.RuntimeError != "") { return invalid("Runtime input policy") } + for name, rule := range a.Policy.Artifacts { + pattern, err := regexp.Compile("^(?:" + rule.Pattern + ")$") + if !regexp.MustCompile(`^[a-z][a-z0-9_]*$`).MatchString(name) || err != nil || pattern.MatchString("") || len(rule.ManifestPath) == 0 { + return invalid("Runtime artifact declaration") + } + for _, key := range rule.ManifestPath { + if key == "" { + return invalid("Runtime artifact manifest path") + } + } + } if a.Operations == nil { return invalid("operation declaration") } diff --git a/services/core/internal/sandbox/providers/registration_test.go b/services/core/internal/sandbox/providers/registration_test.go index 6809eb350..ff97169a7 100644 --- a/services/core/internal/sandbox/providers/registration_test.go +++ b/services/core/internal/sandbox/providers/registration_test.go @@ -14,12 +14,7 @@ import ( func validRegistrationSpec() sandbox.DeploymentSpec { return sandbox.DeploymentSpec{ Resources: sandbox.Resources{CPUs: 2, MemoryMiB: 2048}, - Runtime: &sandbox.RuntimeRelease{ - SourceCommit: strings.Repeat("a", 40), ImageID: "sha256:" + strings.Repeat("b", 64), - ImageManifestDigest: "sha256:" + strings.Repeat("c", 64), - MicrosandboxRef: "oac-runtime@sha256:" + strings.Repeat("d", 64), - RuntimeSHA256: strings.Repeat("e", 64), FirmwareSHA256: strings.Repeat("f", 64), - }, + Runtime: &sandbox.RuntimeRelease{SourceCommit: strings.Repeat("a", 40), Artifacts: map[string]string{"image_id": "sha256:" + strings.Repeat("b", 64), "image_manifest_digest": "sha256:" + strings.Repeat("c", 64)}}, } } @@ -32,6 +27,19 @@ func TestRegistrationRejectsBeforeCallbacksOrConstruction(t *testing.T) { }{ {"missing Runtime input policy", func(a *Adapter) { a.Policy = sandbox.DeploymentPolicy{} }}, {"contradictory Runtime input policy", func(a *Adapter) { a.Policy.RuntimeError = "Runtime rejected" }}, + {"invalid artifact name", func(a *Adapter) { + a.Policy.Artifacts = map[string]sandbox.ArtifactRule{"private-token": {Pattern: "[a-z]+", ManifestPath: []string{"archive"}}} + }}, + {"invalid artifact pattern", func(a *Adapter) { + a.Policy.Artifacts = map[string]sandbox.ArtifactRule{"archive": {Pattern: "[", ManifestPath: []string{"archive"}}} + }}, + {"empty artifact identity", func(a *Adapter) { + a.Policy.Artifacts = map[string]sandbox.ArtifactRule{"archive": {Pattern: "[a-z]*", ManifestPath: []string{"archive"}}} + }}, + {"missing artifact selector", func(a *Adapter) { a.Policy.Artifacts = map[string]sandbox.ArtifactRule{"archive": {Pattern: "[a-z]+"}} }}, + {"empty artifact selector key", func(a *Adapter) { + a.Policy.Artifacts = map[string]sandbox.ArtifactRule{"archive": {Pattern: "[a-z]+", ManifestPath: []string{""}}} + }}, {"missing mode", func(a *Adapter) { a.Mode = "" }}, {"unknown mode", func(a *Adapter) { a.Mode = "private-token" }}, {"missing local constructor", func(a *Adapter) { a.BuildLocal = nil }}, diff --git a/services/core/internal/sandbox/sandbox_provider.go b/services/core/internal/sandbox/sandbox_provider.go index b39185146..2feec19a4 100644 --- a/services/core/internal/sandbox/sandbox_provider.go +++ b/services/core/internal/sandbox/sandbox_provider.go @@ -246,14 +246,21 @@ type ConfigurationRequirements struct { } // DeploymentPolicy is the deployment declaration. Disk declares independent -// disk limits; Runtime requires a pinned Runtime release, and RuntimeError is -// the fixed reason for rejecting one otherwise. DefaultResources is the size +// disk limits; Artifacts requires exactly the declared immutable identities, +// and RuntimeError is the fixed reason for rejecting a release otherwise. DefaultResources is the size // setup proposes, or nil when the Provider's configuration selects it. type DeploymentPolicy struct { - RuntimeError string `json:"-"` - Disk bool `json:"disk"` - Runtime bool `json:"runtime"` - DefaultResources *Resources `json:"default_resources"` + RuntimeError string `json:"-"` + Disk bool `json:"disk"` + Artifacts map[string]ArtifactRule `json:"artifacts"` + DefaultResources *Resources `json:"default_resources"` +} + +// ArtifactRule declares one pinned identity and where the distribution manifest +// authors it. ManifestPath is an object-key path, not a filesystem path. +type ArtifactRule struct { + Pattern string `json:"pattern"` + ManifestPath []string `json:"manifest_path"` } // Configuration is an adapter-owned typed value, never a request or response DTO. diff --git a/services/core/internal/sandbox/testdata/deployment-contract.json b/services/core/internal/sandbox/testdata/deployment-contract.json index afec83248..0bcc6eae5 100644 --- a/services/core/internal/sandbox/testdata/deployment-contract.json +++ b/services/core/internal/sandbox/testdata/deployment-contract.json @@ -9,16 +9,15 @@ }, "runtime": { "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "artifacts": { + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } } }, "valid": true, - "canonical": "{\"provider\":\"docker\",\"resources\":{\"cpus\":2,\"memory_mib\":4096},\"runtime\":{\"source_commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"image_id\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"image_manifest_digest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"microsandbox_ref\":\"oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"runtime_sha256\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"firmware_sha256\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\"}}", - "digest": "1237a0d19e665b387922ad3d60143d5cd530864f0e3e4ee2c5ace57a1134aa38" + "canonical": "{\"provider\":\"docker\",\"resources\":{\"cpus\":2,\"memory_mib\":4096},\"runtime\":{\"source_commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"artifacts\":{\"image_id\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"image_manifest_digest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"}}}", + "digest": "9ad09c54f0a5b1143c191fc5dec37ed385c4917080c45ef79e1b6a98925a704a" }, { "name": "docker-zero-disks", @@ -32,16 +31,15 @@ }, "runtime": { "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "artifacts": { + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } } }, "valid": true, - "canonical": "{\"provider\":\"docker\",\"resources\":{\"cpus\":2,\"memory_mib\":4096},\"runtime\":{\"source_commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"image_id\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"image_manifest_digest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"microsandbox_ref\":\"oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"runtime_sha256\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"firmware_sha256\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\"}}", - "digest": "1237a0d19e665b387922ad3d60143d5cd530864f0e3e4ee2c5ace57a1134aa38" + "canonical": "{\"provider\":\"docker\",\"resources\":{\"cpus\":2,\"memory_mib\":4096},\"runtime\":{\"source_commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"artifacts\":{\"image_id\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"image_manifest_digest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"}}}", + "digest": "9ad09c54f0a5b1143c191fc5dec37ed385c4917080c45ef79e1b6a98925a704a" }, { "name": "microsandbox", @@ -55,16 +53,16 @@ }, "runtime": { "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "artifacts": { + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } } }, "valid": true, - "canonical": "{\"provider\":\"microsandbox\",\"resources\":{\"cpus\":255,\"memory_mib\":1048576,\"root_disk_mib\":1024,\"environment_disk_mib\":4294967295},\"runtime\":{\"source_commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"image_id\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"image_manifest_digest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"microsandbox_ref\":\"oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"runtime_sha256\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"firmware_sha256\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\"}}", - "digest": "1bb6daadcf9e34b236a8ecba6eb4c5215b946a3f226721b2bf148f72acb5f05e" + "canonical": "{\"provider\":\"microsandbox\",\"resources\":{\"cpus\":255,\"memory_mib\":1048576,\"root_disk_mib\":1024,\"environment_disk_mib\":4294967295},\"runtime\":{\"source_commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"artifacts\":{\"firmware_sha256\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\"microsandbox_ref\":\"oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"runtime_sha256\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\"}}}", + "digest": "392b14a9cab7593ba1f7749134bd5b6d25344e4181f07ee9b8d0918a1649844f" }, { "name": "e2b", @@ -89,11 +87,10 @@ }, "runtime": { "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "artifacts": { + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } } }, "valid": false @@ -108,11 +105,10 @@ }, "runtime": { "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "artifacts": { + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } } }, "valid": false @@ -127,11 +123,10 @@ }, "runtime": { "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "artifacts": { + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } } }, "valid": false @@ -146,11 +141,10 @@ }, "runtime": { "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "artifacts": { + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } } }, "valid": false @@ -166,11 +160,10 @@ }, "runtime": { "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "artifacts": { + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } } }, "valid": false @@ -186,11 +179,10 @@ }, "runtime": { "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "artifacts": { + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } } }, "valid": false @@ -207,11 +199,11 @@ }, "runtime": { "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "artifacts": { + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } } }, "valid": false @@ -219,6 +211,212 @@ { "name": "invalid-release-digest", "provider": "docker", + "specification": { + "resources": { + "cpus": 2, + "memory_mib": 4096 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "artifacts": { + "image_id": "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + } + }, + "valid": false + }, + { + "name": "artifact-map-order", + "provider": "docker", + "specification": { + "resources": { + "cpus": 2, + "memory_mib": 4096 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "artifacts": { + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + } + }, + "valid": true, + "canonical": "{\"provider\":\"docker\",\"resources\":{\"cpus\":2,\"memory_mib\":4096},\"runtime\":{\"source_commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"artifacts\":{\"image_id\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"image_manifest_digest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"}}}", + "digest": "9ad09c54f0a5b1143c191fc5dec37ed385c4917080c45ef79e1b6a98925a704a" + }, + { + "name": "missing-artifact", + "provider": "docker", + "specification": { + "resources": { + "cpus": 2, + "memory_mib": 4096 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "artifacts": { + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + } + }, + "valid": false + }, + { + "name": "extra-artifact", + "provider": "docker", + "specification": { + "resources": { + "cpus": 2, + "memory_mib": 4096 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "artifacts": { + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "unused": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + }, + "valid": false + }, + { + "name": "null-artifacts", + "provider": "docker", + "specification": { + "resources": { + "cpus": 2, + "memory_mib": 4096 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "artifacts": null + } + }, + "valid": false + }, + { + "name": "null-artifact", + "provider": "docker", + "specification": { + "resources": { + "cpus": 2, + "memory_mib": 4096 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "artifacts": { + "image_id": null, + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + } + }, + "valid": false + }, + { + "name": "non-string-artifact", + "provider": "docker", + "specification": { + "resources": { + "cpus": 2, + "memory_mib": 4096 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "artifacts": { + "image_id": 123, + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + } + }, + "valid": false + }, + { + "name": "source-newline", + "provider": "docker", + "specification": { + "resources": { + "cpus": 2, + "memory_mib": 4096 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", + "artifacts": { + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + } + }, + "valid": false + }, + { + "name": "microsandbox_ref-newline", + "provider": "microsandbox", + "specification": { + "resources": { + "cpus": 255, + "memory_mib": 1048576, + "root_disk_mib": 1024, + "environment_disk_mib": 4294967295 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "artifacts": { + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\n", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + } + }, + "valid": false + }, + { + "name": "runtime_sha256-newline", + "provider": "microsandbox", + "specification": { + "resources": { + "cpus": 255, + "memory_mib": 1048576, + "root_disk_mib": 1024, + "environment_disk_mib": 4294967295 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "artifacts": { + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\n", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + } + }, + "valid": false + }, + { + "name": "firmware_sha256-newline", + "provider": "microsandbox", + "specification": { + "resources": { + "cpus": 255, + "memory_mib": 1048576, + "root_disk_mib": 1024, + "environment_disk_mib": 4294967295 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "artifacts": { + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n" + } + } + }, + "valid": false + }, + { + "name": "old-release-shape", + "provider": "docker", "specification": { "resources": { "cpus": 2, @@ -229,10 +427,91 @@ "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - "runtime_sha256": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" } }, "valid": false + }, + { + "name": "source-crlf", + "provider": "docker", + "specification": { + "resources": { + "cpus": 2, + "memory_mib": 4096 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\r\n", + "artifacts": { + "image_id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image_manifest_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + } + }, + "valid": false + }, + { + "name": "microsandbox_ref-crlf", + "provider": "microsandbox", + "specification": { + "resources": { + "cpus": 255, + "memory_mib": 1048576, + "root_disk_mib": 1024, + "environment_disk_mib": 4294967295 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "artifacts": { + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\r\n", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + } + }, + "valid": false + }, + { + "name": "runtime_sha256-crlf", + "provider": "microsandbox", + "specification": { + "resources": { + "cpus": 255, + "memory_mib": 1048576, + "root_disk_mib": 1024, + "environment_disk_mib": 4294967295 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "artifacts": { + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\r\n", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + } + }, + "valid": false + }, + { + "name": "firmware_sha256-crlf", + "provider": "microsandbox", + "specification": { + "resources": { + "cpus": 255, + "memory_mib": 1048576, + "root_disk_mib": 1024, + "environment_disk_mib": 4294967295 + }, + "runtime": { + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "artifacts": { + "microsandbox_ref": "oac-runtime@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "runtime_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "firmware_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\r\n" + } + } + }, + "valid": false } ] diff --git a/services/core/migrations/000099_declared_runtime_artifacts.sql b/services/core/migrations/000099_declared_runtime_artifacts.sql new file mode 100644 index 000000000..909af0db7 --- /dev/null +++ b/services/core/migrations/000099_declared_runtime_artifacts.sql @@ -0,0 +1,39 @@ +-- +goose Up +-- Persisted specifications and node pins belong to their installation release. +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM ( + SELECT specification FROM runtime_deployment + UNION ALL + SELECT specification FROM runtime_deployment_generations + ) retained + WHERE specification->'runtime' IS NOT NULL + AND specification->'runtime' <> 'null'::jsonb + AND (jsonb_typeof(specification->'runtime') <> 'object' + OR (specification->'runtime') - ARRAY['source_commit', 'artifacts'] <> '{}'::jsonb + OR COALESCE(jsonb_typeof(specification#>'{runtime,artifacts}'), '') <> 'object') + ) THEN + RAISE EXCEPTION 'Cannot change persisted Runtime release specifications; follow docs/getting-started/operations.md#installation-version-policy and preserve this installation'; + END IF; +END $$; +-- +goose StatementEnd + +-- +goose Down +-- Earlier code cannot interpret declared artifact identities. +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM ( + SELECT specification FROM runtime_deployment + UNION ALL + SELECT specification FROM runtime_deployment_generations + ) retained + WHERE specification->'runtime' ? 'artifacts' + ) THEN + RAISE EXCEPTION 'Cannot downgrade persisted Runtime artifact specifications; follow docs/getting-started/operations.md#installation-version-policy and preserve this installation'; + END IF; +END $$; +-- +goose StatementEnd diff --git a/services/core/tests/integration/runtime_artifacts_migration_test.go b/services/core/tests/integration/runtime_artifacts_migration_test.go new file mode 100644 index 000000000..92d38eada --- /dev/null +++ b/services/core/tests/integration/runtime_artifacts_migration_test.go @@ -0,0 +1,107 @@ +package integration + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + + "github.com/google/uuid" +) + +func TestRuntimeArtifactsMigrationProtectsRetainedSpecifications(t *testing.T) { + for _, direction := range []string{"up", "down"} { + for _, location := range []string{"current", "history", "both"} { + t.Run(direction+"/"+location, func(t *testing.T) { + db, migration := runtimeNamesMigrationSchema(t) + ctx := t.Context() + version := int64(98) + if direction == "down" { + version = 99 + } + if _, err := migration.UpTo(ctx, version); err != nil { + t.Fatal(err) + } + exec := func(query string, args ...any) { + t.Helper() + if _, err := db.ExecContext(ctx, query, args...); err != nil { + t.Fatal(err) + } + } + hash := strings.Repeat("a", 64) + runtime := `{"source_commit":"` + strings.Repeat("b", 40) + `","image_id":"sha256:` + hash + `","image_manifest_digest":"sha256:` + hash + `","microsandbox_ref":"oac-runtime@sha256:` + hash + `","runtime_sha256":"` + hash + `","firmware_sha256":"` + hash + `"}` + if direction == "down" { + runtime = `{"source_commit":"` + strings.Repeat("b", 40) + `","artifacts":{"image_id":"sha256:` + hash + `","image_manifest_digest":"sha256:` + hash + `"}}` + } + specification := `{"resources":{"cpus":2,"memory_mib":2048},"runtime":` + runtime + `}` + // An E2B current selection must not hide an incompatible historical pin. + exec(`UPDATE runtime_deployment SET provider_kind='e2b',mode='direct',generation=2,specification='{"resources":{"cpus":2,"memory_mib":2048}}'`) + if location != "history" { + exec(`UPDATE runtime_deployment SET provider_kind='docker',mode='nodes',specification=$1`, specification) + } + if location != "current" { + exec(`INSERT INTO runtime_deployment_generations(generation,provider_kind,specification) VALUES(1,'docker',$1)`, specification) + } + digest := sha256.Sum256([]byte(`{"provider":"docker",` + specification[1:])) + node, connection := uuid.NewString(), uuid.NewString() + exec(`INSERT INTO runtime_nodes(id,installation_id,name,backend_fingerprint,credential_sha256,max_active,max_retained,connection_id,connected_epoch,deployment_generation,ready_generation,specification_digest) + VALUES($1,$2,'retained', $3,$3,1,2,$4,1,1,1,$5)`, node, uuid.NewString(), hash, connection, hex.EncodeToString(digest[:])) + exec(`INSERT INTO runtime_node_generation_status(node_id,generation,specification_digest,connection_id,owner_epoch,state) VALUES($1,1,$2,$3,1,'ready')`, node, hex.EncodeToString(digest[:]), connection) + snapshot := func(table string) string { + t.Helper() + var value string + if err := db.QueryRowContext(ctx, "SELECT COALESCE(jsonb_agg(to_jsonb(r) ORDER BY to_jsonb(r)::text), '[]'::jsonb)::text FROM "+table+" r").Scan(&value); err != nil { + t.Fatal(err) + } + return value + } + before := map[string]string{} + for _, table := range []string{"runtime_deployment", "runtime_deployment_generations", "runtime_nodes", "runtime_node_generation_status", "agents_api_schema_version"} { + before[table] = snapshot(table) + } + var err error + if direction == "up" { + _, err = migration.UpTo(ctx, 99) + } else { + _, err = migration.DownTo(ctx, 98) + } + if err == nil || !strings.Contains(err.Error(), "installation-version-policy") { + t.Fatalf("migration must reject incompatible retained specifications: %v", err) + } + for table, original := range before { + if snapshot(table) != original { + t.Fatalf("refused migration changed %s", table) + } + } + }) + } + } +} + +func TestRuntimeArtifactsMigrationAllowsEmptyAndE2BInstallations(t *testing.T) { + for _, scenario := range []string{"empty", "e2b", "e2b_history"} { + t.Run(scenario, func(t *testing.T) { + db, migration := runtimeNamesMigrationSchema(t) + ctx := t.Context() + if _, err := migration.UpTo(ctx, 98); err != nil { + t.Fatal(err) + } + if scenario != "empty" { + if _, err := db.ExecContext(ctx, `UPDATE runtime_deployment SET provider_kind='e2b',mode='direct',generation=2,specification='{"resources":{"cpus":2,"memory_mib":2048}}'`); err != nil { + t.Fatal(err) + } + } + if scenario == "e2b_history" { + if _, err := db.ExecContext(ctx, `INSERT INTO runtime_deployment_generations(generation,provider_kind,specification) VALUES(1,'e2b','{"resources":{"cpus":1,"memory_mib":1024}}')`); err != nil { + t.Fatal(err) + } + } + if _, err := migration.UpTo(ctx, 99); err != nil { + t.Fatal(err) + } + if _, err := migration.DownTo(ctx, 98); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/services/core/tests/integration/sandbox_specification_test.go b/services/core/tests/integration/sandbox_specification_test.go index e69631350..7fc521020 100644 --- a/services/core/tests/integration/sandbox_specification_test.go +++ b/services/core/tests/integration/sandbox_specification_test.go @@ -12,8 +12,9 @@ func SandboxDeploymentTestSpec(provider string) sandbox.DeploymentSpec { if provider == "e2b" { return s } - s.Runtime = &sandbox.RuntimeRelease{SourceCommit: strings.Repeat("a", 40), ImageID: "sha256:" + strings.Repeat("b", 64), ImageManifestDigest: "sha256:" + strings.Repeat("c", 64), MicrosandboxRef: "oac-runtime@sha256:" + strings.Repeat("d", 64), RuntimeSHA256: strings.Repeat("e", 64), FirmwareSHA256: strings.Repeat("f", 64)} + s.Runtime = &sandbox.RuntimeRelease{SourceCommit: strings.Repeat("a", 40), Artifacts: map[string]string{"image_id": "sha256:" + strings.Repeat("b", 64), "image_manifest_digest": "sha256:" + strings.Repeat("c", 64)}} if provider == "microsandbox" { + s.Runtime.Artifacts = map[string]string{"microsandbox_ref": "oac-runtime@sha256:" + strings.Repeat("d", 64), "runtime_sha256": strings.Repeat("e", 64), "firmware_sha256": strings.Repeat("f", 64)} s.Resources.RootDiskMiB = 8192 s.Resources.EnvironmentDiskMiB = 8192 }