diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index b3d4c5f74..4ce53087f 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -366,10 +366,10 @@ Dialogs are 448px Paper cards (960px when wide) with 8px corners, a 52px header - **ConfirmDialog**: the one grammar for destructive actions. The body states what will be deleted and its consequences; the footer holds Cancel (outline) and the confirm button (danger), whose label changes while busy. Core's reason for a rejection, or an uncertain-outcome warning, appears in red inside the dialog. The Skill page's delete dialogs follow the same grammar; deleting a whole Skill also requires typing its name. Archiving a project says in bold that it can't be undone, then how many active keys it revokes (the project read's count, or more when its loaded key list shows more) and that assets and accepted work stay; with active keys it too requires typing the project's name, shown in mono with its inner spaces kept (surrounding spaces are forgiven, Unicode compared in NFC). While the project list is read again Archive waits; if that read failed, a red line says the count may be out of date and Archive stays disabled. - **Key dialogs**: name fields carry their rules in a help tip and their problem in red underneath. The issued key appears in a read-only field with a copy button, under a notice that it is shown once; only "I've saved this key" dismisses it. Closing the dialog moves the key into a pending notice card on the page. - **Executor credential dialog** (640px): the shown-once notice, then a prompt to save the JSON privately before Done. Download credential file is primary; Copy credential is secondary. Installation commands are not repeated here. Done forgets the credential; closing preserves it in the pending card. The native installer reads the unchanged JSON file: its absolute path is entered during interactive installation or passed with `--credential-file`; tokens never enter command arguments. -- **Add node**: the sandbox limits first, then the one-time command in a Terminal block (expiry countdown and Copy command in its header), the three progress steps, and, once the installer's minute passes, an amber card with the reason and a copyable system-service log command. Below, the Host requirements Hairline disclosure is open until this browser has shown it once. Installation requires root or sudo, creates the `oac-node` system service, and serves one Core per host because nodes share the service account. The docker group's root-equivalent access is stated once, in **Use Docker instead of microsandbox?**. Do not expose an ordinary-user installation command or user-service prerequisites. The command downloads from the installation's public URL, never the browser's address, so it works as shown on any host. Until the installation is read, a line says it is being checked; a failed read, a public URL other machines can't use (loopback or not HTTPS), or a console without the provider's node files replaces the limits with one line saying why (the failed read with Try again), and the footer offers nothing to generate. Once the node is ready, while Getting started is open, one line under the green status names the next step (set a default model provider, or finish Getting started) with a text action to Settings → Models and credentials or the Overview. +- **Add node**: the Limits choice first (Recommended, which leaves the limits for Core to derive from the host once it enrolls, or Custom with the two numbers), then the one-time command in a Terminal block (expiry countdown and Copy command in its header), the three progress steps, and, once the installer's minute passes, an amber card with the reason and a copyable system-service log command. Below, the Host requirements Hairline disclosure is open until this browser has shown it once. Installation requires root or sudo, creates the `oac-node` system service, and serves one Core per host because nodes share the service account. The docker group's root-equivalent access is stated once, in **Use Docker instead of microsandbox?**. Do not expose an ordinary-user installation command or user-service prerequisites. The command downloads from the installation's public URL, never the browser's address, so it works as shown on any host. Until the installation is read, a line says it is being checked; a failed read, a public URL other machines can't use (loopback or not HTTPS), or a console without the provider's node files replaces the limits with one line saying why (the failed read with Try again), and the footer offers nothing to generate. Once the node is ready, while Getting started is open, one line under the green status names the next step (set a default model provider, or finish Getting started) with a text action to Settings → Models and credentials or the Overview. - **Clean up the host**: after a node is removed, a dialog gives the host's uninstall command in the same Terminal block, a Graphite line that it deletes no sandboxes, volumes or images; the uninstaller prints what it keeps. The command requires root or sudo; there is no user-service alternative. A node enrolled with an earlier Core address adds an "Old Core address gone?" disclosure with the `--force` form. The command, too, downloads from the public URL, which the dialog reads again if it is not at hand: until then one line says it is being checked, a failed read says so with Try again, and a public URL other machines can't use (loopback, or none) gets a line saying the service stays on the host and no command can be given. Done dismisses it and focus returns to the page heading. - **Use Docker instead of microsandbox?**: choosing Docker in sandbox setup lists what it gives up, each point a 600 Ink lead over a Graphite line: weaker isolation (containers share the host kernel; microsandbox gives each sandbox its own microVM), root-equivalent access (the node's account joins the docker group) and limited use (trusted workloads, or hosts without KVM). The footer holds Use Docker (outline) and Keep microsandbox (primary), which takes focus; closing or Escape keeps microsandbox too. -- **Edit node**: the name, then the sandbox limit with a question-mark help tip once the node's heartbeat has the host's CPUs and memory: the host, each sandbox's size and at most how many fit. The Nodes list and a node's Capacity show "Active / limit" for Docker and microsandbox alike, so a saved limit shows where it was set. +- **Edit node**: the name, then the Limits choice, Recommended showing the node's `recommended_capacity` while Core has one, or Custom with the numbers. The Nodes list and a node's Capacity show "Active / limit" for Docker and microsandbox alike, so a saved limit shows where it was set. - **How to call**: wherever a new key is shown, a card under it gives three copyable samples, each a Margin Gray block with a Hairline and its label and copy button in a header row: a Shell block exporting `OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (the new key) together, then curl and Python (with the pinned SDK), each listing the project's Agents and creating a Session with a first message (`environment`, an inline `agent` with `model: ""`, and `input`). A copy the clipboard refuses selects the sample and says so in red underneath. One Graphite line says to put a model the model provider serves in place of ``, and that running an Agent needs a model provider: in each request, saved on the Agent, or the deployment default. An active project's page shows the same samples as a section without any key: the Shell block exports a quoted placeholder, and a Graphite line above the samples says to use a key issued for this project, shown only once at issuance. When the public address is loopback, a note above the samples says the API is reachable only on the Core machine; without a public address only a note to set one shows. Before the installation is read, a skeleton holds the first sample's place. ### Navigation diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index a434ffeb5..e7320e85a 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -31,7 +31,7 @@ The console runs beside the administrator's own Core, with execution, files and - **Monitor**: Overview (service status, running Sessions, sandbox slots, the Needs attention count for the last 24 hours, 24-hour Session activity, a compact inventory of Core and up to four nodes, prioritizing offline and degraded nodes when the list is full, where Core opens Core metrics, a node opens its page on Nodes and E2B opens Sandbox metrics, the Needs attention table, whose rows name each Session, its project, its one-line reason and last activity and open it, with older ones left to the Session log, usage by project), Core metrics (whether Core can run Turns: execution slots, the Turn queue, its p95 wait and interrupted Turns, connected daemons, database latency, the Core process's CPU and memory, and background jobs; Go heap, goroutines and the database pool sit in a collapsed Diagnostics area), Agent metrics (requests, errors, duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d), Sandbox metrics (hosted Runtimes across projects over time, active against total; a sandbox opens in a dialog with its figures and CPU and memory charts; an E2B deployment adds what Core holds in E2B's cloud), Request log (recent public or administration HTTP requests, status, duration, safe error code and correlation IDs; bounded to the serving Core instance, with manual refresh and no body capture), Session log (every Session, read-only, with a failed Session's reason under its status, opening one Session's history, which jumps to its failed Turns; a self-hosted Session's page also has its environment's executor credentials). Session facts and Agent metrics link to an Agent's page only when that saved resource exists; inline snapshots and snapshots of deleted Agents remain readable without a broken resource link. Agent metrics' failed Turns open the Agent's Sessions in the Session log. - **Resources**: Agents, Environment templates, Skills, Files, Vaults. Each list shows one project or all projects, with a Project column when all are shown and a Creator column naming the creating key. Detail pages show the resource's facts and offer Delete. An empty list says, in one sentence, that applications create these resources through the Agents API, and links to that resource's section of the API guide; Agent metrics does the same for Sessions. -- **Platform**: Projects and keys (projects, their assets and usage, named keys, write history), Nodes (the node list, each node's capacity and limits, host figures and charts, allocations, Add node, Edit node and individual removal operations), and Settings, the single entry for platform configuration. Settings has four groups: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Sandbox and scheduling owns setup, resource edits, rollout, reset and the idle policy, and links to Nodes for nodes and their capacity. Add node on Nodes asks for limits before issuing its one-time command; installers use Core's public URL and require supported node artifacts. Edit node changes a node's name and limits; removal stays on the node page and offers the host's uninstall command. Models and credentials owns each Harness's default model and shows whether each Harness is ready: Ready when it is enabled and has a default model provider, otherwise the missing step, Model provider not set (with Set as the primary action) or Disabled (with a link to the startup settings). Getting started uses the same readiness. Service and deployment owns installation facts (the public and API base URLs, with the installation ID and source commit behind Installation details), Domain and HTTPS actions and read-only startup settings, each named by its environment variable, with a link to the configuration guide's section on changing them. +- **Platform**: Projects and keys (projects, their assets and usage, named keys, write history), Nodes (the node list, each node's capacity and limits, host figures and charts, allocations, Add node, Edit node and individual removal operations), and Settings, the single entry for platform configuration. Settings has four groups: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Sandbox and scheduling owns setup, resource edits, rollout, reset and the idle policy, and links to Nodes for nodes and their capacity. Add node on Nodes offers Recommended or Custom limits before issuing its one-time command; installers use Core's public URL and require supported node artifacts. Edit node changes a node's name and limits; removal stays on the node page and offers the host's uninstall command. Models and credentials owns each Harness's default model and shows whether each Harness is ready: Ready when it is enabled and has a default model provider, otherwise the missing step, Model provider not set (with Set as the primary action) or Disabled (with a link to the startup settings). Getting started uses the same readiness. Service and deployment owns installation facts (the public and API base URLs, with the installation ID and source commit behind Installation details), Domain and HTTPS actions and read-only startup settings, each named by its environment variable, with a link to the configuration guide's section on changing them. - A node whose provider is not ready names the reason as one Provider-neutral readiness class (provider unavailable, host unsupported, provider files or Runtime image missing, a host too small) and its fix in the help tip beside its status, wherever that status shows. - A node enrolled with an earlier Core address gets no new sandboxes, so on the Nodes list and its page its status is Old address, with "Remove and add again", never Available. - **Sandbox reset** is an explicit administrator operation in Settings → Sandbox and scheduling. Auto clear is the default, with a one-hour deadline (5 minutes–24 hours); Force clear requires destructive confirmation. Reset stops new hosted Session admission, clears idle, suspended and pending hosted work, and waits for busy Turns and file writes until Core forces the remaining work. It does not affect self-hosted execution. Histories and persisted Files/Artifacts remain; archived Sessions cannot resume, and unpersisted workspace contents may be lost. Cancel stops further clearing without undoing archives. Core alone reports progress and completion, including resources blocked on named offline nodes; force does not bypass their cleanup. Completion clears the backend configuration and retires old nodes/enrollment credentials. A new configuration is then a separate deliberate save. diff --git a/apps/web/e2e/data/routes.mjs b/apps/web/e2e/data/routes.mjs index 48ac025cd..535af7a62 100644 --- a/apps/web/e2e/data/routes.mjs +++ b/apps/web/e2e/data/routes.mjs @@ -84,9 +84,9 @@ export function buildDemo(now = Math.floor(Date.now() / 1000), publicUrl = "http }); sessions.sort((a, b) => b.created_at - a.created_at); const nodes = [ - { rollout: { state: "ready", ready_generation: 1 }, id: "node-local", name: "core-01", provider: "docker", online: true, provider_ready: true, cpu_count: 16, available_memory_bytes: 38 * 2 ** 30, available_disk_bytes: 410 * 2 ** 30, running: 5, snapshots: 2, last_seen_at: new Date((now - 8) * 1000).toISOString(), max_active: 8, max_retained: 16, active: 5, reserved: 1, retained: 2, cleanup_pending: 0, created_at: new Date((now - 86400 * 30) * 1000).toISOString() }, - { rollout: { state: "failed", ready_generation: null, diagnostic: "host_unsupported" }, id: "node-gpu", name: "gpu-worker-02", provider: "docker", online: true, provider_ready: false, cpu_count: 32, available_memory_bytes: 12 * 2 ** 30, available_disk_bytes: 96 * 2 ** 30, running: 7, snapshots: 5, last_seen_at: new Date((now - 12) * 1000).toISOString(), max_active: 8, max_retained: 16, active: 7, reserved: 0, retained: 5, cleanup_pending: 1, created_at: new Date((now - 86400 * 12) * 1000).toISOString() }, - { rollout: { state: "unknown", ready_generation: 1 }, id: "node-edge", name: "edge-03", provider: "docker", online: false, provider_ready: false, cpu_count: 8, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: new Date((now - 5400) * 1000).toISOString(), max_active: 4, max_retained: 8, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date((now - 86400 * 3) * 1000).toISOString() }, + { rollout: { state: "ready", ready_generation: 1 }, id: "node-local", name: "core-01", provider: "docker", online: true, provider_ready: true, cpu_count: 16, available_memory_bytes: 38 * 2 ** 30, available_disk_bytes: 410 * 2 ** 30, running: 5, snapshots: 2, last_seen_at: new Date((now - 8) * 1000).toISOString(), max_active: 8, max_retained: 16, recommended_capacity: { max_active: 6, max_retained: 6 }, active: 5, reserved: 1, retained: 2, cleanup_pending: 0, created_at: new Date((now - 86400 * 30) * 1000).toISOString() }, + { rollout: { state: "failed", ready_generation: null, diagnostic: "host_unsupported" }, id: "node-gpu", name: "gpu-worker-02", provider: "docker", online: true, provider_ready: false, cpu_count: 32, available_memory_bytes: 12 * 2 ** 30, available_disk_bytes: 96 * 2 ** 30, running: 7, snapshots: 5, last_seen_at: new Date((now - 12) * 1000).toISOString(), max_active: 8, max_retained: 16, recommended_capacity: null, active: 7, reserved: 0, retained: 5, cleanup_pending: 1, created_at: new Date((now - 86400 * 12) * 1000).toISOString() }, + { rollout: { state: "unknown", ready_generation: 1 }, id: "node-edge", name: "edge-03", provider: "docker", online: false, provider_ready: false, cpu_count: 8, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: new Date((now - 5400) * 1000).toISOString(), max_active: 4, max_retained: 8, recommended_capacity: null, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date((now - 86400 * 3) * 1000).toISOString() }, ]; const hosted = sessions.filter((session) => session.environment.type === "openai_hosted"); const allocations = hosted.map((session, index) => { diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 0922789b8..abf97bdcd 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -517,7 +517,7 @@ async function executorCredentialRoute(request, response, projectId, environment */ function registeredNode(nodeId) { const { enrollment_id = null, ...limits } = state.enrollment ?? { max_active: 1, max_retained: 1 }; - return { rollout: { state: "unknown", ready_generation: null }, id: nodeId, name: nodeId, provider: state.deployment?.provider ?? "docker", core_url: publicUrl(), enrollment_id, online: false, provider_ready: false, cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: null, ...limits, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date().toISOString() }; + return { rollout: { state: "unknown", ready_generation: null }, id: nodeId, name: nodeId, provider: state.deployment?.provider ?? "docker", core_url: publicUrl(), enrollment_id, online: false, provider_ready: false, cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: null, recommended_capacity: null, ...limits, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date().toISOString() }; } const HARNESS_PROVIDER = /^\/harnesses\/([^/]+)\/model-configuration$/; diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 09e9f8c0c..5fb60fca2 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -24,6 +24,7 @@ test("adds a node: host requirements, a root/sudo command, a countdown, the same // Node installation only offers a system service; there is no user-mode alternative. await expect(add.getByText("No sudo on this host?")).toHaveCount(0); await expect(add.getByLabel("One-time enrollment command without sudo", { exact: true })).toHaveCount(0); + await add.getByRole("radio", { name: "Custom" }).click(); await add.getByLabel("Sandboxes at once").fill("3"); const tokenRequest = () => page.waitForRequest((sent) => sent.method() === "POST" && sent.url().endsWith("/core/v1/sandbox/enrollment-tokens")); const issued = tokenRequest(); @@ -99,9 +100,19 @@ test("adds a node: host requirements, a root/sudo command, a countdown, the same await expect(progress).toHaveText("edge-04 · Connected"); await add.getByRole("button", { name: "Done" }).click(); await expect(add).toBeHidden(); - // A finished flow leaves no limits behind: the next node starts from the defaults. + // A finished flow leaves no limits behind: the next node starts from the recommended ones. await page.getByRole("button", { name: "Add node" }).click(); - await expect(add.getByLabel("Sandboxes at once")).toHaveValue("2"); + await expect(add.getByRole("radio", { name: "Recommended" })).toBeChecked(); + await expect(add.getByLabel("Sandboxes at once")).toHaveCount(0); +}); + +test("asks Core to recommend a new node's limits unless the administrator enters them", async ({ page, request }) => { + await openConsole(page, request, "nodes"); + await page.getByRole("button", { name: "Add node" }).click(); + const add = page.getByRole("dialog", { name: "Add node" }); + const issued = page.waitForRequest((sent) => sent.method() === "POST" && sent.url().endsWith("/core/v1/sandbox/enrollment-tokens")); + await add.getByRole("button", { name: "Generate command" }).click(); + expect((await issued).postDataJSON()).toEqual({}); }); test("issues no command before the installation is read, for a loopback public URL or without node files, and sees a fix on reopening", async ({ page, request }) => { @@ -201,8 +212,8 @@ test("sets up own-machine sandboxes page by page", async ({ page, request }) => await expect(page.getByRole("heading", { name: "Review and save" })).toBeVisible(); await page.getByRole("button", { name: "Save configuration" }).click(); - // Own machines continue straight to adding the first node, at its limits: no command is issued yet. - await expect(page.getByRole("dialog", { name: "Add node" }).getByLabel("Sandboxes at once")).toBeVisible(); + // Own machines continue straight to adding the first node, with Recommended limits: no command is issued yet. + await expect(page.getByRole("dialog", { name: "Add node" }).getByRole("radio", { name: "Recommended" })).toBeChecked(); const add = page.getByRole("dialog", { name: "Add node" }); await expect(add.getByText("No sudo on this host?")).toHaveCount(0); // None is requested within a second of opening, and Core saw only the deployment write. @@ -357,7 +368,9 @@ test("renames a node and sets how many sandboxes run on it at once", async ({ pa await page.getByRole("row").filter({ hasText: "core-01" }).getByRole("button", { name: "Edit node" }).click(); const edit = page.getByRole("dialog", { name: "Edit node" }); await edit.getByLabel("Name").fill("core-01-large"); - await edit.getByLabel("Sandboxes at once").fill("6"); + await expect(edit).toContainText("Recommended: 6 at once."); + await edit.getByRole("button", { name: "Use recommended" }).click(); + await expect(edit.getByLabel("Sandboxes at once")).toHaveValue("6"); await edit.getByRole("button", { name: "Save" }).click(); await expect(edit).toBeHidden(); await expect(capacity).toContainText("core-01-large"); diff --git a/apps/web/src/features/overview/test-fixtures.ts b/apps/web/src/features/overview/test-fixtures.ts index e11dfcea3..135f60cae 100644 --- a/apps/web/src/features/overview/test-fixtures.ts +++ b/apps/web/src/features/overview/test-fixtures.ts @@ -39,7 +39,7 @@ export function node(id: string, overrides: Partial = {}): SandboxN rollout: { state: overrides.online === false ? "unknown" : "ready", ready_generation: 1 }, cpu_count: 8, available_memory_bytes: 1024, available_disk_bytes: 2048, running: 1, snapshots: 0, last_seen_at: "2026-09-24T00:00:00Z", - max_active: 4, max_retained: 8, active: 1, reserved: 0, retained: 0, cleanup_pending: 0, + max_active: 4, max_retained: 8, recommended_capacity: null, active: 1, reserved: 0, retained: 0, cleanup_pending: 0, created_at: "2026-09-01T00:00:00Z", core_url: "https://core.example", enrollment_id: null, ...overrides, }; diff --git a/apps/web/src/features/sandbox/NodeCapacityFields.tsx b/apps/web/src/features/sandbox/NodeCapacityFields.tsx index 67b21e05f..24e100866 100644 --- a/apps/web/src/features/sandbox/NodeCapacityFields.tsx +++ b/apps/web/src/features/sandbox/NodeCapacityFields.tsx @@ -1,4 +1,3 @@ -import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { HelpTip } from "../../components/console-ui"; @@ -11,14 +10,14 @@ export function nodeCapacity(active: string, retained: string) { retainedError: !valid(maxRetained) ? "Enter a whole number from 1 to 1,000,000." as const : valid(maxActive) && maxRetained < maxActive ? "Enter at least the number of sandboxes at once." as const : null }; } -export function NodeCapacityFields({ id, active, retained, suspends, activeError, retainedError, onActive, onRetained, autoFocus, help }: { +export function NodeCapacityFields({ id, active, retained, suspends, activeError, retainedError, onActive, onRetained, autoFocus }: { id: string; active: string; retained: string; suspends: boolean; activeError: string | null; retainedError: string | null; - onActive: (value: string) => void; onRetained: (value: string) => void; autoFocus?: boolean; help?: ReactNode; + onActive: (value: string) => void; onRetained: (value: string) => void; autoFocus?: boolean; }) { const { t } = useTranslation("sandbox"); return <>
- {t("The most sandboxes Core places on this node at the same time.")}{help} + {t("The most sandboxes Core places on this node at the same time.")} onActive(event.target.value)} aria-invalid={Boolean(activeError)} aria-errormessage={activeError ? `${id}-active-error` : undefined} /> {activeError ? {activeError} : null}
diff --git a/apps/web/src/features/sandbox/NodeEditDialog.tsx b/apps/web/src/features/sandbox/NodeEditDialog.tsx index 554bbb41c..7768dbad2 100644 --- a/apps/web/src/features/sandbox/NodeEditDialog.tsx +++ b/apps/web/src/features/sandbox/NodeEditDialog.tsx @@ -1,29 +1,22 @@ import { useId, useState } from "react"; -import type { SandboxAdminClient, SandboxNode, SandboxResources } from "@oac/agents-client"; -import { useQuery } from "@tanstack/react-query"; +import type { SandboxAdminClient, SandboxNode } from "@oac/agents-client"; import { useTranslation } from "react-i18next"; import { nodeCapacity, NodeCapacityFields } from "./NodeCapacityFields"; import { Modal } from "../../components/Modal"; import { coreFieldError } from "../../lib/core-error"; -import { formatBytes } from "../../lib/format"; import { sandboxRequestError } from "../../lib/sandbox-labels"; -import { nodeDetailQuery } from "../fleet/fleet-queries"; -import { sandboxesThatFit } from "./deployment-specification"; /** * A node's name and sandbox limits (`PATCH /core/v1/sandbox/nodes/{id}`). Core * takes all three together. Only a deployment whose Provider suspends sandboxes * shows the retained limit; otherwise the saved one is kept, raised to at least - * the active limit because Core requires it. Under the limit, the host's CPUs - * and memory from the node's last heartbeat, each sandbox's size and how many - * of those the host holds. + * the active limit because Core requires it. Beneath the fields, Core's + * recommendation for the node's host, which one action fills in. */ -export function NodeEditDialog({ client, node, size, suspends, onClose, onSaved }: { +export function NodeEditDialog({ client, node, suspends, onClose, onSaved }: { client: SandboxAdminClient; node: SandboxNode | null; - /** Each sandbox's CPUs and memory, from the deployment. */ - size: SandboxResources | null; /** Whether the deployment's Provider suspends sandboxes, which its suspension policy declares. */ suspends: boolean; onClose: () => void; @@ -34,8 +27,8 @@ export function NodeEditDialog({ client, node, size, suspends, onClose, onSaved const { t: tCommon } = useTranslation("common"); const id = useId(); const [name, setName] = useState(node?.name ?? ""); - const [active, setActive] = useState(String(node?.max_active ?? 2)); - const [retained, setRetained] = useState(String(node?.max_retained ?? 8)); + const [active, setActive] = useState(String(node?.max_active ?? "")); + const [retained, setRetained] = useState(String(node?.max_retained ?? "")); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const limits = nodeCapacity(active, suspends ? retained : String(Math.max(node?.max_retained ?? 0, Number(active) || 0))); @@ -48,19 +41,7 @@ export function NodeEditDialog({ client, node, size, suspends, onClose, onSaved const activeError = coreFieldError(error, "max_active", tCommon) ?? activeProblem; const retainedError = coreFieldError(error, "max_retained", tCommon) ?? retainedProblem; const ready = node !== null && !nameProblem && !activeProblem && !retainedProblem && !busy; - // The node detail read adds the host's total memory to its CPU count. - const detail = useQuery({ ...nodeDetailQuery(node?.id ?? "", "1h"), enabled: node !== null }); - const host = detail.data?.id === node?.id ? detail.data?.host : undefined; - const hostCpus = host?.effective_cpu_cores ?? node?.cpu_count ?? null; - const hostMemory = host?.total_memory_bytes ?? null; - const fit = sandboxesThatFit({ cpus: hostCpus, memoryBytes: hostMemory }, size); - const measure = (cpus: number, memory: number) => t("{{cpus}} CPU · {{memory}}", { cpus, memory: formatBytes(memory) }); - // Sentences run on with a space in English and without one in Chinese. - const hostFacts = hostCpus !== null && hostMemory !== null ? [ - t("Host: {{host}}.", { host: measure(hostCpus, hostMemory) }), - ...(size ? [t("Each sandbox: {{size}}.", { size: measure(size.cpus, size.memory_mib * 2 ** 20) })] : []), - ...(fit !== null && fit > 0 ? [t("Suggested: at most {{count}} at once.", { count: fit })] : []), - ].join(locale === "zh-CN" ? "" : " ") : null; + const recommended = node?.recommended_capacity ?? null; async function save() { if (!ready || !node) return; @@ -92,7 +73,11 @@ export function NodeEditDialog({ client, node, size, suspends, onClose, onSaved { setName(event.target.value); setError(null); }} autoComplete="off" aria-invalid={Boolean(nameError)} aria-describedby={nameError ? `${id}-name-error` : undefined} /> {nameError ? {nameError} : null} - {hostFacts} : null} onActive={(value) => { setActive(value); setError(null); }} onRetained={(value) => { setRetained(value); setError(null); }} /> + { setActive(value); setError(null); }} onRetained={(value) => { setRetained(value); setError(null); }} /> + {recommended ?

+ {suspends ? t("Recommended: {{active}} at once, {{retained}} retained.", { active: recommended.max_active, retained: recommended.max_retained }) : t("Recommended: {{active}} at once.", { active: recommended.max_active })}{" "} + +

: null} {error !== null ?

{sandboxRequestError(error, locale)}

: null} diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index 164a65e2b..3b46d2d83 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useId, useRef, useState } from "react"; import type { SandboxAdminClient, SandboxDeployment, SandboxEnrollment, SandboxNode } from "@oac/agents-client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; +import { HelpTip, SegmentedControl, StatusDot, type Tone } from "../../components/console-ui"; import { Modal } from "../../components/Modal"; import { coreFieldError } from "../../lib/core-error"; import { formatBytes } from "../../lib/format"; @@ -30,13 +30,10 @@ function rememberRequirementsSeen() { try { window.localStorage.setItem(REQUIREMENTS_SEEN, "1"); } catch { /* Storage can be unavailable; the list then opens each time. */ } } -/** The limits a new flow starts from. */ -const DEFAULT_ACTIVE = "2"; -const DEFAULT_RETAINED = "8"; - /** - * Add node: the administrator sets the node's sandbox limits, then Core issues a - * one-time enrollment command that approves them + * Add node: the administrator keeps the recommended limits, which Core derives + * from the host when the node registers, or enters both numbers; then Core + * issues a one-time enrollment command * (`POST /core/v1/sandbox/enrollment-tokens`). Only a deployment with a * suspension policy, which its Provider's checkpoint support declares, asks for * a retained limit; otherwise a node retains exactly the sandboxes it runs at @@ -73,8 +70,9 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const { t: tCommon } = useTranslation("common"); const id = useId(); - const [active, setActive] = useState(DEFAULT_ACTIVE); - const [retained, setRetained] = useState(DEFAULT_RETAINED); + const [custom, setCustom] = useState(false); + const [active, setActive] = useState(""); + const [retained, setRetained] = useState(""); const [busy, setBusy] = useState(false); const [enrollment, setEnrollment] = useState(null); const [appeared, setAppeared] = useState<{ id: string; at: number } | null>(null); @@ -116,9 +114,10 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, const retainedLimit = limits.retained; const activeProblem = limits.activeError ? t(limits.activeError) : null; const retainedProblem = suspends && limits.retainedError ? t(limits.retainedError) : null; - const activeError = coreFieldError(error, "max_active", tCommon) ?? activeProblem; - const retainedError = coreFieldError(error, "max_retained", tCommon) ?? retainedProblem; - const limitsReady = !activeProblem && !retainedProblem; + // An empty field is not an error yet; it only keeps Generate command off. + const activeError = coreFieldError(error, "max_active", tCommon) ?? (active.trim() ? activeProblem : null); + const retainedError = coreFieldError(error, "max_retained", tCommon) ?? (retained.trim() ? retainedProblem : null); + const limitsReady = !custom || (!activeProblem && !retainedProblem); const node = enrollment ? enrolledNode(nodes, enrollment) : null; const progress = enrollmentProgress(node, node && appeared?.id === node.id ? appeared.at : undefined, now); // Registration uses the token, so from then on its expiry no longer matters; rerunning @@ -188,7 +187,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, // A connected node ends the command; otherwise it waits here for the next opening. if (ready) { setEnrollment(null); setAppeared(null); } // The limits, and which command was copied, stay only with an unfinished flow: a command still waiting for its node. - if (!enrollment || ready) { setActive(DEFAULT_ACTIVE); setRetained(DEFAULT_RETAINED); } + if (!enrollment || ready) { setCustom(false); setActive(""); setRetained(""); } onClose(); } /** @@ -201,8 +200,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, setEnrollment(null); setAppeared(null); setError(null); } async function generate() { - if (request.current || !fresh || !available || blocker || !limitsReady || activeLimit === null || retainedLimit === null) return; - const capacity = { max_active: activeLimit, max_retained: retainedLimit }; + if (request.current || !fresh || !available || blocker || !limitsReady) return; + const capacity = custom ? { max_active: activeLimit!, max_retained: retainedLimit! } : {}; generation.current++; const controller = new AbortController(); request.current = controller; setBusy(true); setError(null); @@ -270,8 +269,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, :

{blocker.text}

: !enrollment ? (
{ event.preventDefault(); void generate(); }}> -

{t("Set the sandbox limits for the host you want to add.")}

- { setActive(value); setError(null); }} onRetained={(value) => { setRetained(value); setError(null); }} /> + { setCustom(value === "custom"); setError(null); }} /> + {custom ? { setActive(value); setError(null); }} onRetained={(value) => { setRetained(value); setError(null); }} /> : null} {error !== null ?

{sandboxRequestError(error, locale)}

: null} {requirements} diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 4995d8654..1406a33f0 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -18,7 +18,6 @@ import { NodeList, onOldAddress } from "./NodeList"; import { NodeDetail } from "./NodeDetail"; import { NodeEnrollment } from "./NodeEnrollment"; import { NodeEditDialog } from "./NodeEditDialog"; -import { sandboxSize } from "./deployment-specification"; import { NodeCleanupDialog, type NodeCleanup } from "./NodeCleanupDialog"; import "./SandboxManagerView.css"; @@ -142,7 +141,6 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig key={editTarget?.id ?? "closed"} client={client} node={editTarget} - size={snapshot ? sandboxSize(snapshot.deployment) : null} suspends={suspends} onClose={() => setEditId(null)} onSaved={() => { diff --git a/apps/web/src/features/sandbox/deployment-specification.test.ts b/apps/web/src/features/sandbox/deployment-specification.test.ts index 9b6649e1f..5cd0f464b 100644 --- a/apps/web/src/features/sandbox/deployment-specification.test.ts +++ b/apps/web/src/features/sandbox/deployment-specification.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { defaultSandboxResources, sandboxesThatFit, savedSpecification, selectedWorkspace, validSandboxResources } from "./deployment-specification"; +import { defaultSandboxResources, savedSpecification, selectedWorkspace, validSandboxResources } from "./deployment-specification"; import { deploymentContract, type SandboxProvider, type SandboxSpecification } from "@oac/agents-client"; describe("deployment resources", () => { @@ -54,13 +54,3 @@ describe("deployment resources", () => { expect(selectedWorkspace("docker", { tenant_capacity_mib: 0 })).toBeUndefined(); }); }); - -describe("sandboxes a host holds", () => { - it("is the smaller of what its CPUs and its memory hold, and unknown without either or the size", () => { - const size = { cpus: 2, memory_mib: 4096 }; - expect(sandboxesThatFit({ cpus: 16, memoryBytes: 64 * 2 ** 30 }, size)).toBe(8); - expect(sandboxesThatFit({ cpus: 64, memoryBytes: 18 * 2 ** 30 }, size)).toBe(4); - expect(sandboxesThatFit({ cpus: null, memoryBytes: 64 * 2 ** 30 }, size)).toBeNull(); - expect(sandboxesThatFit({ cpus: 16, memoryBytes: 64 * 2 ** 30 }, null)).toBeNull(); - }); -}); diff --git a/apps/web/src/features/sandbox/deployment-specification.ts b/apps/web/src/features/sandbox/deployment-specification.ts index 3a62c15cf..229ce3b78 100644 --- a/apps/web/src/features/sandbox/deployment-specification.ts +++ b/apps/web/src/features/sandbox/deployment-specification.ts @@ -45,13 +45,3 @@ export function templateBuildStatus(build: SandboxE2BTemplateBuild | undefined): export function sandboxSize(deployment: SandboxDeployment): SandboxResources | null { return deployment.specification?.resources ?? templateBuildSize(deployment); } - -/** - * At most how many sandboxes of this size a host's CPUs and memory hold at once, - * each at its full limits; null while a figure or the size is unknown. A - * suggestion for a node's limit, which Core itself never derives. - */ -export function sandboxesThatFit(host: { cpus: number | null; memoryBytes: number | null }, size: Pick | null): number | null { - if (!size || host.cpus === null || host.memoryBytes === null || size.cpus <= 0 || size.memory_mib <= 0) return null; - return Math.min(Math.floor(host.cpus / size.cpus), Math.floor(host.memoryBytes / (size.memory_mib * 2 ** 20))); -} diff --git a/apps/web/src/i18n/locales/zh-CN/sandbox.ts b/apps/web/src/i18n/locales/zh-CN/sandbox.ts index f9cae0b04..8095689ae 100644 --- a/apps/web/src/i18n/locales/zh-CN/sandbox.ts +++ b/apps/web/src/i18n/locales/zh-CN/sandbox.ts @@ -135,7 +135,6 @@ export const sandbox = { "Connection status unavailable. Refresh to check your node.": "连接状态暂不可用,请刷新以查看节点。", "Generate a new command to continue.": "请生成新命令后继续。", "Generate new command": "生成新命令", - "Set the sandbox limits for the host you want to add.": "为要添加的主机设置沙箱上限。", "Generate command": "生成命令", "Change limits": "修改上限", "Add another node": "添加另一个节点", @@ -200,9 +199,11 @@ export const sandbox = { "Health": "健康状态", "Active / limit": "活跃 / 上限", "Sandboxes Core has placed on this node, against the most it places here at once.": "Core 在这台节点上放置的沙箱数,相对于同时运行的上限。", - "Host: {{host}}.": "主机:{{host}}。", - "Each sandbox: {{size}}.": "每个沙箱:{{size}}。", - "Suggested: at most {{count}} at once.": "建议同时运行不超过 {{count}} 个。", + "Limits": "上限", + "Custom": "自定义", + "Use recommended": "使用推荐值", + "Recommended: {{active}} at once, {{retained}} retained.": "推荐:同时运行 {{active}} 个,保留 {{retained}} 个。", + "Recommended: {{active}} at once.": "推荐:同时运行 {{active}} 个。", "Reserved": "已预留", "Retained / limit": "保留 / 上限", "Cleanup pending": "待清理", diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 32dcd2874..208eb6620 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -772,6 +772,7 @@ definitions: api.SandboxEnrollmentTokenRequest: properties: max_active: + description: An omitted limit is derived from the node's host when it enrolls. type: integer max_retained: description: Docker never suspends, so Core replaces this with max_active; microsandbox uses both limits. @@ -1219,6 +1220,16 @@ definitions: - nodes_on_other_address - self_hosted_executors type: object + deployment.Capacity: + properties: + max_active: + type: integer + max_retained: + type: integer + required: + - max_active + - max_retained + type: object deployment.HostHistory: properties: points: @@ -1294,6 +1305,11 @@ definitions: provider_ready: description: True while the node is online and its serving generation is ready. type: boolean + recommended_capacity: + allOf: + - $ref: '#/definitions/deployment.Capacity' + description: The limits Core recommends for the node's latest host measurements and the current specification; null before the node reports them. + x-nullable: true reserved: type: integer retained: @@ -1321,6 +1337,7 @@ definitions: - online - provider - provider_ready + - recommended_capacity - reserved - retained - rollout @@ -1424,6 +1441,11 @@ definitions: provider_ready: description: True while the node is online and its serving generation is ready. type: boolean + recommended_capacity: + allOf: + - $ref: '#/definitions/deployment.Capacity' + description: The limits Core recommends for the node's latest host measurements and the current specification; null before the node reports them. + x-nullable: true reserved: type: integer retained: @@ -1453,6 +1475,7 @@ definitions: - online - provider - provider_ready + - recommended_capacity - reserved - retained - rollout diff --git a/contracts/agents-api/machine-api.md b/contracts/agents-api/machine-api.md index 75e932e77..7c95e5c85 100644 --- a/contracts/agents-api/machine-api.md +++ b/contracts/agents-api/machine-api.md @@ -27,7 +27,7 @@ The generated [`runtime.openapi.yaml`](./runtime.openapi.yaml) describes only th | Credential | Issued by | Accepted on | | --- | --- | --- | -| Enrollment token | `POST /core/v1/sandbox/enrollment-tokens` (Web **Add node**), with the node's approved capacity. One use; it expires at the response's `expires_at` | `sandbox-node/configuration` without a node ID, `sandbox-node/enroll` | +| Enrollment token | `POST /core/v1/sandbox/enrollment-tokens` (Web **Add node**), with optional node limits. One use; it expires at the response's `expires_at` | `sandbox-node/configuration` without a node ID, `sandbox-node/enroll` | | Node credential | The node itself: it generates a secret of 32 to 256 characters without whitespace and registers it at enrollment | `sandbox-node/configuration` with `X-OAC-Node-ID`, `sandbox-node/identity`, `sandbox-node/connect` | | Installation grant | The `x_agents_core.installation` command of a `self_hosted` Session; short-lived | `agent-daemon/installation` and its `claim` | | Executor credential | The installation claim, or the Core-key routes in [Executor credentials](./environment-executor-credentials.md) | `agent-daemon/enroll` and `agent-daemon/connection`; after enrollment it is also the daemon credential of the bound device | @@ -58,7 +58,7 @@ oac-daemon connect --profile default - A new node sends its enrollment token without `X-OAC-Node-ID`. The token must be valid, unexpired, unconsumed and issued by this installation. An active reset refuses this read. - A registered node sends its node credential and its UUID in `X-OAC-Node-ID`. Without a query it reads the current target. `?generation=N` reads only a generation this node may still need: the current target, its serving pin, or one held by an unreleased allocation or placement on it; any other generation is refused. This read stays available during a reset, for owned recovery. -The response has `installation_id`, `provider`, `core_url` (the installation public URL), `generation`, `specification`, `specification_digest`, `max_active` and `max_retained`. It never contains an administrator, Project or E2B credential, and exists only for node-backed providers. The [sandbox deployment contract](./sandbox-deployment.md#canonical-node-specification) defines the specification and its digest. +The response has `installation_id`, `provider`, `core_url` (the installation public URL), `generation`, `specification` and `specification_digest`. It never contains an administrator, Project or E2B credential, and exists only for node-backed providers. The [sandbox deployment contract](./sandbox-deployment.md#canonical-node-specification) defines the specification and its digest. ### Enroll a node @@ -73,8 +73,9 @@ The response has `installation_id`, `provider`, `core_url` (the installation pub | `backend_fingerprint` | The node's backend namespace digest | | `deployment_generation`, `specification_digest` | The configuration the node read | | `core_url` | The Core origin the node stores and connects to | +| `cpu_count`, `effective_cpu_cores`, `total_memory_bytes`, `available_disk_bytes` | Optional host measurements, as the [node protocol](./node-generation-protocol.md#connection) defines | -Core checks, in one transaction, that the token is valid, the deployment is initialized, node-backed and not resetting, the generation and digest match the current specification, `core_url` equals the installation public URL and the node ID is new. Only then does it register the node, with the capacity approved in the token, and consume the token. The 201 response is the node identity: `node_id`, `installation_id`, `provider`, `deployment_generation`, `specification_digest`, `max_active` and `max_retained`. The node cannot submit capacity; the enrolled generation and digest stay the node's immutable identity, and later generations use separate configurations. +Core checks, in one transaction, that the token is valid, the deployment is initialized, node-backed and not resetting, the generation and digest match the current specification, `core_url` equals the installation public URL and the node ID is new. Only then does it register the node, with the limits the token sets and, for each it leaves unset, the [recommended capacity](./sandbox-deployment.md#recommended-capacity) of the reported host, and consume the token. The 201 response is the node identity: `node_id`, `installation_id`, `provider`, `deployment_generation`, `specification_digest`, `max_active` and `max_retained`. The node cannot submit limits, but a limit the token leaves unset follows the host measurements the node reports, bounded by 1 and 1000000; explicit values and later edits bind. The enrolled generation and digest stay the node's immutable identity, and later generations use separate configurations. ### Recover a node's identity @@ -89,6 +90,7 @@ Core checks, in one transaction, that the token is valid, the deployment is init | 401 | `invalid_node_credential` | A missing, invalid, expired, consumed or foreign token or node credential | | 409 | `sandbox_specification_mismatch` | The node's generation or digest does not match | | 409 | `sandbox_node_address_mismatch` | `core_url` is not the installation public URL; the token stays unused | +| 409 | `sandbox_node_host_unmeasured` | The token leaves a limit unset and the request lacks a CPU count or effective cores, or total memory; the token stays unused | | 409 | `idempotency_conflict` | The node ID is already registered | | 409 | `sandbox_reset_in_progress` | Enrollment or a new node's configuration read during a reset | | 503 | `runtime_node_unavailable` | The deployment is not initialized, or storage is unavailable | diff --git a/contracts/agents-api/node-generation-protocol.md b/contracts/agents-api/node-generation-protocol.md index 2d6976860..e7dcc3156 100644 --- a/contracts/agents-api/node-generation-protocol.md +++ b/contracts/agents-api/node-generation-protocol.md @@ -50,7 +50,7 @@ sequenceDiagram Core counts a node as online while it is connected under the current owner epoch and its last heartbeat is less than 45 seconds old. A heartbeat establishes provider readiness and the last host measurements, never Session activity. [Timeouts and limits](#timeouts-and-limits) lists every timer. -Health carries `observed_at`, `active_operations` (at most 32) and the host measurements that the [Runtime telemetry API](./runtime-observability-api.md#node-host-observations-and-history) reports. A node reports readiness per generation, as described below. A `diagnostic` in a generation observation that is not a declared class makes the frame invalid. The [nodes guide](../../docs/getting-started/nodes.md#readiness-codes) lists the classes and their causes. +Health carries `observed_at`, `active_operations` (at most 32) and the host measurements that the [Runtime telemetry API](./runtime-observability-api.md#node-host-observations-and-history) reports. The enrollment request carries the same measurements that the node takes for health, `cpu_count`, `effective_cpu_cores`, `total_memory_bytes` and `available_disk_bytes`, each omitted when the node cannot measure it and validated like a health report. Core derives from them every limit the enrollment token leaves unset, as [Recommended capacity](./sandbox-deployment.md#recommended-capacity) defines. A node reports readiness per generation, as described below. A `diagnostic` in a generation observation that is not a declared class makes the frame invalid. The [nodes guide](../../docs/getting-started/nodes.md#readiness-codes) lists the classes and their causes. ## Provider requests diff --git a/contracts/agents-api/runtime.openapi.yaml b/contracts/agents-api/runtime.openapi.yaml index 1ab2fdab4..1f8718377 100644 --- a/contracts/agents-api/runtime.openapi.yaml +++ b/contracts/agents-api/runtime.openapi.yaml @@ -38,15 +38,22 @@ definitions: type: object deployment.Enrollment: properties: + available_disk_bytes: + type: integer backend_fingerprint: type: string core_url: description: The Core origin this node stores and connects to, such as https://core.example. It must equal the installation public URL; otherwise enrollment gets 409 sandbox_node_address_mismatch and the token stays unused. type: string + cpu_count: + description: Host measurements as in the node's health reports. Core derives a limit the enrollment token leaves unset from them. + type: integer credential: type: string deployment_generation: type: integer + effective_cpu_cores: + type: number name: type: string node_id: @@ -55,6 +62,8 @@ definitions: type: string specification_digest: type: string + total_memory_bytes: + type: integer type: object deployment.NodeConfiguration: properties: @@ -64,10 +73,6 @@ definitions: type: integer installation_id: type: string - max_active: - type: integer - max_retained: - type: integer provider: type: string specification: diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index fee77fb2d..c72e22920 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -19,7 +19,7 @@ Every route requires the Core key. [Web's console server](../../docs/web/console | `POST /core/v1/sandbox/deployment/reset` | Start or escalate a durable clear of hosted resources | | `DELETE /core/v1/sandbox/deployment/reset?expected_generation=N` | Cancel the remaining clear | | `POST /core/v1/sandbox/providers/{provider}/discovery` | Query a provider's configuration catalog with a transient credential | -| `POST /core/v1/sandbox/enrollment-tokens` | Issue a one-use node enrollment token with approved capacity | +| `POST /core/v1/sandbox/enrollment-tokens` | Issue a one-use node enrollment token with optional node limits | | `GET /core/v1/sandbox/nodes` | List registered nodes | | `GET /core/v1/sandbox/nodes/{node_id}` | Read one node with its [host observations and history](./runtime-observability-api.md#node-host-observations-and-history) | | `PATCH /core/v1/sandbox/nodes/{node_id}` | Change a node's name and capacity | @@ -50,7 +50,7 @@ The request has no Core address. Core derives the deployment's `core_url` from t | `root_disk_mib` | microsandbox: at least 1024 MiB; Docker and E2B: omitted or zero | | `environment_disk_mib` | microsandbox owned disk: at least 1024 MiB; external filesystem: each new workspace's admitted capacity, where zero requests no quota and a positive value must be at least 1024 MiB and requires enforced quotas; Docker and E2B: omitted or zero | -These limits describe each sandbox. A node's `max_active` and `max_retained` are separate reservation limits, and host measurements never permit exceeding either. Native providers may reject values that pass these bounds. +These limits describe each sandbox. A node's `max_active` and `max_retained` are separate reservation limits, set as [Node capacity](#nodes-and-allocations) describes; no host measurement permits exceeding either once set. Native providers may reject values that pass these bounds. Docker applies the CPU and memory limits and checks the running container's limits and exact image; it has no hard root or workspace disk quota. E2B CPU and memory must equal the exact ready template build, which Core validates through the pinned SDK before saving; disk capacity stays part of the template. Neither provider accepts a disk quota it cannot enforce. An E2B selection may omit `resources`: Core then stores the build's CPU count and memory as `cpus` and `memory_mib`, returned in `specification.resources` without disk fields. On restart Core loads the committed E2B selection without validating the template build again, so an E2B outage never blocks inspection or cleanup; new selections still require validation. @@ -190,9 +190,9 @@ One mutation gate serializes setup, PUT, reset, cancellation and finalization. A ## Nodes and allocations -Node capacity is approved by the administrator, separately from the deployment specification. `POST /core/v1/sandbox/enrollment-tokens` accepts optional `max_active` and `max_retained`, default 2 and 8, and returns `{token, expires_at, enrollment_id}`; `enrollment_id` is a public handle of that command, never a credential. microsandbox uses both limits. Docker never suspends, so Core replaces its `max_retained` with `max_active`, here and in PATCH. E2B has no nodes and answers 409 `sandbox_deployment_conflict`. Core stores the approval with the token and copies it to the node it registers; a node cannot submit capacity, and its local checks may refuse a deployment it cannot run but never raise the limits. [Node capacity](../../docs/configuration.md#node-capacity) explains the limits for operators. +Node capacity is set separately from the deployment specification. `POST /core/v1/sandbox/enrollment-tokens` accepts optional `max_active` and `max_retained` and returns `{token, expires_at, enrollment_id}`; `enrollment_id` is a public handle of that command, never a credential. Core stores the token with the limits as sent, and an omitted limit stays unset. When the node enrolls, Core copies each explicit limit to it unchanged and sets each unset limit to the [recommended capacity](#recommended-capacity) of the host measurements in the enrollment request and the current specification; an unset limit with no host measurements fails with 409 `sandbox_node_host_unmeasured` and leaves the token unused. When only `max_retained` is set, the derived `max_active` never exceeds it; when only `max_active` is set, the derived `max_retained` is raised to at least `max_active`. microsandbox uses both limits. Docker never suspends, so Core replaces its `max_retained` with `max_active`, here and in PATCH. E2B has no nodes and answers 409 `sandbox_deployment_conflict`. A node cannot submit limits, but an unset limit follows the host measurements the node reports at enrollment, bounded by 1 and 1000000. Its local checks may refuse a deployment it cannot run but never raise the limits. Explicit values and later PATCH edits bind: once a node is enrolled, Core derives nothing again. [Node capacity](../../docs/configuration.md#node-capacity) explains the limits for operators. -`GET /core/v1/sandbox/nodes` returns `{data: [...]}` with, for each node: `id`, `name`, `provider`, `online`, `last_seen_at`, `created_at`, `max_active`, `max_retained`, the counts `active`, `reserved`, `running`, `retained`, `snapshots` and `cleanup_pending`, `provider_ready`, the host measurements `cpu_count`, `available_memory_bytes` and `available_disk_bytes`, `rollout`, `enrollment_id` and `core_url`. `enrollment_id` is the handle of the command that registered the node, or null when Core has none. `core_url` is the installation public URL at enrollment; a node whose `core_url` differs from the current public URL receives no new placements. Work already placed on it finishes there, including a placed Environment that has no allocation yet, and its retained sandboxes can still resume while the old address reaches Core. Remove it and add it again. +`GET /core/v1/sandbox/nodes` returns `{data: [...]}` with, for each node: `id`, `name`, `provider`, `online`, `last_seen_at`, `created_at`, `max_active`, `max_retained`, the counts `active`, `reserved`, `running`, `retained`, `snapshots` and `cleanup_pending`, `provider_ready`, the host measurements `cpu_count`, `available_memory_bytes` and `available_disk_bytes`, `rollout`, `recommended_capacity`, `enrollment_id` and `core_url`. `recommended_capacity` is `{max_active, max_retained}`, the [recommended capacity](#recommended-capacity) of the node's latest health and the current specification, or null until the node has reported a CPU count or effective cores and its total memory, and while the current specification does not validate. `enrollment_id` is the handle of the command that registered the node, or null when Core has none. `core_url` is the installation public URL at enrollment; a node whose `core_url` differs from the current public URL receives no new placements. Work already placed on it finishes there, including a placed Environment that has no allocation yet, and its retained sandboxes can still resume while the old address reaches Core. Remove it and add it again. A node manages generations, so its `provider_ready` follows its serving generation and its fixed code for the target generation appears in `rollout.diagnostic`. The node classifies the first failed readiness check and sends only its code; a frame with any other value is invalid, and Core never stores or returns probe text or host paths. The codes are Provider-neutral classes: `provider_unavailable`, `host_unsupported`, `artifacts_unavailable`, `runtime_image_unavailable` and `capacity_insufficient`. [Readiness codes](../../docs/getting-started/nodes.md#readiness-codes) gives causes and operator actions. Core and nodes must come from the same distribution. @@ -200,6 +200,15 @@ A node manages generations, so its `provider_ready` follows its serving generati `GET /core/v1/sandbox/nodes/{node_id}/allocations` lists the node's unreleased allocations. Each item's `compute_phase_changed_at` is when the allocation entered its current `compute_phase`, or null when unknown. This is a phase transition time, not a snapshot expiry. The snapshot deadline is not exposed by this response and must not be inferred from `suspension.retention_seconds`. +### Recommended capacity + +Core derives the recommendation from the node's host and the size of one sandbox, in one rule. `cpus` is `effective_cpu_cores` when the node reports it, otherwise `cpu_count`. + +- `max_active` is the smaller of `floor(2 × cpus / specification cpus)` and `floor((total_memory − 2 GiB) / specification memory)`, at least 1 and at most 1000000. CPU is committed at twice the cores because agent sandboxes mostly wait on the model. Memory is never overcommitted, and 2 GiB stays for the host and the node service. +- `max_retained` is `max_active` for a provider without checkpoint support. Otherwise it is the larger of `max_active` and `floor(available_disk / 2 / root_disk)`, at most 1000000: suspended sandboxes keep their root disk checkpoints on the node, and half the free disk is for them. + +The recommendation is absent unless the host reports a CPU count or effective cores and its total memory. Core applies it only when an enrollment token leaves a limit unset; Web shows it on **Edit node** and never applies it on its own. + ## What each field means per sandbox provider Some fields keep one name across providers but differ in meaning, or do not apply. Deployment fields come from `GET /core/v1/sandbox/deployment`, node and allocation fields from the node routes, and Runtime fields from the [Runtime telemetry API](./runtime-observability-api.md), where `disk` appears only in the observation list and history is described under [Session Runtime history](./runtime-observability-api.md#session-runtime-history). @@ -211,7 +220,7 @@ Some fields keep one name across providers but differ in meaning, or do not appl | Deployment `metadata.template_build` | Absent: `metadata` is empty | The build as Core read it when the selection was saved | Absent: `metadata` is empty | | Deployment `suspension` | `{idle_seconds, retention_seconds}` | `null`; Core does not suspend E2B sandboxes | `null` | | Deployment `resources.allocations`, `resources.pending` | Totals across all nodes | Core's unreleased E2B sandboxes, and hosted Environments waiting for one | Totals across all nodes | -| Enrollment-token `max_active`, `max_retained` | Both limits apply | 409 `sandbox_deployment_conflict`, after the 400 capacity checks; E2B has no nodes | `max_retained` always equals `max_active` | +| Enrollment-token `max_active`, `max_retained` | Both limits apply; an omitted limit is derived at enrollment | 409 `sandbox_deployment_conflict`, after the 400 capacity checks; E2B has no nodes | `max_retained` always equals `max_active` | | Node list and detail | Enrolled nodes | Empty list; detail returns 404 | Enrolled nodes | | Node `retained`, `snapshots`, `max_retained` | Suspended sandboxes are `retained` minus `active`; `snapshots` also includes retained snapshots during restore or cleanup | Not applicable | Docker never suspends: `retained` equals `active`, `snapshots` is 0 and `max_retained` equals `max_active` | | Node `host.available_disk_bytes` | Free space on the filesystem of the node state directory; sandbox disks have their own quotas | Not applicable | Free space on the filesystem of the node state directory, not a container's disk | diff --git a/contracts/agents-api/zh/machine-api.md b/contracts/agents-api/zh/machine-api.md index d59363798..40bb4c23a 100644 --- a/contracts/agents-api/zh/machine-api.md +++ b/contracts/agents-api/zh/machine-api.md @@ -1,7 +1,7 @@ --- title: "机器连接 API" source: contracts/agents-api/machine-api.md -source_hash: 637717534ca8fd64eea6f6688461dd6b065b317ae271cd974880524d698c3ae9 +source_hash: f3e9a3cd478fcc16f466cf749e49ace8cd49bd8769e9698f6e7a0edacad3c5f7 --- 机器通过 `/api/v1` 调用 Core:包括沙箱节点、Runtime daemon 和自托管安装程序。各路由仅接受所列凭据,不接受 Core 密钥或项目 API 密钥;控制台登录也不授予此处权限。反向代理将 `/api/v1` 直接发送给 Core;Web 不提供这些路由。 @@ -29,7 +29,7 @@ source_hash: 637717534ca8fd64eea6f6688461dd6b065b317ae271cd974880524d698c3ae9 | 凭据 | 签发方 | 接受位置 | | --- | --- | --- | -| 登记 token | `POST /core/v1/sandbox/enrollment-tokens`(Web **添加节点**),带节点批准容量。使用一次;在响应 `expires_at` 过期 | 无节点 ID 的 `sandbox-node/configuration`、`sandbox-node/enroll` | +| 登记 token | `POST /core/v1/sandbox/enrollment-tokens`(Web **添加节点**),带可选的节点限制。使用一次;在响应 `expires_at` 过期 | 无节点 ID 的 `sandbox-node/configuration`、`sandbox-node/enroll` | | 节点凭据 | 节点自身:生成 32 至 256 个无空白字符的密钥,在登记时注册 | 带 `X-OAC-Node-ID` 的 `sandbox-node/configuration`、`sandbox-node/identity`、`sandbox-node/connect` | | 安装授权 | `self_hosted` Session 的 `x_agents_core.installation` 命令;短期有效 | `agent-daemon/installation` 及其 `claim` | | 执行器凭据 | 安装领取,或[执行器凭据](environment-executor-credentials.md)中使用 Core 密钥的接口 | `agent-daemon/enroll` 和 `agent-daemon/connection`;登记后也作为绑定设备的 daemon 凭据 | @@ -60,7 +60,7 @@ oac-daemon connect --profile default - 新节点发送登记 token,不带 `X-OAC-Node-ID`。token 必须有效、未过期、未消费且由此安装签发。活动重置拒绝该读取。 - 已注册节点发送节点凭据,并在 `X-OAC-Node-ID` 放其 UUID。无查询时读取当前目标。`?generation=N` 仅读取此节点仍可能需要的代际:当前目标、服务 pin,或其上未释放分配或 placement 持有的代际;其他代际被拒绝。重置期间仍可读取,以恢复已有归属资源。 -响应包含 `installation_id`、`provider`、`core_url`(安装公开 URL)、`generation`、`specification`、`specification_digest`、`max_active` 和 `max_retained`。不包含管理员、项目或 E2B 凭据,仅适用于节点型提供商。[沙箱部署契约](sandbox-deployment.md#canonical-node-specification)定义 specification 与摘要。 +响应包含 `installation_id`、`provider`、`core_url`(安装公开 URL)、`generation`、`specification`、`specification_digest`。不包含管理员、项目或 E2B 凭据,仅适用于节点型提供商。[沙箱部署契约](sandbox-deployment.md#canonical-node-specification)定义 specification 与摘要。 ### 登记节点 {#enroll-a-node} @@ -75,8 +75,9 @@ oac-daemon connect --profile default | `backend_fingerprint` | 节点后端命名空间摘要 | | `deployment_generation`, `specification_digest` | 节点读取的配置 | | `core_url` | 节点存储并连接的 Core origin | +| `cpu_count`、`effective_cpu_cores`、`total_memory_bytes`、`available_disk_bytes` | 可选的主机测量值,见[节点协议](node-generation-protocol.md#connection) | -Core 在一个事务中检查 token 有效、部署已初始化且为节点型且未重置、代际与摘要匹配当前 specification、`core_url` 等于安装公开 URL、节点 ID 未使用。仅通过后按 token 批准容量注册节点并消费 token。201 响应为节点身份:`node_id`、`installation_id`、`provider`、`deployment_generation`、`specification_digest`、`max_active` 和 `max_retained`。节点不能提交容量;登记代际和摘要为不可变身份,后续代际使用独立配置。 +Core 在一个事务中检查 token 有效、部署已初始化且为节点型且未重置、代际与摘要匹配当前 specification、`core_url` 等于安装公开 URL、节点 ID 未使用。仅通过后按 token 设置的限制注册节点,token 未设置的限制取所报告主机的[推荐容量](sandbox-deployment.md#recommended-capacity),并消费 token。201 响应为节点身份:`node_id`、`installation_id`、`provider`、`deployment_generation`、`specification_digest`、`max_active` 和 `max_retained`。节点不能提交限制,但令牌未设置的限制取决于节点报告的主机测量值,范围限定在 1 到 1000000;显式值和之后的修改具有约束力。登记代际和摘要为不可变身份,后续代际使用独立配置。 ### 恢复节点身份 {#recover-a-node-s-identity} @@ -91,6 +92,7 @@ Core 在一个事务中检查 token 有效、部署已初始化且为节点型 | 401 | `invalid_node_credential` | token 或节点凭据缺失、无效、过期、已消费或属于其他安装 | | 409 | `sandbox_specification_mismatch` | 节点代际或摘要不匹配 | | 409 | `sandbox_node_address_mismatch` | `core_url` 不是安装公开 URL;token 保持未使用 | +| 409 | `sandbox_node_host_unmeasured` | token 未设置某个限制,且请求缺少 CPU 数量或有效核数,或总内存;token 保持未使用 | | 409 | `idempotency_conflict` | 节点 ID 已注册 | | 409 | `sandbox_reset_in_progress` | 重置期间登记或新节点读取配置 | | 503 | `runtime_node_unavailable` | 部署未初始化或存储不可用 | diff --git a/contracts/agents-api/zh/node-generation-protocol.md b/contracts/agents-api/zh/node-generation-protocol.md index 385a2e399..bddaaa69f 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: a4b0eae5408163c044b11016dda7a2d66bdcb6b6f90660d1388ffb5a5a2115e3 +source_hash: 21fb07f531d4087de59618e5f34211d5dc9674efe97530501170950e8a76f6f2 --- 沙箱节点协议连接 Core 与 Docker 或 microsandbox 主机:主机针对其本地提供商执行提供商操作,并报告就绪状态、主机测量值及其持有的部署代次。 @@ -52,7 +52,7 @@ sequenceDiagram 只要节点在当前所有者 epoch 下保持连接,并且最近一次心跳距今不足 45 秒,Core 就会将该节点计为在线。心跳会确立提供商的就绪状态和最近的主机测量值,但绝不表示 Session 活动。[超时与限制](#timeouts-and-limits)列出所有计时器。 -健康报告包含 `observed_at`、最多为 32 的 `active_operations`,以及 [Runtime 遥测 API](runtime-observability-api.md#node-host-observations-and-history) 报告的主机测量值。节点按代次报告就绪状态,详见下文。代次观察记录中的 `diagnostic` 若不是已声明的类别,该帧即无效。[节点指南](../../../docs/zh/getting-started/nodes.md#readiness-codes) 列出了这些类别及其原因。 +健康报告包含 `observed_at`、最多为 32 的 `active_operations`,以及 [Runtime 遥测 API](runtime-observability-api.md#node-host-observations-and-history) 报告的主机测量值。注册请求携带节点为健康报告所取的同样测量值:`cpu_count`、`effective_cpu_cores`、`total_memory_bytes` 和 `available_disk_bytes`,无法测量的省略,并按健康报告同样的规则校验。Core 用它们推导注册令牌未设置的每个限制,见[推荐容量](sandbox-deployment.md#recommended-capacity)。节点按代次报告就绪状态,详见下文。代次观察记录中的 `diagnostic` 若不是已声明的类别,该帧即无效。[节点指南](../../../docs/zh/getting-started/nodes.md#readiness-codes) 列出了这些类别及其原因。 ## 提供商请求 {#provider-requests} diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index d6a1e9943..cbfa78e05 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: 7e94d713fd7de92996f17717201c8da58bf8e538dc78e18334f5c43a76802e86 +source_hash: e84e173775d35451be7a77b58946c1786327f77aa627fa169f676648a6933943 --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择沙箱提供商和每个沙箱的资源。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源。Runtime 不属于该选择:每个节点运行其所属安装验证过的 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。 @@ -21,7 +21,7 @@ source_hash: 7e94d713fd7de92996f17717201c8da58bf8e538dc78e18334f5c43a76802e86 | `POST /core/v1/sandbox/deployment/reset` | 启动托管资源的持久清除或将其升级为持久清除 | | `DELETE /core/v1/sandbox/deployment/reset?expected_generation=N` | 取消剩余清除 | | `POST /core/v1/sandbox/providers/{provider}/discovery` | 使用临时凭据查询提供商配置目录 | -| `POST /core/v1/sandbox/enrollment-tokens` | 签发具有已批准容量的一次性节点注册令牌 | +| `POST /core/v1/sandbox/enrollment-tokens` | 签发带可选节点限制的一次性节点注册令牌 | | `GET /core/v1/sandbox/nodes` | 列出已注册节点 | | `GET /core/v1/sandbox/nodes/{node_id}` | 读取一个节点及其[主机观测和历史](runtime-observability-api.md#node-host-observations-and-history) | | `PATCH /core/v1/sandbox/nodes/{node_id}` | 更改节点的名称和容量 | @@ -52,7 +52,7 @@ POST 和 PUT 接受相同的完整选择,并要求提供先前 GET 返回的 ` | `root_disk_mib` | microsandbox:至少 1024 MiB;Docker 和 E2B:省略或为零 | | `environment_disk_mib` | microsandbox 自有磁盘:至少 1024 MiB;外部文件系统:每个新工作区的获准容量,零表示不请求配额,正值必须至少为 1024 MiB 且要求强制执行配额;Docker 和 E2B:省略或为零 | -这些限制描述每个沙箱。节点的 `max_active` 和 `max_retained` 是独立的预留限制,主机测量值绝不会允许超过其中任何一个。即使数值满足这些边界,原生提供商仍可能拒绝这些值。 +这些限制描述每个沙箱。节点的 `max_active` 和 `max_retained` 是独立的预留限制,设置方式见[节点与分配](#nodes-and-allocations);限制一经设置,任何主机测量值都不允许超过其中任何一个。即使数值满足这些边界,原生提供商仍可能拒绝这些值。 Docker 应用 CPU 和内存限制,并检查运行中容器的限制和精确镜像;它没有硬性的根磁盘或工作区磁盘配额。E2B 的 CPU 和内存必须与精确的就绪模板构建一致,Core 会在保存前通过固定版本 SDK 进行验证;磁盘容量仍属于模板的一部分。两种提供商都不接受其无法强制执行的磁盘配额。E2B 选择可以省略 `resources`:此时 Core 将构建的 CPU 数量和内存存储为 `cpus` 和 `memory_mib`,并通过 `specification.resources` 返回,不带磁盘字段。重启时,Core 会加载已提交的 E2B 选择,而不会再次验证模板构建,因此 E2B 中断绝不会阻碍检查或清理;新选择仍需验证。 @@ -195,9 +195,9 @@ stateDiagram-v2 ## 节点与分配 {#nodes-and-allocations} -节点容量由管理员批准,独立于部署 specification。`POST /core/v1/sandbox/enrollment-tokens` 接受可选的 `max_active` 和 `max_retained`,默认分别为 2 和 8,并返回 `{token, expires_at, enrollment_id}`;`enrollment_id` 是该命令的公共句柄,绝不是凭据。microsandbox 会使用两个限制。Docker 绝不暂停,因此 Core 会在此处和 PATCH 中用 `max_active` 替换其 `max_retained`。E2B 没有节点,并返回 409 `sandbox_deployment_conflict`。Core 将批准值与令牌一同存储,并复制到其注册的节点;节点不能提交容量,其本地检查可以拒绝其无法运行的部署,但绝不会提高限制。[节点容量](../../../docs/zh/configuration.md#node-capacity)为操作员说明了这些限制。 +节点容量独立于部署 specification 设置。`POST /core/v1/sandbox/enrollment-tokens` 接受可选的 `max_active` 和 `max_retained`,并返回 `{token, expires_at, enrollment_id}`;`enrollment_id` 是该命令的公共句柄,绝不是凭据。Core 将令牌与发送的限制一同存储,省略的限制保持未设置。节点注册时,Core 把每个显式限制原样复制给节点,并把每个未设置的限制设为注册请求中的主机测量值与当前 specification 的[推荐容量](#recommended-capacity);限制未设置且没有主机测量值时,返回 409 `sandbox_node_host_unmeasured`,令牌保持未使用。只设置 `max_retained` 时,推导出的 `max_active` 不会超过它;只设置 `max_active` 时,推导出的 `max_retained` 会被提高到至少等于 `max_active`。microsandbox 会使用两个限制。Docker 绝不暂停,因此 Core 会在此处和 PATCH 中用 `max_active` 替换其 `max_retained`。E2B 没有节点,并返回 409 `sandbox_deployment_conflict`。节点不能提交限制,但未设置的限制取决于节点在注册时报告的主机测量值,范围限定在 1 到 1000000。其本地检查可以拒绝其无法运行的部署,但绝不会提高限制。显式值和之后的 PATCH 修改具有约束力:节点注册之后,Core 不再推导。[节点容量](../../../docs/zh/configuration.md#node-capacity)为操作员说明了这些限制。 -`GET /core/v1/sandbox/nodes` 返回 `{data: [...]}`,其中每个节点包含 `id`、`name`、`provider`、`online`、`last_seen_at`、`created_at`、`max_active`、`max_retained`,计数项 `active`、`reserved`、`running`、`retained`、`snapshots` 和 `cleanup_pending`,以及 `provider_ready`、主机测量值 `cpu_count`、`available_memory_bytes` 和 `available_disk_bytes`、`rollout`、`enrollment_id` 和 `core_url`。`enrollment_id` 是注册该节点的命令的句柄;如果 Core 没有该句柄,则为 null。`core_url` 是注册时的安装公开 URL;`core_url` 与当前公开 URL 不同的节点不会收到新放置。已放置到该节点的工作会继续在那里完成,包括已放置但尚未获得分配的 Environment;只要旧地址仍能访问 Core,其保留沙箱仍可恢复。请移除该节点并重新添加。 +`GET /core/v1/sandbox/nodes` 返回 `{data: [...]}`,其中每个节点包含 `id`、`name`、`provider`、`online`、`last_seen_at`、`created_at`、`max_active`、`max_retained`,计数项 `active`、`reserved`、`running`、`retained`、`snapshots` 和 `cleanup_pending`,以及 `provider_ready`、主机测量值 `cpu_count`、`available_memory_bytes` 和 `available_disk_bytes`、`rollout`、`recommended_capacity`、`enrollment_id` 和 `core_url`。`recommended_capacity` 是 `{max_active, max_retained}`,即节点最新健康状态与当前 specification 的[推荐容量](#recommended-capacity);在节点报告 CPU 数量或有效核数以及总内存之前,以及当前 specification 未通过校验时,为 null。`enrollment_id` 是注册该节点的命令的句柄;如果 Core 没有该句柄,则为 null。`core_url` 是注册时的安装公开 URL;`core_url` 与当前公开 URL 不同的节点不会收到新放置。已放置到该节点的工作会继续在那里完成,包括已放置但尚未获得分配的 Environment;只要旧地址仍能访问 Core,其保留沙箱仍可恢复。请移除该节点并重新添加。 节点管理代次,因此其 `provider_ready` 取决于服务代次,目标代次的固定代码会出现在 `rollout.diagnostic` 中。节点会对首次失败的就绪检查进行分类,并且只发送代码;携带任何其他值的帧均无效,且 Core 绝不存储或返回探测文本或主机路径。这些代码是与提供商无关的类别:`provider_unavailable`、`host_unsupported`、`artifacts_unavailable`、`runtime_image_unavailable` 和 `capacity_insufficient`。[就绪代码](../../../docs/zh/getting-started/nodes.md#readiness-codes)给出了原因和操作员应采取的措施。Core 和节点必须来自同一发行包。 @@ -205,6 +205,15 @@ stateDiagram-v2 `GET /core/v1/sandbox/nodes/{node_id}/allocations` 列出节点尚未释放的分配。每个项的 `compute_phase_changed_at` 表示该分配进入当前 `compute_phase` 的时间;未知时为 null。该时间是阶段切换时间,不是快照到期时间。此响应不提供快照截止时间,不应根据 `suspension.retention_seconds` 推算。 +### 推荐容量 {#recommended-capacity} + +Core 用一条规则,从节点主机和单个沙箱的大小推导推荐值。`cpus` 在节点报告 `effective_cpu_cores` 时取该值,否则取 `cpu_count`。 + +- `max_active` 取 `floor(2 × cpus / specification cpus)` 与 `floor((total_memory − 2 GiB) / specification memory)` 中较小者,至少为 1,至多为 1000000。CPU 按核数的两倍分配,因为 Agent 沙箱大多在等待模型。内存从不超配,并为主机和节点服务保留 2 GiB。 +- 不支持检查点的提供商,`max_retained` 等于 `max_active`。否则取 `max_active` 与 `floor(available_disk / 2 / root_disk)` 中较大者,至多为 1000000:挂起的沙箱把根磁盘检查点保留在节点上,其中一半可用磁盘留给它们。 + +主机未同时报告 CPU 数量或有效核数与总内存时,没有推荐值。Core 仅在注册令牌未设置某个限制时应用它;Web 在 **编辑节点** 中显示它,从不自行应用。 + ## 各沙箱提供商中的字段含义 {#what-each-field-means-per-sandbox-provider} 某些字段在不同提供商中保持同一名称,但含义不同或不适用。部署字段来自 `GET /core/v1/sandbox/deployment`,节点和分配字段来自节点路由,Runtime 字段来自 [Runtime 遥测 API](runtime-observability-api.md);其中 `disk` 仅出现在观测列表中,历史则在 [Session Runtime 历史](runtime-observability-api.md#session-runtime-history)下说明。 @@ -216,7 +225,7 @@ stateDiagram-v2 | 部署 `metadata.template_build` | 不存在:`metadata` 为空 | Core 保存选择时读取到的构建 | 不存在:`metadata` 为空 | | 部署 `suspension` | `{idle_seconds, retention_seconds}` | `null`;Core 不暂停 E2B 沙箱 | `null` | | 部署 `resources.allocations`、`resources.pending` | 所有节点的总数 | Core 中尚未释放的 E2B 沙箱,以及正在等待沙箱的托管 Environment | 所有节点的总数 | -| 注册令牌 `max_active`、`max_retained` | 两个限制均适用 | 先执行 400 容量检查,然后返回 409 `sandbox_deployment_conflict`;E2B 没有节点 | `max_retained` 始终等于 `max_active` | +| 注册令牌 `max_active`、`max_retained` | 两个限制均适用;省略的限制在注册时推导 | 先执行 400 容量检查,然后返回 409 `sandbox_deployment_conflict`;E2B 没有节点 | `max_retained` 始终等于 `max_active` | | 节点列表和详情 | 已注册节点 | 空列表;详情返回 404 | 已注册节点 | | 节点 `retained`、`snapshots`、`max_retained` | 已暂停沙箱数为 `retained` 减去 `active`;`snapshots` 还包含恢复或清理期间保留的快照 | 不适用 | Docker 绝不暂停:`retained` 等于 `active`,`snapshots` 为 0,`max_retained` 等于 `max_active` | | 节点 `host.available_disk_bytes` | 节点状态目录所在文件系统的可用空间;沙箱磁盘有自己的配额 | 不适用 | 节点状态目录所在文件系统的可用空间,而不是容器的磁盘 | diff --git a/deploy/node/node_install.py b/deploy/node/node_install.py index 9ee3ee139..a386b8a45 100644 --- a/deploy/node/node_install.py +++ b/deploy/node/node_install.py @@ -123,6 +123,8 @@ def registration_failure(stderr): status, code = matches[-1].group(1).decode(), (matches[-1].group(2) or b"").decode() if code == "sandbox_node_address_mismatch": return AddressChanged(node_spec.PUBLIC_URL_CHANGED + " The token was not used; downloaded files are kept.") + if code == "sandbox_node_host_unmeasured": + return InstallError("Core could not measure this host's CPUs and memory, so it cannot derive the node limits. Request a token with explicit limits, then rerun the command.") if status == "401": return InstallError(node_spec.ENROLLMENT_EXPIRED + " Downloaded files are kept.") return InstallError(REGISTRATION_UNCONFIRMED + " Core answered HTTP " + status + (" " + code if code else "") + ".") diff --git a/deploy/node/node_spec.py b/deploy/node/node_spec.py index 72b0e9534..01c519aac 100644 --- a/deploy/node/node_spec.py +++ b/deploy/node/node_spec.py @@ -78,9 +78,7 @@ def validate(data, args): if (provider not in ("docker", "microsandbox") or data["installation_id"] != args.installation_id or data["core_url"] != args.core_url or type(data["generation"]) is not int or data["generation"] < 1 or getattr(args, "provider", None) not in (None, provider) - or set(spec) - {"workspace"} != {"resources"} - 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): + or set(spec) - {"workspace"} != {"resources"}): raise ValueError() canonical_spec(provider, spec) if data["specification_digest"] != digest(provider, spec): diff --git a/deploy/node/test_node_install.py b/deploy/node/test_node_install.py index 79f05d471..9fa3744a6 100644 --- a/deploy/node/test_node_install.py +++ b/deploy/node/test_node_install.py @@ -102,7 +102,7 @@ def configuration_response(self, request, **kwargs): resources.update(root_disk_mib=10240, environment_disk_mib=12288) spec = {"resources": resources} 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, + "core_url": self.args.core_url, "generation": 1, "specification": spec, "specification_digest": node_spec.digest(self.args.provider, spec)} return io.BytesIO(json.dumps(configuration).encode()) @@ -344,6 +344,10 @@ def test_unconfirmed_registration_retains_state_removes_token_and_does_not_start def test_registration_failure_names_only_cores_fixed_answer(self): self.assertEqual(str(installer.registration_failure(b"secret /home/path details")), installer.REGISTRATION_UNCONFIRMED) + def test_registration_failure_names_unmeasured_host(self): + message = str(installer.registration_failure(b"node enrollment rejected (HTTP 409 sandbox_node_host_unmeasured)\n")) + self.assertIn("explicit limits", message) + def test_changed_public_url_clears_unregistered_state_for_a_new_command(self): # Core refused the address before consuming the token, so it has no record of this node. self.register_stderr = b"node enrollment rejected (HTTP 409 sandbox_node_address_mismatch)\n" diff --git a/deploy/node/test_node_spec.py b/deploy/node/test_node_spec.py index e8e39d16e..68b3256d1 100644 --- a/deploy/node/test_node_spec.py +++ b/deploy/node/test_node_spec.py @@ -17,7 +17,7 @@ def setUp(self): self.spec = {"resources": {"cpus": 2, "memory_mib": 4096}} 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} + "core_url": self.args.core_url} self.retained = {"core_url": self.args.core_url, "credential": "f" * 64, "identity": { "node_id": "634d97be-e54d-40f0-9468-ae6b62be85bf", "installation_id": self.args.installation_id, "provider": "docker", "deployment_generation": 3, "specification_digest": self.data["specification_digest"]}} @@ -95,17 +95,6 @@ def test_invalid_limits_digests_and_provider_assertions_are_rejected(self): with self.assertRaises(node_spec.SpecificationError): node_spec.validate(self.data, self.args) - def test_capacity_requires_approved_bounded_integers(self): - for key, value in (("max_active", None), ("max_active", True), ("max_active", 0), - ("max_retained", 1), ("max_retained", 1000001)): - data = copy.deepcopy(self.data) - data[key] = value - with self.subTest(key=key, value=value), self.assertRaises(node_spec.SpecificationError): - node_spec.validate(data, self.args) - self.data["max_active"], self.data["max_retained"] = 3, 9 - 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_digest_is_independent_of_response_object_key_order(self): reordered = copy.deepcopy(self.spec) reordered["resources"] = dict(reversed(list(reordered["resources"].items()))) diff --git a/docs/configuration.md b/docs/configuration.md index 84e2cc249..02294cbc2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,7 +68,7 @@ Which Harnesses are enabled, and the default one, are process settings (`OAC_HAR ### Node capacity -Core approves a node's capacity when you generate its Add node command: **Sandboxes at once** (`max_active`, default 2) and, for microsandbox only, **Retained sandboxes** (`max_retained`, default 8), with `max_retained >= max_active >= 1`. Docker never suspends sandboxes, so Web doesn't ask for it and Core keeps `max_retained` equal to `max_active`. Change them later on **Nodes** with **Edit node**. Reservations and cleanup that is not confirmed count against capacity; full retained slots can [end an idle checkpoint's retention early](sandbox-provider.md#cold-replacement-after-checkpoint-retention); lowering a limit stops no running sandbox. A node's own files can't change its capacity, size or Runtime. +A node's capacity is **Sandboxes at once** (`max_active`) and, for microsandbox only, **Retained sandboxes** (`max_retained`), with `max_retained >= max_active >= 1`. **Add node** keeps the recommended limits by default, which Core [derives from the host](../contracts/agents-api/sandbox-deployment.md#recommended-capacity) when the node registers, or you enter both numbers. Docker never suspends sandboxes, so Web doesn't ask for it and Core keeps `max_retained` equal to `max_active`. Change them later on **Nodes** with **Edit node**, which shows the recommendation and fills it in on request. Reservations and cleanup that is not confirmed count against capacity; full retained slots can [end an idle checkpoint's retention early](sandbox-provider.md#cold-replacement-after-checkpoint-retention); lowering a limit stops no running sandbox. A node's own files can't change its capacity, size or Runtime. `OAC_EXECUTION_CONCURRENCY` is unrelated: it limits concurrent execution work in Core. diff --git a/docs/getting-started/nodes.md b/docs/getting-started/nodes.md index 4f3218c58..610c3e307 100644 --- a/docs/getting-started/nodes.md +++ b/docs/getting-started/nodes.md @@ -29,7 +29,7 @@ The Core host joins like any other host: to run sandboxes on it, add it as a nod ## Add a node 1. In Web, open **Nodes** and choose **Add node**. -2. Set **Sandboxes at once** and, with microsandbox, **Retained sandboxes**: the node's [capacity](../configuration.md#node-capacity). You can change them later with **Edit node**. +2. Keep **Recommended** limits, or choose **Custom** and set **Sandboxes at once** and, with microsandbox, **Retained sandboxes**: the node's [capacity](../configuration.md#node-capacity). **Edit node** shows the recommendation for the host and fills it in with **Use recommended**. 3. Choose **Generate command** and copy the command. It registers one node, once, and only if it runs within an hour; Web counts down and offers **Generate new command** when it expires. 4. Run it on the host. Web follows the node from registered to connected to ready. @@ -103,7 +103,7 @@ Run `sudo journalctl -u oac-node-.service`. In a root shell, om Sandbox settings apply to the whole installation; nodes follow them. - **Size, E2B key or template build.** Open **Settings** → **Sandbox and scheduling** → **Change resources**, edit and save. Existing sandboxes keep their configuration. Each node prepares the new one while it keeps serving the old one, and **Nodes** shows its progress: **Preparing target**, **Ready for target** or **Preparation failed** with the [reason](#readiness-codes). Core places new Sessions on nodes ready for the new configuration first, and on nodes still serving an older one when those have no room. E2B changes apply at once. -- **Capacity and idle suspension.** On **Nodes**, use **Edit node** for a node's active and retained limits. In **Settings** → **Sandbox and scheduling**, use **Automatic suspension** → **Idle before suspension (seconds)** → **Save** for the deployment-wide [idle policy](../../contracts/agents-api/sandbox-deployment.md#suspension-policy). +- **Capacity and idle suspension.** On **Nodes**, use **Edit node** for a node's active and retained limits, or **Use recommended** to fill in the recommendation. In **Settings** → **Sandbox and scheduling**, use **Automatic suspension** → **Idle before suspension (seconds)** → **Save** for the deployment-wide [idle policy](../../contracts/agents-api/sandbox-deployment.md#suspension-policy). - **Backend.** On the same page, choose **Reset deployment**. Auto reset archives idle hosted Sessions at once and lets running work finish until the deadline you set; force cancels it now. Offline nodes must come back so Core can confirm their cleanup. When the reset completes, Core has retired every node and unused command: set up the new backend, then add nodes again. **Cancel reset** stops the remaining work; archived Sessions stay archived. The [reset contract](../../contracts/agents-api/sandbox-deployment.md#generation-ownership-and-rollout) describes what reset archives and keeps. To archive a single hosted Session, use the [Core API](../../contracts/agents-api/admin-api.md#session-archive). diff --git a/docs/web/console-api-usage.md b/docs/web/console-api-usage.md index 02899aa06..97a29c595 100644 --- a/docs/web/console-api-usage.md +++ b/docs/web/console-api-usage.md @@ -106,10 +106,10 @@ The list carries each Harness's configuration, so the console does not read `GET | Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, the read-only `core_url` (`OAC_PUBLIC_URL`, shown in the setup review and never sent), reset state, installation ID and specification; a 409 `sandbox_configuration_error` (E2B with a loopback `public_url`) shows the shared client's fixed safe address-configuration message in the setup wizard, with Managed in Settings leading to Settings → Service and deployment, and leaves nothing to confirm; initialize the deployment with Docker or microsandbox `resources`, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. E2B's `metadata.template_build` (status, CPU, memory, disk) shows in Settings → Sandbox and scheduling and Sandbox metrics, and sizes each sandbox when `specification.resources` is missing; microsandbox's `suspension` (idle and retention seconds) shows in Settings → Sandbox and scheduling | | E2B discovery | `POST /core/v1/sandbox/providers/e2b/discovery` | The setup wizard lists the templates the entered E2B key can see, then the selected template's ready builds. The key travels only in these request bodies and the deployment write | | Reset | `POST`, `DELETE /core/v1/sandbox/deployment/reset` | Explicitly clear hosted resources, or cancel the remaining clear at the observed generation; show Core's remaining and offline projection | -| Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; each hosted Runtime's node on Sandbox metrics. An online node whose `provider_ready` is false is degraded; the help tip beside its status on the Nodes list and on the node's page says *Sandbox provider unavailable*, and the target preparation reason (`rollout.diagnostic`, a [readiness code](../getting-started/nodes.md#readiness-codes)) shows in its target status. A node whose `core_url` (the address it enrolled with) differs from the deployment's `core_url` is named on the Nodes page as bound to an old address, to be removed and added again, and its status there and on its page reads Old address instead of its health. **Add node** follows only the node whose `enrollment_id` equals its command's | -| Node detail | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | A node's page on Nodes: the host's CPU busy share, memory and free disk from its last heartbeat, and CPU and memory history over 1 hour, 6 hours or 24 hours, refreshed every 30 s while visible. **Edit node** reads `host.effective_cpu_cores` and `host.total_memory_bytes` to show the host beside each sandbox's size and at most how many of those fit | +| Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; each hosted Runtime's node on Sandbox metrics. An online node whose `provider_ready` is false is degraded; the help tip beside its status on the Nodes list and on the node's page says *Sandbox provider unavailable*, and the target preparation reason (`rollout.diagnostic`, a [readiness code](../getting-started/nodes.md#readiness-codes)) shows in its target status. A node whose `core_url` (the address it enrolled with) differs from the deployment's `core_url` is named on the Nodes page as bound to an old address, to be removed and added again, and its status there and on its page reads Old address instead of its health. **Edit node** reads the node's `recommended_capacity` from this list. **Add node** follows only the node whose `enrollment_id` equals its command's | +| Node detail | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | A node's page on Nodes: the host's CPU busy share, memory and free disk from its last heartbeat, and CPU and memory history over 1 hour, 6 hours or 24 hours, refreshed every 30 s while visible. | | Allocations | `GET /core/v1/sandbox/nodes/{node_id}/allocations` | Nodes page; Sandbox metrics. A node's page shows from `compute_phase_changed_at` how long each allocation has been in its compute phase; a null time shows a dash. It does not infer snapshot expiry from the phase timestamp or the deployment's `suspension.retention_seconds` | -| Enrollment | `POST /core/v1/sandbox/enrollment-tokens` | **Add node**: the administrator sets the node's sandbox limits (`max_active`; `max_retained` only for microsandbox, equal to `max_active` for Docker) before Core issues a single-use token inside a command that verifies the installer checksum, with the command's `enrollment_id`, which the node it registers reports. The command runs the installer with sudo (a system service) and passes the token on standard input; root runs it directly. No ordinary-user installation or removal entry is exposed, and the log hint always names the system service. The command downloads the installer from the installation's `public_url`. No token is requested until the installation is read, when it cannot be read, when it is `local_only` (or its `public_url` is not a valid HTTP or HTTPS origin), or when `/console/config` lists `node_artifacts` without the deployment's provider. The dialog reads both again on opening and when the window regains focus | +| Enrollment | `POST /core/v1/sandbox/enrollment-tokens` | **Add node**: the administrator keeps the recommended limits (the request body is empty and Core derives them when the node registers) or sets both (`max_active`; `max_retained` only for microsandbox, equal to `max_active` for Docker) before Core issues a single-use token inside a command that verifies the installer checksum, with the command's `enrollment_id`, which the node it registers reports. The command runs the installer with sudo (a system service) and passes the token on standard input; root runs it directly. No ordinary-user installation or removal entry is exposed, and the log hint always names the system service. The command downloads the installer from the installation's `public_url`. No token is requested until the installation is read, when it cannot be read, when it is `local_only` (or its `public_url` is not a valid HTTP or HTTPS origin), or when `/console/config` lists `node_artifacts` without the deployment's provider. The dialog reads both again on opening and when the window regains focus | | Update node | `PATCH /core/v1/sandbox/nodes/{node_id}` | **Edit node**: the name and sandbox limits together (the retained limit only for microsandbox; under Docker, Core sets it to the active limit) | | Remove node | `DELETE /core/v1/sandbox/nodes/{node_id}` | Confirmed node removal; the row goes only after Core acknowledges the deletion, and a Clean up the host dialog then gives the host's uninstall command (requiring root or sudo; for a node enrolled with another address than the deployment's, also with `--force`, which skips the installer's confirmation with Core) | | Runtime observations | `GET /core/v1/sandbox/runtime-observations` | Sandbox metrics: hosted Runtimes of every project, each labelled with its project; an E2B sandbox's dialog adds its `observation.disk` as used / limit (null elsewhere) | diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 6e6e8c6f6..0fca20108 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,7 +1,7 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: 48515be4243404bb45d5fb93ffe9836580e37f01efb495c1e06e2722d6183165 +source_hash: d3d85be9a8784413ccb107ff8111a15647fd573f31bd66525a322202332e0fb7 --- Core 安装的每项设置、其归属位置以及修改方式。安装程序写入初始值;之后请使用本参考。每项设置都恰好只有一个归属位置,分属以下三类: @@ -72,7 +72,7 @@ Core 安装的每项设置、其归属位置以及修改方式。安装程序写 ### 节点容量 {#node-capacity} -生成 **添加节点** 命令时,Core 会批准节点容量:**同时运行的沙箱**(`max_active`,默认值为 2),并且仅对 microsandbox 还会批准 **保留的沙箱**(`max_retained`,默认值为 8),其中 `max_retained >= max_active >= 1`。Docker 从不暂停沙箱,因此 Web 不会询问此项,并且 Core 会使 `max_retained` 保持等于 `max_active`。之后可在 **节点** 使用 **编辑节点** 修改这些值。预留和尚未确认的清理操作都会占用容量;retained slot 已满时可以[提前结束空闲检查点的保留期](./sandbox-provider.md#cold-replacement-after-checkpoint-retention);调低限制不会停止任何正在运行的沙箱。节点自身的文件无法更改其容量、大小或 Runtime。 +节点容量是 **同时运行的沙箱**(`max_active`),并且仅对 microsandbox 还有 **保留的沙箱**(`max_retained`),其中 `max_retained >= max_active >= 1`。**添加节点** 默认采用推荐值,即节点注册时 Core [按主机推导](../../contracts/agents-api/zh/sandbox-deployment.md#recommended-capacity)的值;也可以自行填写两个数字。Docker 从不暂停沙箱,因此 Web 不会询问此项,并且 Core 会使 `max_retained` 保持等于 `max_active`。之后可在 **节点** 使用 **编辑节点** 修改这些值,它会显示推荐值并可一键填入。预留和尚未确认的清理操作都会占用容量;retained slot 已满时可以[提前结束空闲检查点的保留期](./sandbox-provider.md#cold-replacement-after-checkpoint-retention);调低限制不会停止任何正在运行的沙箱。节点自身的文件无法更改其容量、大小或 Runtime。 `OAC_EXECUTION_CONCURRENCY` 与此无关:它限制 Core 中并发执行的工作量。 diff --git a/docs/zh/getting-started/nodes.md b/docs/zh/getting-started/nodes.md index 6f1c7f677..9d4e7ff0d 100644 --- a/docs/zh/getting-started/nodes.md +++ b/docs/zh/getting-started/nodes.md @@ -1,7 +1,7 @@ --- title: "添加和管理节点" source: docs/getting-started/nodes.md -source_hash: 60f79b32a3c11ee369cf66e2ddde8cfe2652f204996c5adf37eacfe6729be25b +source_hash: 65d9798a2843a67c27d8b4e4a86cc4977be1ac41b798e32b6b8dde6059958640 --- 节点是一台 Linux 主机,为 Core 托管 Session 运行沙箱。本指南面向在沙箱提供商(参阅[选择沙箱提供商](./sandbox-backends.md))为 microsandbox 或 Docker 时添加、修改和移除节点的运维人员;E2B 不需要节点。 @@ -31,7 +31,7 @@ Core 主机与其他主机一样加入:要在它上面运行沙箱,将它添 ## 添加节点 {#add-a-node} 1. 在 Web 打开 **节点**,选择 **添加节点**。 -2. 设置 **同时运行的沙箱**,使用 microsandbox 时还设置 **保留的沙箱**,即节点[容量](../configuration.md#node-capacity)。之后可以通过 **编辑节点** 修改。 +2. 保持 **推荐** 上限,或选择 **自定义** 并设置 **同时运行的沙箱**(使用 microsandbox 时还有 **保留的沙箱**),即节点[容量](../configuration.md#node-capacity)。**编辑节点** 会显示该主机的推荐值,并可通过 **使用推荐值** 填入。 3. 选择 **生成命令** 并复制命令。命令仅注册一个节点、只能使用一次,且必须在 1 小时内执行;Web 显示倒计时,过期后提供 **生成新命令**。 4. 在主机上执行。Web 跟踪节点从注册、连接到就绪的过程。 @@ -105,7 +105,7 @@ root 只准备账号、组和服务单元;其他操作(包括 Docker 网络 沙箱设置适用于整个安装,节点跟随这些设置。 - **规格、E2B 密钥或模板构建。** 打开 **设置** → **沙箱与调度** → **修改资源**,修改并保存。已有沙箱保持其配置。每个节点继续服务旧配置,同时准备新配置;**节点** 展示进度:**正在准备目标配置**、**目标配置已就绪**,或带[原因](#readiness-codes)的 **目标配置准备失败**。Core 优先在已准备好新配置的节点上放置新 Session;这些节点没有容量时,会使用仍服务旧配置的节点。E2B 变更立即生效。 -- **容量与空闲挂起。** 在 **节点** 中通过 **编辑节点** 修改节点的活跃和保留上限。在 **设置** → **沙箱与调度** 中,通过 **自动挂起** → **空闲多久后挂起(秒)** → **保存** 修改整个部署的[空闲策略](../../../contracts/agents-api/zh/sandbox-deployment.md#suspension-policy)。 +- **容量与空闲挂起。** 在 **节点** 中通过 **编辑节点** 修改节点的活跃和保留上限,或用 **使用推荐值** 填入推荐值。在 **设置** → **沙箱与调度** 中,通过 **自动挂起** → **空闲多久后挂起(秒)** → **保存** 修改整个部署的[空闲策略](../../../contracts/agents-api/zh/sandbox-deployment.md#suspension-policy)。 - **沙箱提供商。** 同一页面选择 **重置部署**。自动重置立即归档空闲托管 Session,并让运行中的工作在设定截止时间前完成;强制重置立即取消工作。离线节点必须恢复连接,让 Core 确认清理。重置完成时,Core 已撤销所有节点和未使用命令:配置新的沙箱提供商后重新添加节点。**取消重置** 停止剩余工作;已归档 Session 仍保持归档。 [重置协议](../../../contracts/agents-api/zh/sandbox-deployment.md#generation-ownership-and-rollout)描述重置会归档和保留哪些内容。归档单个托管 Session 时,使用 [Core API](../../../contracts/agents-api/zh/admin-api.md#session-archive)。 diff --git a/docs/zh/web/console-api-usage.md b/docs/zh/web/console-api-usage.md index ea0df6348..19f1330f1 100644 --- a/docs/zh/web/console-api-usage.md +++ b/docs/zh/web/console-api-usage.md @@ -1,7 +1,7 @@ --- title: "控制台 API 使用" source: docs/web/console-api-usage.md -source_hash: 8529f1216a0e6987b099247270583f1ed9f3b4482dd8593231bab3389dfd5520 +source_hash: d14848610e56678c0de35ed6796b4631c5cd42b8a1f0d25366e224a4ff878e44 --- 本页列出各控制台页面调用的 Core 路由,以及控制台如何限定读取范围。本页面向控制台开发者;运维人员请阅读 [Web 控制台](index.md)。[Core 管理 API](../../../contracts/agents-api/zh/admin-api.md) 定义了路由、响应结构、分页和审计记录;[API 命名空间和凭据](../api/index.md) 定义了本文使用的术语。 @@ -109,9 +109,9 @@ source_hash: 8529f1216a0e6987b099247270583f1ed9f3b4482dd8593231bab3389dfd5520 | E2B 发现 | `POST /core/v1/sandbox/providers/e2b/discovery` | 设置向导先列出输入的 E2B 密钥可见的模板,再列出所选模板的可用构建。该密钥只会通过这些请求体和部署写入请求传输 | | 重置 | `POST`、`DELETE /core/v1/sandbox/deployment/reset` | 显式清除托管资源,或在观测到的 generation 处取消剩余清除;显示 Core 的剩余资源和离线预测 | | 节点 | `GET /core/v1/sandbox/nodes` | **节点** 页面;**概览** 上的机群;**沙箱监控** 中每个托管 Runtime 所在的节点。`provider_ready` 为 false 的在线节点为降级;**节点** 列表及节点页面中紧邻其状态的帮助提示显示 *沙箱提供商不可用*,目标准备原因(`rollout.diagnostic`,一个[就绪状态码](../getting-started/nodes.md#readiness-codes))显示在其目标状态中。如果节点的 `core_url`(其注册时使用的地址)与部署的 `core_url` 不同,**节点** 页面会将其标记为绑定到旧地址,需要移除后重新添加;此时它在该页面和节点页面中的状态会显示 **地址已过期**,而不是健康状态。**添加节点** 仅跟踪 `enrollment_id` 与其命令所含 `enrollment_id` 相等的节点 | -| 节点详情 | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | **节点** 中的节点页面:主机最近一次心跳报告的 CPU 忙碌占比、内存使用量和可用磁盘,以及 1 小时、6 小时或 24 小时内 CPU 与内存的历史记录;页面可见时每 30 秒刷新一次。**编辑节点** 读取 `host.effective_cpu_cores` 和 `host.total_memory_bytes`,用于在每个 Sandbox 大小旁显示主机容量,以及最多可容纳多少个该大小的 Sandbox | +| 节点详情 | `GET /core/v1/sandbox/nodes/{node_id}?range=1h\|6h\|24h` | **节点** 中的节点页面:主机最近一次心跳报告的 CPU 忙碌占比、内存使用量和可用磁盘,以及 1 小时、6 小时或 24 小时内 CPU 与内存的历史记录;页面可见时每 30 秒刷新一次。**编辑节点** 读取节点的 `recommended_capacity` 作为推荐值 | | 分配 | `GET /core/v1/sandbox/nodes/{node_id}/allocations` | **节点** 页面;**沙箱监控**。节点页面根据 `compute_phase_changed_at` 显示每个分配处于计算阶段的时间;时间为 null 时显示短横线。它不会根据阶段时间或部署的 `suspension.retention_seconds` 推算快照到期时间 | -| 注册 | `POST /core/v1/sandbox/enrollment-tokens` | **添加节点**:管理员先设置节点的 Sandbox 限制(`max_active`;`max_retained` 仅适用于 microsandbox,在 Docker 下等于 `max_active`),然后 Core 才会把一次性令牌放入命令中;该命令会验证安装程序校验和,并包含命令的 `enrollment_id`,节点注册时会报告此 ID。命令使用 sudo 运行安装程序(作为系统服务),并通过标准输入传递令牌;以 root 运行时则直接执行。界面不提供普通用户安装或移除入口,日志提示始终指明系统服务。命令从安装的 `public_url` 下载安装程序。只有成功读取安装信息后才会请求令牌;如果安装信息无法读取、安装为 `local_only`(或其 `public_url` 不是有效的 HTTP 或 HTTPS 来源),或者 `/console/config` 列出的 `node_artifacts` 不包含部署的提供商,则不会请求令牌。对话框在打开时和窗口重新获得焦点时,会再次读取这两项信息 | +| 注册 | `POST /core/v1/sandbox/enrollment-tokens` | **添加节点**:管理员保留推荐上限(请求体不含上限,由 Core 在节点注册时推导)或同时设置两个上限(`max_active`;`max_retained` 仅适用于 microsandbox,在 Docker 下等于 `max_active`),然后 Core 才会把一次性令牌放入命令中;该命令会验证安装程序校验和,并包含命令的 `enrollment_id`,节点注册时会报告此 ID。命令使用 sudo 运行安装程序(作为系统服务),并通过标准输入传递令牌;以 root 运行时则直接执行。界面不提供普通用户安装或移除入口,日志提示始终指明系统服务。命令从安装的 `public_url` 下载安装程序。只有成功读取安装信息后才会请求令牌;如果安装信息无法读取、安装为 `local_only`(或其 `public_url` 不是有效的 HTTP 或 HTTPS 来源),或者 `/console/config` 列出的 `node_artifacts` 不包含部署的提供商,则不会请求令牌。对话框在打开时和窗口重新获得焦点时,会再次读取这两项信息 | | 更新节点 | `PATCH /core/v1/sandbox/nodes/{node_id}` | **编辑节点**:同时修改名称和 Sandbox 限制(保留数量限制仅适用于 microsandbox;在 Docker 下,Core 会将其设为活动数量限制) | | 移除节点 | `DELETE /core/v1/sandbox/nodes/{node_id}` | 确认后移除节点;只有 Core 确认删除后该行才会消失,随后 **清理主机** 对话框会提供主机的卸载命令(需要 root 或 sudo;对于使用不同于部署地址的地址注册的节点,还需要使用 `--force`,以跳过安装程序与 Core 的确认) | | Runtime 观测 | `GET /core/v1/sandbox/runtime-observations` | **沙箱监控**:每个项目的托管 Runtime,每项均以所属项目为标签;E2B Sandbox 对话框还会将 `observation.disk` 显示为已用量/限制值(其他位置为 null) | diff --git a/packages/agents-client/src/generated/core-api.ts b/packages/agents-client/src/generated/core-api.ts index 94892ac69..60aff68c4 100644 --- a/packages/agents-client/src/generated/core-api.ts +++ b/packages/agents-client/src/generated/core-api.ts @@ -116,6 +116,11 @@ export interface AdminauditPage { export const adminauditPageFields = ["data", "has_more", "next_cursor"] as const; export const attachmentKindValues = ["host_directory"] as const; export type AttachmentKind = (typeof attachmentKindValues)[number]; +export interface Capacity { + max_active: number; + max_retained: number; +} +export const capacityFields = ["max_active", "max_retained"] as const; export interface ConfigurationDiscoveryInput { configuration?: Record; credential?: Record; @@ -425,13 +430,14 @@ export interface Node { online: boolean; provider: string; provider_ready: boolean; + recommended_capacity: Capacity | null; reserved: number; retained: number; rollout: NodeRollout; running: number; snapshots: number; } -export const nodeFields = ["active", "available_disk_bytes", "available_memory_bytes", "cleanup_pending", "core_url", "cpu_count", "created_at", "enrollment_id", "id", "last_seen_at", "max_active", "max_retained", "name", "online", "provider", "provider_ready", "reserved", "retained", "rollout", "running", "snapshots"] as const; +export const nodeFields = ["active", "available_disk_bytes", "available_memory_bytes", "cleanup_pending", "core_url", "cpu_count", "created_at", "enrollment_id", "id", "last_seen_at", "max_active", "max_retained", "name", "online", "provider", "provider_ready", "recommended_capacity", "reserved", "retained", "rollout", "running", "snapshots"] as const; export interface NodeAllocation { compute_phase: string; compute_phase_changed_at: string | null; @@ -468,13 +474,14 @@ export interface NodeDetail { online: boolean; provider: string; provider_ready: boolean; + recommended_capacity: Capacity | null; reserved: number; retained: number; rollout: NodeRollout; running: number; snapshots: number; } -export const nodeDetailFields = ["active", "available_disk_bytes", "available_memory_bytes", "cleanup_pending", "core_url", "cpu_count", "created_at", "enrollment_id", "history", "host", "id", "last_seen_at", "max_active", "max_retained", "name", "online", "provider", "provider_ready", "reserved", "retained", "rollout", "running", "snapshots"] as const; +export const nodeDetailFields = ["active", "available_disk_bytes", "available_memory_bytes", "cleanup_pending", "core_url", "cpu_count", "created_at", "enrollment_id", "history", "host", "id", "last_seen_at", "max_active", "max_retained", "name", "online", "provider", "provider_ready", "recommended_capacity", "reserved", "retained", "rollout", "running", "snapshots"] as const; export const nodeDiagnosticCodeValues = ["provider_unavailable", "host_unsupported", "artifacts_unavailable", "runtime_image_unavailable", "capacity_insufficient"] as const; export type NodeDiagnosticCode = (typeof nodeDiagnosticCodeValues)[number]; export interface NodeHost { diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts index b40d14918..2a66e6573 100644 --- a/packages/agents-client/src/sandbox-client.test.ts +++ b/packages/agents-client/src/sandbox-client.test.ts @@ -16,11 +16,12 @@ const node = { cpu_count: 16, available_memory_bytes: 8589934592, available_disk_bytes: 107374182400, running: 1, snapshots: 0, last_seen_at: "2026-09-25T09:00:00Z", max_active: 2, max_retained: 2, active: 1, reserved: 0, retained: 1, cleanup_pending: 0, created_at: created, core_url: "https://core.example", enrollment_id: "9d2e6b1a-4c3f-4e8d-a7b6-5c4d3e2f1a0b", + recommended_capacity: { max_active: 8, max_retained: 8 }, }; /** Never heard from and enrolled before Core recorded enrollment IDs. */ const unready = { ...node, rollout: { state: "unknown", ready_generation: 1 }, id: "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918", online: false, provider_ready: false, - cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, last_seen_at: null, active: 0, retained: 0, enrollment_id: null, + cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, last_seen_at: null, active: 0, retained: 0, enrollment_id: null, recommended_capacity: null, }; const detail = { ...node, diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index 045093f6f..d5030a1e8 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -6,7 +6,7 @@ import { attachmentKindValues, deploymentResourcesFields, deploymentSpecFields, deploymentSpecRequired, deploymentViewFields, deploymentModeValues, deploymentViewRequired, hostHistoryFields, hostHistoryPointFields, nodeAllocationDiagnosticValues, nodeAllocationFields, nodeDetailFields, - nodeDiagnosticCodeValues, nodeFields, nodeHostFields, nodeRolloutFields, nodeRolloutRequired, nodeRolloutStateValues, resetModeValues, + capacityFields, nodeDiagnosticCodeValues, nodeFields, nodeHostFields, nodeRolloutFields, nodeRolloutRequired, nodeRolloutStateValues, resetModeValues, resetFields, resetOfflineNodeFields, resetRemainingFields, rolloutFields, rolloutNodesFields, rolloutStateValues, sandboxAllocationListFields, sandboxNodeListFields, sandboxResourcesFields, sandboxResourcesRequired, suspensionFields, type DeploymentSpec, type DeploymentView, type HostHistoryPoint, type Node, type NodeAllocation, type NodeDetail, type NodeDiagnosticCode, @@ -181,6 +181,7 @@ function projectNode(node: Record): SandboxNode { valid(strings(node, ["id", "name", "provider", "core_url"]) && typeof node.online === "boolean" && typeof node.provider_ready === "boolean" && [node.cpu_count, node.available_memory_bytes, node.available_disk_bytes].every(nullable(isNonnegativeInteger)) && [node.running, node.snapshots, node.max_active, node.max_retained, node.active, node.reserved, node.retained, node.cleanup_pending].every(isNonnegativeInteger) && + nullable((capacity) => { const c = members(capacity, capacityFields); return [c.max_active, c.max_retained].every(isNonnegativeInteger); })(node.recommended_capacity) && nullable(timestamp)(node.last_seen_at) && timestamp(node.created_at) && (node.enrollment_id === null || typeof node.enrollment_id === "string")); return fields as unknown as SandboxNode; } diff --git a/services/core/internal/api/core_error_catalog_test.go b/services/core/internal/api/core_error_catalog_test.go index 42d36dee5..27d4b3d72 100644 --- a/services/core/internal/api/core_error_catalog_test.go +++ b/services/core/internal/api/core_error_catalog_test.go @@ -23,7 +23,7 @@ var nonAdministrationCodes = []string{ // Session creation and input admission on /v1. "environment_input_cancelled", "environment_input_expired", "model_provider_required", "sandbox_nodes_preparing", "turn_conflict", // Machine routes for nodes and native installers. - "installation_authorization_invalid", "installation_unavailable", "invalid_node_credential", "sandbox_node_address_mismatch", + "installation_authorization_invalid", "installation_unavailable", "invalid_node_credential", "sandbox_node_address_mismatch", "sandbox_node_host_unmeasured", } // The shared catalog lists every code an administration caller (/core/v1 or diff --git a/services/core/internal/api/core_store_validation_test.go b/services/core/internal/api/core_store_validation_test.go index eafc6d5d1..ab72175a1 100644 --- a/services/core/internal/api/core_store_validation_test.go +++ b/services/core/internal/api/core_store_validation_test.go @@ -84,8 +84,8 @@ func TestCoreStoreValidationFieldsAndPublicFallback(t *testing.T) { func TestCoreActiveCapacityUpperBoundNamesSubmittedField(t *testing.T) { deps, fakes := sandboxFakes(t) - fakes.deployment.createEnrollment = func(_ context.Context, capacity deployment.Capacity) (deployment.EnrollmentToken, error) { - return deployment.EnrollmentToken{}, nodeCapacity(capacity.MaxActive) + fakes.deployment.createEnrollment = func(_ context.Context, limits deployment.EnrollmentLimits) (deployment.EnrollmentToken, error) { + return deployment.EnrollmentToken{}, nodeCapacity(*limits.MaxActive) } fakes.deployment.updateNode = func(_ context.Context, _ string, input deployment.NodeUpdate) error { return nodeCapacity(input.MaxActive) diff --git a/services/core/internal/api/errors_deployment.go b/services/core/internal/api/errors_deployment.go index 553518e8a..ae9cf9a48 100644 --- a/services/core/internal/api/errors_deployment.go +++ b/services/core/internal/api/errors_deployment.go @@ -92,6 +92,8 @@ func writeSandboxError(w http.ResponseWriter, err error) bool { writeError(w, http.StatusConflict, "sandbox_configuration_error", err.Error()) case errors.Is(err, deployment.ErrNodeAddressMismatch): writeError(w, http.StatusConflict, "sandbox_node_address_mismatch", "This node uses a different Core address than the installation public URL. Generate a new command on the Nodes page and run it on the host.") + case errors.Is(err, deployment.ErrNodeHostUnmeasured): + writeError(w, http.StatusConflict, "sandbox_node_host_unmeasured", "This node reported no host measurements, so Core cannot derive its limits. Set both limits when you generate the command.") case errors.Is(err, deployment.ErrSpecificationMismatch): writeError(w, http.StatusConflict, "sandbox_specification_mismatch", "The node resource limits do not match the active deployment. Restore its installed configuration or remove and enroll the node again after a drained deployment change.") case errors.Is(err, deployment.ErrNodeExists): diff --git a/services/core/internal/api/fakes_test.go b/services/core/internal/api/fakes_test.go index b94f0074b..49e9087e1 100644 --- a/services/core/internal/api/fakes_test.go +++ b/services/core/internal/api/fakes_test.go @@ -188,7 +188,7 @@ type fakeDeployment struct { nodeDetail func(context.Context, string, string) (deployment.NodeDetail, error) updateNode func(context.Context, string, deployment.NodeUpdate) error removeNode func(context.Context, string) error - createEnrollment func(context.Context, deployment.Capacity) (deployment.EnrollmentToken, error) + createEnrollment func(context.Context, deployment.EnrollmentLimits) (deployment.EnrollmentToken, error) enroll func(context.Context, string, deployment.Enrollment) (deployment.NodeIdentity, error) nodeConfiguration func(context.Context, string, string, uint64) (deployment.NodeConfiguration, error) nodeStatus func(context.Context, string, string) (deployment.NodeStatus, error) @@ -230,7 +230,7 @@ func (f *fakeDeployment) RemoveNode(a0 context.Context, a1 string) error { return f.removeNode(a0, a1) } -func (f *fakeDeployment) CreateEnrollment(a0 context.Context, a1 deployment.Capacity) (deployment.EnrollmentToken, error) { +func (f *fakeDeployment) CreateEnrollment(a0 context.Context, a1 deployment.EnrollmentLimits) (deployment.EnrollmentToken, error) { if f.createEnrollment == nil { unexpectedCall(f.t, "CreateEnrollment") } diff --git a/services/core/internal/api/sandbox_manager.go b/services/core/internal/api/sandbox_manager.go index 3ce2491ce..dba990413 100644 --- a/services/core/internal/api/sandbox_manager.go +++ b/services/core/internal/api/sandbox_manager.go @@ -29,6 +29,7 @@ type SandboxMutationResponse struct { Updated bool `json:"updated,omitempty"` } type SandboxEnrollmentTokenRequest struct { + // An omitted limit is derived from the node's host when it enrolls. MaxActive *int `json:"max_active,omitempty"` // Docker never suspends, so Core replaces this with max_active; microsandbox uses both limits. MaxRetained *int `json:"max_retained,omitempty"` @@ -43,7 +44,7 @@ type Deployment interface { NodeDetail(ctx context.Context, id, window string) (deployment.NodeDetail, error) UpdateNode(ctx context.Context, id string, update deployment.NodeUpdate) error RemoveNode(ctx context.Context, id string) error - CreateEnrollment(ctx context.Context, capacity deployment.Capacity) (deployment.EnrollmentToken, error) + CreateEnrollment(ctx context.Context, limits deployment.EnrollmentLimits) (deployment.EnrollmentToken, error) Enroll(ctx context.Context, token string, input deployment.Enrollment) (deployment.NodeIdentity, error) NodeConfiguration(ctx context.Context, nodeID, token string, generation uint64) (deployment.NodeConfiguration, error) NodeStatus(ctx context.Context, nodeID, credential string) (deployment.NodeStatus, error) @@ -202,14 +203,7 @@ func (h *Handler) createSandboxEnrollment(w http.ResponseWriter, r *http.Request writeDeploymentError(w, r, deployment.ErrInvalidInput) return } - capacity := deployment.Capacity{MaxActive: 2, MaxRetained: 8} - if input.MaxActive != nil { - capacity.MaxActive = *input.MaxActive - } - if input.MaxRetained != nil { - capacity.MaxRetained = *input.MaxRetained - } - enrollment, err := h.Sandboxes.Deployment.CreateEnrollment(r.Context(), capacity) + enrollment, err := h.Sandboxes.Deployment.CreateEnrollment(r.Context(), deployment.EnrollmentLimits{MaxActive: input.MaxActive, MaxRetained: input.MaxRetained}) if err != nil { writeDeploymentError(w, r, err) return @@ -238,7 +232,7 @@ func (h *Handler) enrollSandboxNode(w http.ResponseWriter, r *http.Request) { return } var input deployment.Enrollment - if decodeInputObject(raw, &input, "node_id", "credential", "name", "provider", "backend_fingerprint", "deployment_generation", "specification_digest", "core_url") != nil { + if decodeInputObject(raw, &input, "node_id", "credential", "name", "provider", "backend_fingerprint", "deployment_generation", "specification_digest", "core_url", "cpu_count", "effective_cpu_cores", "total_memory_bytes", "available_disk_bytes") != nil { writeDeploymentError(w, r, deployment.ErrInvalidInput) return } diff --git a/services/core/internal/api/sandbox_manager_test.go b/services/core/internal/api/sandbox_manager_test.go index ddb395c04..d9f3fbe71 100644 --- a/services/core/internal/api/sandbox_manager_test.go +++ b/services/core/internal/api/sandbox_manager_test.go @@ -79,8 +79,8 @@ func nodeCapacity(active int) error { func TestSandboxEnrollmentCapacityIsAdministratorOnly(t *testing.T) { deps, fakes := sandboxFakes(t) - fakes.deployment.createEnrollment = func(_ context.Context, capacity deployment.Capacity) (deployment.EnrollmentToken, error) { - return deployment.EnrollmentToken{}, nodeCapacity(capacity.MaxActive) + fakes.deployment.createEnrollment = func(_ context.Context, limits deployment.EnrollmentLimits) (deployment.EnrollmentToken, error) { + return deployment.EnrollmentToken{}, nodeCapacity(*limits.MaxActive) } h := newTestHandler(t, deps) for _, test := range []struct{ path, token, body string }{ diff --git a/services/core/internal/api/sandbox_node_detail_test.go b/services/core/internal/api/sandbox_node_detail_test.go index e43f73eab..19beab019 100644 --- a/services/core/internal/api/sandbox_node_detail_test.go +++ b/services/core/internal/api/sandbox_node_detail_test.go @@ -2,6 +2,7 @@ package api import ( "context" + "strings" "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" @@ -29,3 +30,19 @@ func TestSandboxNodeDetailValidationAndAuthentication(t *testing.T) { t.Fatal(w.Code) } } + +func TestSandboxNodeResponsesCarryRecommendedCapacity(t *testing.T) { + deps, fakes := sandboxFakes(t) + node := deployment.Node{RecommendedCapacity: &deployment.Capacity{MaxActive: 3, MaxRetained: 14}} + fakes.deployment.listNodes = func(context.Context) ([]deployment.Node, error) { return []deployment.Node{node}, nil } + fakes.deployment.nodeDetail = func(context.Context, string, string) (deployment.NodeDetail, error) { + return deployment.NodeDetail{Node: node}, nil + } + h := newTestHandler(t, deps) + for _, path := range []string{"/core/v1/sandbox/nodes", "/core/v1/sandbox/nodes/11111111-1111-4111-8111-111111111111"} { + w := projectKeyHTTP(h, "GET", path, "administrator", "") + if w.Code != 200 || !strings.Contains(w.Body.String(), `"recommended_capacity":{"max_active":3,"max_retained":14}`) { + t.Fatal(path, w.Code, w.Body.String()) + } + } +} diff --git a/services/core/internal/db/sqlc/models.go b/services/core/internal/db/sqlc/models.go index 16d2f1c41..0b29eb728 100644 --- a/services/core/internal/db/sqlc/models.go +++ b/services/core/internal/db/sqlc/models.go @@ -329,8 +329,8 @@ type RuntimeNodeEnrollment struct { ExpiresAt pgtype.Timestamptz `json:"expires_at"` ConsumedAt pgtype.Timestamptz `json:"consumed_at"` NodeID pgtype.UUID `json:"node_id"` - MaxActive int32 `json:"max_active"` - MaxRetained int32 `json:"max_retained"` + MaxActive pgtype.Int4 `json:"max_active"` + MaxRetained pgtype.Int4 `json:"max_retained"` ID pgtype.UUID `json:"id"` } diff --git a/services/core/internal/db/sqlc/runtime_nodes.sql.go b/services/core/internal/db/sqlc/runtime_nodes.sql.go index d6635736f..b22bfd8a0 100644 --- a/services/core/internal/db/sqlc/runtime_nodes.sql.go +++ b/services/core/internal/db/sqlc/runtime_nodes.sql.go @@ -58,8 +58,8 @@ type CreateRuntimeEnrollmentParams struct { ID pgtype.UUID `json:"id"` TokenSha256 string `json:"token_sha256"` InstallationID pgtype.UUID `json:"installation_id"` - MaxActive int32 `json:"max_active"` - MaxRetained int32 `json:"max_retained"` + MaxActive pgtype.Int4 `json:"max_active"` + MaxRetained pgtype.Int4 `json:"max_retained"` } func (q *Queries) CreateRuntimeEnrollment(ctx context.Context, arg CreateRuntimeEnrollmentParams) error { diff --git a/services/core/internal/deployment/capacity.go b/services/core/internal/deployment/capacity.go new file mode 100644 index 000000000..284e3fb11 --- /dev/null +++ b/services/core/internal/deployment/capacity.go @@ -0,0 +1,75 @@ +package deployment + +import ( + "context" + "math" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +// hostReservedMiB stays for the host and the node service; memory is never overcommitted beyond it. +const hostReservedMiB = 2048 + +// RecommendCapacity derives a node's limits from its host measurements and the +// size of one sandbox. It reports false when the host measurements cannot +// establish them. +// +// Agent sandboxes mostly wait on the model, so CPU is committed at twice the +// host's cores. Memory is not overcommitted, after hostReservedMiB. A provider +// that checkpoints retains suspended sandboxes on disk, so it keeps as many +// root disks as fit in half the free disk; one that does not retains none. +func RecommendCapacity(host NodeHealth, resources sandbox.Resources, checkpoint bool) (Capacity, bool) { + cpus := host.CPUCount + var cores float64 + switch { + case host.Host != nil && host.Host.EffectiveCPUCores != nil: + cores = *host.Host.EffectiveCPUCores + case cpus != nil: + cores = float64(*cpus) + } + if cores <= 0 || host.Host == nil || host.Host.TotalMemoryBytes == nil || resources.CPUs == 0 || resources.MemoryMiB == 0 { + return Capacity{}, false + } + memoryMiB := *host.Host.TotalMemoryBytes>>20 - hostReservedMiB + byCPU := int64(math.Floor(min(2*cores/float64(resources.CPUs), maxNodeCapacity))) + active := int(max(min(byCPU, memoryMiB/int64(resources.MemoryMiB), maxNodeCapacity), 1)) + retained := active + if checkpoint && host.AvailableDiskBytes != nil && resources.RootDiskMiB > 0 { + retained = int(min(max(int64(active), *host.AvailableDiskBytes>>20/2/int64(resources.RootDiskMiB)), maxNodeCapacity)) + } + return Capacity{MaxActive: active, MaxRetained: retained}, true +} + +// recommendations returns, for the current specification, the recommendation +// for a node's latest health: nil when there is no specification or the node +// reported too little. +func (s *Service) recommendations(ctx context.Context) (func(NodeHealth) *Capacity, error) { + // The snapshot reads the deployment without opening its sealed credential. + snapshot, err := s.reader.Snapshot(ctx) + if err != nil { + return nil, err + } + d := snapshot.Record + spec, err := s.specification(d) + if err != nil { + return func(NodeHealth) *Capacity { return nil }, nil + } + checkpoint, err := s.registry.SupportsCheckpoint(d.Provider) + if err != nil { + return func(NodeHealth) *Capacity { return nil }, nil + } + return func(health NodeHealth) *Capacity { + capacity, ok := RecommendCapacity(health, spec.Resources, checkpoint) + if !ok { + return nil + } + return &capacity + }, nil +} + +// enrollmentHealth is the host an enrollment reports, validated like a health report. +func enrollmentHealth(e Enrollment) (NodeHealth, error) { + observed := time.Now() + return normalizeHealth(NodeHealth{CPUCount: e.CPUCount, AvailableDiskBytes: e.AvailableDiskBytes, Host: &NodeHost{EffectiveCPUCores: e.EffectiveCPUCores, TotalMemoryBytes: e.TotalMemoryBytes, AvailableDiskBytes: e.AvailableDiskBytes, ObservedAt: &observed}}) +} diff --git a/services/core/internal/deployment/capacity_test.go b/services/core/internal/deployment/capacity_test.go new file mode 100644 index 000000000..c72ae41a8 --- /dev/null +++ b/services/core/internal/deployment/capacity_test.go @@ -0,0 +1,37 @@ +package deployment + +import ( + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" +) + +func TestRecommendCapacity(t *testing.T) { + const gib = int64(1) << 30 + size := sandbox.Resources{CPUs: 2, MemoryMiB: 4096, RootDiskMiB: 8192} + host := func(cpus int64, cores *float64, memory, disk int64) NodeHealth { + now := time.Now() + return NodeHealth{CPUCount: &cpus, AvailableDiskBytes: &disk, Host: &NodeHost{EffectiveCPUCores: cores, TotalMemoryBytes: &memory, AvailableDiskBytes: &disk, ObservedAt: &now}} + } + limited := 1.0 + for _, c := range []struct { + name string + host NodeHealth + checkpoint bool + want Capacity + }{ + {"checkpoint host", host(4, nil, 14*gib+gib*4/10, 239*gib), true, Capacity{MaxActive: 3, MaxRetained: 14}}, + {"no checkpoint", host(4, nil, 14*gib+gib*4/10, 239*gib), false, Capacity{MaxActive: 3, MaxRetained: 3}}, + {"just enough for one", host(1, nil, 6*gib, 1*gib), true, Capacity{MaxActive: 1, MaxRetained: 1}}, + {"cgroup limit wins", host(64, &limited, 256*gib, 1000*gib), true, Capacity{MaxActive: 1, MaxRetained: 62}}, + {"memory bound", host(64, nil, 10*gib, 100*gib), false, Capacity{MaxActive: 2, MaxRetained: 2}}, + } { + if got, ok := RecommendCapacity(c.host, size, c.checkpoint); !ok || got != c.want { + t.Errorf("%s: %v %v, want %v", c.name, got, ok, c.want) + } + } + if _, ok := RecommendCapacity(NodeHealth{}, size, true); ok { + t.Error("recommended without host measurements") + } +} diff --git a/services/core/internal/deployment/errors.go b/services/core/internal/deployment/errors.go index d3d30bb50..f0c032d00 100644 --- a/services/core/internal/deployment/errors.go +++ b/services/core/internal/deployment/errors.go @@ -32,6 +32,9 @@ var ( // ErrNodeAddressMismatch rejects an enrollment whose Core address is not // the installation public URL. The token stays unconsumed. ErrNodeAddressMismatch = errors.New("sandbox node Core address differs from the public URL") + // ErrNodeHostUnmeasured rejects an enrollment that leaves a limit unset + // and reports no host measurements to derive it from. The token stays unused. + ErrNodeHostUnmeasured = errors.New("sandbox node reported no host measurements") // ErrNodeExists rejects an enrollment whose node ID is already in use. ErrNodeExists = errors.New("sandbox node ID is already enrolled") // ErrSandboxResetSessionBusy reports a hosted Session an automatic reset diff --git a/services/core/internal/deployment/node.go b/services/core/internal/deployment/node.go index 1216cf4a5..57c9d9439 100644 --- a/services/core/internal/deployment/node.go +++ b/services/core/internal/deployment/node.go @@ -27,8 +27,8 @@ type EnrollmentToken struct { } type Capacity struct { - MaxActive int `json:"max_active"` - MaxRetained int `json:"max_retained"` + MaxActive int `json:"max_active" binding:"required"` + MaxRetained int `json:"max_retained" binding:"required"` } type Enrollment struct { @@ -41,6 +41,16 @@ type Enrollment struct { BackendFingerprint string `json:"backend_fingerprint"` // The Core origin this node stores and connects to, such as https://core.example. It must equal the installation public URL; otherwise enrollment gets 409 sandbox_node_address_mismatch and the token stays unused. CoreURL string `json:"core_url"` + // Host measurements as in the node's health reports. Core derives a limit the enrollment token leaves unset from them. + CPUCount *int64 `json:"cpu_count,omitempty"` + EffectiveCPUCores *float64 `json:"effective_cpu_cores,omitempty"` + TotalMemoryBytes *int64 `json:"total_memory_bytes,omitempty"` + AvailableDiskBytes *int64 `json:"available_disk_bytes,omitempty"` +} + +// EnrollmentLimits are the limits an enrollment token approves. A nil limit is unset. +type EnrollmentLimits struct { + MaxActive, MaxRetained *int } type NodeHealth struct { @@ -69,6 +79,8 @@ type Node struct { Retained int64 `json:"retained" binding:"required"` CleanupPending int64 `json:"cleanup_pending" binding:"required"` CreatedAt time.Time `json:"created_at" binding:"required"` + // The limits Core recommends for the node's latest host measurements and the current specification; null before the node reports them. + RecommendedCapacity *Capacity `json:"recommended_capacity" extensions:"x-nullable" binding:"required"` // The Core address this node enrolled with. A node whose address differs // from the installation public URL receives no new sandboxes; re-add it. CoreURL string `json:"core_url" binding:"required"` @@ -91,8 +103,6 @@ type NodeStatus struct { } type NodeConfiguration struct { - MaxActive int `json:"max_active"` - MaxRetained int `json:"max_retained"` InstallationID string `json:"installation_id"` Provider string `json:"provider"` CoreURL string `json:"core_url"` diff --git a/services/core/internal/deployment/nodes.go b/services/core/internal/deployment/nodes.go index 1ae61160c..d8069a746 100644 --- a/services/core/internal/deployment/nodes.go +++ b/services/core/internal/deployment/nodes.go @@ -22,17 +22,21 @@ func (s *Service) ListNodes(ctx context.Context) ([]Node, error) { if err != nil { return nil, err } + recommend, err := s.recommendations(ctx) + if err != nil { + return nil, err + } out := make([]Node, 0, len(records)) for _, n := range records { - out = append(out, nodeView(n)) + out = append(out, nodeView(n, recommend)) } return out, nil } -func nodeView(n NodeRecord) Node { +func nodeView(n NodeRecord, recommend func(NodeHealth) *Capacity) Node { health := n.Health health.Host = nil - return Node{Rollout: nodeRollout(n), NodeHealth: health, ProviderReady: n.Online && n.ServingReady, Running: n.Running, Snapshots: n.Snapshots, ID: n.ID, Name: n.Name, CoreURL: n.CoreURL, EnrollmentID: n.EnrollmentID, Provider: n.Provider, Online: n.Online, LastSeenAt: n.LastSeenAt, MaxActive: n.MaxActive, MaxRetained: n.MaxRetained, Active: n.Active, Reserved: n.Reserved, Retained: n.Retained, CleanupPending: n.CleanupPending, CreatedAt: n.CreatedAt} + return Node{Rollout: nodeRollout(n), NodeHealth: health, ProviderReady: n.Online && n.ServingReady, Running: n.Running, Snapshots: n.Snapshots, ID: n.ID, Name: n.Name, CoreURL: n.CoreURL, EnrollmentID: n.EnrollmentID, Provider: n.Provider, Online: n.Online, LastSeenAt: n.LastSeenAt, MaxActive: n.MaxActive, MaxRetained: n.MaxRetained, Active: n.Active, Reserved: n.Reserved, Retained: n.Retained, CleanupPending: n.CleanupPending, CreatedAt: n.CreatedAt, RecommendedCapacity: recommend(n.Health)} } // NodeDetail returns one node with its last host observation and its host @@ -53,7 +57,11 @@ func (s *Service) NodeDetail(ctx context.Context, id, window string) (NodeDetail if err != nil { return NodeDetail{}, err } - result := NodeDetail{Node: nodeView(record), History: HostHistory{ResolutionSeconds: span.ResolutionSeconds, Points: historyPoints(span, samples)}} + recommend, err := s.recommendations(ctx) + if err != nil { + return NodeDetail{}, err + } + result := NodeDetail{Node: nodeView(record, recommend), History: HostHistory{ResolutionSeconds: span.ResolutionSeconds, Points: historyPoints(span, samples)}} if record.Health.Host != nil { result.Host = *record.Health.Host } @@ -129,10 +137,13 @@ func (s *Service) RemoveNode(ctx context.Context, id string) error { // CreateEnrollment issues a one-use enrollment token. Its ID is a public, // non-secret handle: the node the token registers reports it as enrollment_id. -func (s *Service) CreateEnrollment(ctx context.Context, capacity Capacity) (EnrollmentToken, error) { +// A limit left unset is derived from the node's host when it enrolls. +func (s *Service) CreateEnrollment(ctx context.Context, limits EnrollmentLimits) (EnrollmentToken, error) { // The retained limit depends on the provider, so the transaction checks it. - if err := validateNode("enrollment", capacity.MaxActive, capacity.MaxActive); err != nil { - return EnrollmentToken{}, err + if limits.MaxActive != nil { + if err := validateNode("enrollment", *limits.MaxActive, *limits.MaxActive); err != nil { + return EnrollmentToken{}, err + } } var bytes [32]byte if _, err := rand.Read(bytes[:]); err != nil { @@ -140,12 +151,23 @@ func (s *Service) CreateEnrollment(ctx context.Context, capacity Capacity) (Enro } result := EnrollmentToken{Token: hex.EncodeToString(bytes[:]), ID: uuid.NewString()} err := s.withManager(ctx, func(tx NodeTx, d Record) error { - retained, err := s.registry.RetainedLimit(d.Provider, capacity.MaxActive, capacity.MaxRetained) - if err != nil { - return err - } - if err := validateNode("enrollment", capacity.MaxActive, retained); err != nil { - return err + // A provider without checkpoints keeps no retained limit, so the token ignores it. + if limits.MaxRetained != nil { + checkpoint, err := s.registry.SupportsCheckpoint(d.Provider) + if err != nil { + return err + } + if !checkpoint { + limits.MaxRetained = nil + } else { + active := 1 + if limits.MaxActive != nil { + active = *limits.MaxActive + } + if err := validateNode("enrollment", active, *limits.MaxRetained); err != nil { + return err + } + } } if d.Reset != nil { return ErrResetInProgress @@ -156,7 +178,8 @@ func (s *Service) CreateEnrollment(ctx context.Context, capacity Capacity) (Enro if _, err := s.specification(d); err != nil { return ErrConflict } - result.ExpiresAt, err = tx.CreateEnrollment(NewEnrollment{ID: result.ID, TokenDigest: tokenDigest(result.Token), InstallationID: d.InstallationID, MaxActive: capacity.MaxActive, MaxRetained: retained}) + var err error + result.ExpiresAt, err = tx.CreateEnrollment(NewEnrollment{ID: result.ID, TokenDigest: tokenDigest(result.Token), InstallationID: d.InstallationID, MaxActive: limits.MaxActive, MaxRetained: limits.MaxRetained}) return err }) if err != nil { @@ -174,6 +197,10 @@ func (s *Service) Enroll(ctx context.Context, token string, input Enrollment) (N if err := validateNode(input.Name, 1, 1); err != nil { return NodeIdentity{}, err } + host, err := enrollmentHealth(input) + if err != nil { + return NodeIdentity{}, err + } var result NodeIdentity err = s.storage.WithNodes(ctx, func(tx NodeTx) error { d, err := tx.LoadDeployment() @@ -212,11 +239,11 @@ func (s *Service) Enroll(ctx context.Context, token string, input Enrollment) (N if input.CoreURL != s.rules.PublicURL() { return ErrNodeAddressMismatch } - retained, err := s.registry.RetainedLimit(d.Provider, receipt.MaxActive, receipt.MaxRetained) + capacity, err := s.enrolledCapacity(d, spec, host, receipt) if err != nil { return err } - n, err := tx.InsertNode(NewNode{ID: nodeID, InstallationID: d.InstallationID, Name: input.Name, BackendFingerprint: input.BackendFingerprint, CredentialDigest: tokenDigest(input.Credential), MaxActive: receipt.MaxActive, MaxRetained: retained, SpecificationDigest: input.SpecificationDigest, DeploymentGeneration: input.DeploymentGeneration, CoreURL: input.CoreURL, EnrollmentID: receipt.ID}) + n, err := tx.InsertNode(NewNode{ID: nodeID, InstallationID: d.InstallationID, Name: input.Name, BackendFingerprint: input.BackendFingerprint, CredentialDigest: tokenDigest(input.Credential), MaxActive: capacity.MaxActive, MaxRetained: capacity.MaxRetained, SpecificationDigest: input.SpecificationDigest, DeploymentGeneration: input.DeploymentGeneration, CoreURL: input.CoreURL, EnrollmentID: receipt.ID}) if err != nil { return err } @@ -236,6 +263,46 @@ func (s *Service) Enroll(ctx context.Context, token string, input Enrollment) (N return result, nil } +// enrolledCapacity completes the token's limits: each unset limit is the +// recommendation for the node's host, and the retained limit is never below the +// active one or beyond what the provider keeps. +func (s *Service) enrolledCapacity(d Record, spec sandbox.DeploymentSpec, host NodeHealth, receipt EnrollmentRecord) (Capacity, error) { + capacity := Capacity{} + if receipt.MaxActive != nil { + capacity.MaxActive = *receipt.MaxActive + } + if receipt.MaxRetained != nil { + capacity.MaxRetained = *receipt.MaxRetained + } + checkpoint, err := s.registry.SupportsCheckpoint(d.Provider) + if err != nil { + return Capacity{}, err + } + // Without checkpoints a retained limit is just the active one, which needs no host. + if receipt.MaxActive == nil || receipt.MaxRetained == nil && checkpoint { + recommended, ok := RecommendCapacity(host, spec.Resources, checkpoint) + if !ok { + return Capacity{}, ErrNodeHostUnmeasured + } + if receipt.MaxActive == nil { + // An explicit retained limit binds: active never exceeds it. + capacity.MaxActive = recommended.MaxActive + if receipt.MaxRetained != nil && checkpoint { + capacity.MaxActive = min(capacity.MaxActive, capacity.MaxRetained) + } + } + if receipt.MaxRetained == nil { + capacity.MaxRetained = recommended.MaxRetained + } + } + retained, err := s.registry.RetainedLimit(d.Provider, capacity.MaxActive, max(capacity.MaxRetained, capacity.MaxActive)) + if err != nil { + return Capacity{}, err + } + capacity.MaxRetained = retained + return capacity, nil +} + func identity(n StoredNode, provider string) NodeIdentity { return NodeIdentity{SpecificationDigest: n.SpecificationDigest, DeploymentGeneration: n.DeploymentGeneration, NodeID: n.ID, InstallationID: n.InstallationID, Provider: provider, BackendFingerprint: n.BackendFingerprint, MaxActive: n.MaxActive, MaxRetained: n.MaxRetained} } @@ -352,13 +419,12 @@ func (s *Service) NodeConfiguration(ctx context.Context, nodeID, token string, g } var node *StoredNode var installation string - var active, retained int if nodeID == "" { r, err := tx.LoadEnrollment(tokenDigest(token)) if err != nil || r.Consumed || !r.ExpiresAt.After(time.Now()) { return ErrNodeCredential } - installation, active, retained = r.InstallationID, r.MaxActive, r.MaxRetained + installation = r.InstallationID } else { id, err := parseID(nodeID) if err != nil { @@ -368,7 +434,7 @@ func (s *Service) NodeConfiguration(ctx context.Context, nodeID, token string, g if err != nil || n.CredentialDigest != tokenDigest(token) { return ErrNodeCredential } - node, installation, active, retained = &n, n.InstallationID, n.MaxActive, n.MaxRetained + node, installation = &n, n.InstallationID } // A claimed installation rejects foreign credentials identically before // and after initialization. @@ -407,7 +473,7 @@ func (s *Service) NodeConfiguration(ctx context.Context, nodeID, token string, g if err != nil { return err } - result = NodeConfiguration{MaxActive: active, MaxRetained: retained, InstallationID: d.InstallationID, Provider: d.Provider, CoreURL: s.rules.PublicURL(), Generation: selected, Specification: spec, SpecificationDigest: spec.Digest(d.Provider)} + result = NodeConfiguration{InstallationID: d.InstallationID, Provider: d.Provider, CoreURL: s.rules.PublicURL(), Generation: selected, Specification: spec, SpecificationDigest: spec.Digest(d.Provider)} return nil }) if err != nil { diff --git a/services/core/internal/deployment/service_test.go b/services/core/internal/deployment/service_test.go index 387ffb117..08e61d461 100644 --- a/services/core/internal/deployment/service_test.go +++ b/services/core/internal/deployment/service_test.go @@ -246,7 +246,7 @@ func TestEnrollChecksTheTokenBeforeTheDeployment(t *testing.T) { current := webDeployment(t, installation, "docker", 1) valid := Enrollment{NodeID: uuid.NewString(), Name: "node", Credential: strings.Repeat("n", 64), Provider: "docker", BackendFingerprint: strings.Repeat("b", 64), DeploymentGeneration: 1, SpecificationDigest: testSpecification("docker").Digest("docker"), CoreURL: testPublicURL} - receipt := EnrollmentRecord{ID: uuid.NewString(), InstallationID: installation, ExpiresAt: time.Now().Add(10 * time.Minute), MaxActive: 1, MaxRetained: 1} + receipt := EnrollmentRecord{ID: uuid.NewString(), InstallationID: installation, ExpiresAt: time.Now().Add(10 * time.Minute), MaxActive: new(1), MaxRetained: new(1)} resetting := current resetting.Reset = &ResetState{Clear: "sandboxes", RequestedAt: time.Now()} unselected := current diff --git a/services/core/internal/deployment/storage.go b/services/core/internal/deployment/storage.go index 7e987fc4e..3a7dd0ae5 100644 --- a/services/core/internal/deployment/storage.go +++ b/services/core/internal/deployment/storage.go @@ -494,13 +494,14 @@ type EnrollmentRecord struct { ID, InstallationID string ExpiresAt time.Time Consumed bool - MaxActive, MaxRetained int + MaxActive, MaxRetained *int } // NewEnrollment is an enrollment token to store, by digest. type NewEnrollment struct { ID, TokenDigest, InstallationID string - MaxActive, MaxRetained int + // A nil limit is unset: Core derives it from the host when the node enrolls. + MaxActive, MaxRetained *int } // Heartbeat is a node's health for one connection. diff --git a/services/core/internal/execution/runtime_preparation_capacity_test.go b/services/core/internal/execution/runtime_preparation_capacity_test.go index 45142170d..1e2fa76ee 100644 --- a/services/core/internal/execution/runtime_preparation_capacity_test.go +++ b/services/core/internal/execution/runtime_preparation_capacity_test.go @@ -196,7 +196,7 @@ func TestUnsettledFilesystemPreparationYieldsOnlyCompute(t *testing.T) { if err != nil { t.Fatal(err) } - token, err := r.deployments.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 1, MaxRetained: 2}) + token, err := r.deployments.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(1), MaxRetained: new(2)}) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/execution/runtime_replacement_test.go b/services/core/internal/execution/runtime_replacement_test.go index 1d066d5c3..9b3d2c7d6 100644 --- a/services/core/internal/execution/runtime_replacement_test.go +++ b/services/core/internal/execution/runtime_replacement_test.go @@ -339,7 +339,7 @@ func workspaceNodeSetup(t *testing.T, external bool) func(Owner, *deployment.Ser if err != nil { t.Fatal(err) } - token, err := service.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 1, MaxRetained: 2}) + token, err := service.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(1), MaxRetained: new(2)}) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/execution/runtime_restore_attempt_test.go b/services/core/internal/execution/runtime_restore_attempt_test.go index ce584d995..fa677fbd1 100644 --- a/services/core/internal/execution/runtime_restore_attempt_test.go +++ b/services/core/internal/execution/runtime_restore_attempt_test.go @@ -116,7 +116,7 @@ func TestCheckpointTransferRoutesBeforeTargetIO(t *testing.T) { if err != nil { t.Fatal(err) } - token, err := r.deployments.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 1, MaxRetained: 2}) + token, err := r.deployments.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(1), MaxRetained: new(2)}) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/persistence/postgres/deploymentpg/fixture_test.go b/services/core/internal/persistence/postgres/deploymentpg/fixture_test.go index a50c40d3d..aa7175e1b 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/fixture_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/fixture_test.go @@ -118,7 +118,7 @@ func (f fixture) initialize(t *testing.T, changes *deployment.ExecutionOperation // generation with it. func (f fixture) enroll(t *testing.T, view deployment.View, capacity deployment.Capacity) deployment.Enrollment { t.Helper() - token, err := f.service.CreateEnrollment(admin(t), capacity) + token, err := f.service.CreateEnrollment(admin(t), deployment.EnrollmentLimits{MaxActive: &capacity.MaxActive, MaxRetained: &capacity.MaxRetained}) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/persistence/postgres/deploymentpg/nodes_test.go b/services/core/internal/persistence/postgres/deploymentpg/nodes_test.go index 399e75669..09df492d9 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/nodes_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/nodes_test.go @@ -39,19 +39,15 @@ func TestEnrollmentApprovedCapacity(t *testing.T) { f := newFixture(t) changes, _ := f.execution(t) _, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")}) - for _, capacity := range []deployment.Capacity{{MaxActive: 0, MaxRetained: 8}, {MaxActive: 3, MaxRetained: 2}, {MaxActive: 1, MaxRetained: 1000001}} { - if _, err := f.service.CreateEnrollment(t.Context(), capacity); !errors.Is(err, deployment.ErrInvalidInput) { + for _, limits := range []deployment.EnrollmentLimits{{MaxActive: new(0), MaxRetained: new(8)}, {MaxActive: new(3), MaxRetained: new(2)}, {MaxActive: new(1), MaxRetained: new(1000001)}, {MaxRetained: new(0)}} { + if _, err := f.service.CreateEnrollment(t.Context(), limits); !errors.Is(err, deployment.ErrInvalidInput) { t.Fatal("invalid capacity accepted", err) } } - token, err := f.service.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 1, MaxRetained: 3}) + token, err := f.service.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(1), MaxRetained: new(3)}) if err != nil { t.Fatal(err) } - config, err := f.service.NodeConfiguration(t.Context(), "", token.Token, 0) - if err != nil || config.MaxActive != 1 || config.MaxRetained != 3 { - t.Fatal("bootstrap lost approved capacity", config, err) - } input := nodesEnrollment(view, "approved", "a") identity, err := f.service.Enroll(t.Context(), token.Token, input) if err != nil || identity.MaxActive != 1 || identity.MaxRetained != 3 { @@ -75,9 +71,44 @@ func TestEnrollmentApprovedCapacity(t *testing.T) { if err != nil || identity.MaxActive != 2 || identity.MaxRetained != 5 { t.Fatal("credential read ignored admin update", identity, err) } - config, err = f.service.NodeConfiguration(t.Context(), input.NodeID, input.Credential, 0) - if err != nil || config.MaxActive != 2 || config.MaxRetained != 5 { - t.Fatal("configuration read ignored admin update", config, err) +} + +// A token that leaves a limit unset takes it from the enrolling host; an explicit limit stays. +func TestEnrollmentDerivesUnsetLimitsFromHost(t *testing.T) { + f := newFixture(t) + changes, _ := f.execution(t) + _, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")}) + host := func(input deployment.Enrollment) deployment.Enrollment { + cpus, memory, disk := int64(64), int64(256<<30), int64(8<<40) + input.CPUCount, input.TotalMemoryBytes, input.AvailableDiskBytes = &cpus, &memory, &disk + return input + } + enroll := func(limits deployment.EnrollmentLimits, input deployment.Enrollment) (deployment.NodeIdentity, error) { + token, err := f.service.CreateEnrollment(t.Context(), limits) + if err != nil { + t.Fatal(err) + } + return f.service.Enroll(t.Context(), token.Token, input) + } + if _, err := enroll(deployment.EnrollmentLimits{}, nodesEnrollment(view, "blind", "b")); !errors.Is(err, deployment.ErrNodeHostUnmeasured) { + t.Fatal("unset limits enrolled without host measurements", err) + } + recommended, err := enroll(deployment.EnrollmentLimits{}, host(nodesEnrollment(view, "derived", "d"))) + if err != nil || recommended.MaxActive < 2 || recommended.MaxRetained < recommended.MaxActive { + t.Fatal("unset limits were not derived from the host", recommended, err) + } + explicit, err := enroll(deployment.EnrollmentLimits{MaxActive: new(1), MaxRetained: new(1)}, host(nodesEnrollment(view, "explicit", "e"))) + if err != nil || explicit.MaxActive != 1 || explicit.MaxRetained != 1 { + t.Fatal("explicit limits changed", explicit, err) + } + // Only retained set: it binds, and active follows the host but never above it. + bound, err := enroll(deployment.EnrollmentLimits{MaxRetained: new(4)}, host(nodesEnrollment(view, "bound", "r"))) + if err != nil || bound.MaxActive > 4 || bound.MaxRetained != 4 { + t.Fatal("an explicit retained limit was overridden", bound, err) + } + partial, err := enroll(deployment.EnrollmentLimits{MaxActive: new(1)}, host(nodesEnrollment(view, "partial", "p"))) + if err != nil || partial.MaxActive != 1 || partial.MaxRetained < 1 { + t.Fatal("an unset retained limit was not derived", partial, err) } } @@ -86,7 +117,7 @@ func TestDockerRetainedLimitFollowsActive(t *testing.T) { f := newFixture(t) changes, _ := f.execution(t) _, view := f.initialize(t, changes, sandbox.Selection{Provider: "docker", DeploymentSpec: testSpecification("docker")}) - token, err := f.service.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 3, MaxRetained: 1}) + token, err := f.service.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(3), MaxRetained: new(1)}) if err != nil { t.Fatal(err) } @@ -122,7 +153,7 @@ func TestNodeCredentialPrecedesDeploymentState(t *testing.T) { t.Fatal("unknown node authenticated", err) } _, view := f.initialize(t, changes, sandbox.Selection{Provider: "docker", DeploymentSpec: testSpecification("docker")}) - consumed, err := f.service.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 1, MaxRetained: 1}) + consumed, err := f.service.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(1), MaxRetained: new(1)}) if err != nil { t.Fatal(err) } @@ -130,7 +161,7 @@ func TestNodeCredentialPrecedesDeploymentState(t *testing.T) { if _, err := f.service.Enroll(t.Context(), consumed.Token, node); err != nil { t.Fatal(err) } - fresh, err := f.service.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 1, MaxRetained: 1}) + fresh, err := f.service.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(1), MaxRetained: new(1)}) if err != nil { t.Fatal(err) } @@ -192,7 +223,7 @@ func TestEnrollmentAddressMismatchKeepsToken(t *testing.T) { f := newFixture(t) changes, _ := f.execution(t) _, view := f.initialize(t, changes, sandbox.Selection{Provider: "docker", DeploymentSpec: testSpecification("docker")}) - token, err := f.service.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 1, MaxRetained: 1}) + token, err := f.service.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(1), MaxRetained: new(1)}) if err != nil { t.Fatal(err) } @@ -233,7 +264,7 @@ func TestEnrollmentReplay(t *testing.T) { f := newFixture(t) changes, _ := f.execution(t) _, view := f.initialize(t, changes, sandbox.Selection{Provider: "docker", DeploymentSpec: testSpecification("docker")}) - token, err := f.service.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 1, MaxRetained: 1}) + token, err := f.service.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(1), MaxRetained: new(1)}) if err != nil { t.Fatal(err) } @@ -248,7 +279,7 @@ func TestEnrollmentReplay(t *testing.T) { if recovered, err := f.service.AuthenticateNode(t.Context(), input.NodeID, input.Credential); err != nil || recovered != identity { t.Fatal("lost response cannot recover", recovered, err) } - second, err := f.service.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 1, MaxRetained: 1}) + second, err := f.service.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(1), MaxRetained: new(1)}) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/persistence/postgres/deploymentpg/reset_test.go b/services/core/internal/persistence/postgres/deploymentpg/reset_test.go index 5e242387a..fde3c15a0 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/reset_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/reset_test.go @@ -54,7 +54,7 @@ func TestResetStartEscalateAndComplete(t *testing.T) { changes, _ := f.execution(t) installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "docker", DeploymentSpec: testSpecification("docker")}) f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1}) - if _, err := f.service.CreateEnrollment(admin(t), deployment.Capacity{MaxActive: 1, MaxRetained: 1}); err != nil { + if _, err := f.service.CreateEnrollment(admin(t), deployment.EnrollmentLimits{MaxActive: new(1), MaxRetained: new(1)}); err != nil { t.Fatal(err) } diff --git a/services/core/internal/persistence/postgres/deploymentpg/specification_test.go b/services/core/internal/persistence/postgres/deploymentpg/specification_test.go index 30531a86d..101753e7e 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/specification_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/specification_test.go @@ -76,7 +76,7 @@ func TestEnrollmentRecordsItsIDAndRefusesAnotherAddress(t *testing.T) { f := newFixture(t) changes, _ := f.execution(t) _, view := f.initialize(t, changes, sandbox.Selection{Provider: "docker", DeploymentSpec: testSpecification("docker")}) - issued, err := f.service.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 2, MaxRetained: 2}) + issued, err := f.service.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(2), MaxRetained: new(2)}) if err != nil || uuid.Validate(issued.ID) != nil || issued.Token == "" { t.Fatal(issued.ID, err) } @@ -90,7 +90,7 @@ func TestEnrollmentRecordsItsIDAndRefusesAnotherAddress(t *testing.T) { t.Fatal("the refused enrollment consumed its token", err) } earlier := strings.Repeat("e", 64) - if _, err := f.pool.Exec(t.Context(), "INSERT INTO runtime_node_enrollments(token_sha256,installation_id,expires_at) VALUES($1,$2,clock_timestamp()+interval '10 minutes')", + if _, err := f.pool.Exec(t.Context(), "INSERT INTO runtime_node_enrollments(token_sha256,installation_id,expires_at,max_active,max_retained) VALUES($1,$2,clock_timestamp()+interval '10 minutes',2,2)", setupDigest(earlier), view.InstallationID); err != nil { t.Fatal(err) } diff --git a/services/core/internal/persistence/postgres/deploymentpg/tx.go b/services/core/internal/persistence/postgres/deploymentpg/tx.go index fb290d7db..765460cc2 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/tx.go +++ b/services/core/internal/persistence/postgres/deploymentpg/tx.go @@ -85,7 +85,7 @@ func (u unit) LoadEnrollment(tokenDigest string) (deployment.EnrollmentRecord, e if err != nil { return deployment.EnrollmentRecord{}, err } - return deployment.EnrollmentRecord{ID: uuidString(r.ID), InstallationID: uuidString(r.InstallationID), ExpiresAt: r.ExpiresAt.Time, Consumed: r.ConsumedAt.Valid, MaxActive: int(r.MaxActive), MaxRetained: int(r.MaxRetained)}, nil + return deployment.EnrollmentRecord{ID: uuidString(r.ID), InstallationID: uuidString(r.InstallationID), ExpiresAt: r.ExpiresAt.Time, Consumed: r.ConsumedAt.Valid, MaxActive: optionalInt(r.MaxActive), MaxRetained: optionalInt(r.MaxRetained)}, nil } func (u unit) LoadGenerationSpecification(generation uint64) (deployment.GenerationSpecification, error) { @@ -175,6 +175,21 @@ func (t *nodeTx) RemoveNode(id string) error { return t.q.RemoveRuntimeNode(t.ctx, nodeID) } +func optionalInt4(v *int) pgtype.Int4 { + if v == nil { + return pgtype.Int4{} + } + return pgtype.Int4{Int32: int32(*v), Valid: true} +} + +func optionalInt(v pgtype.Int4) *int { + if !v.Valid { + return nil + } + n := int(v.Int32) + return &n +} + func (t *nodeTx) CreateEnrollment(enrollment deployment.NewEnrollment) (time.Time, error) { id, err := parseID(enrollment.ID) if err != nil { @@ -184,7 +199,7 @@ func (t *nodeTx) CreateEnrollment(enrollment deployment.NewEnrollment) (time.Tim if err != nil { return time.Time{}, err } - if err := t.q.CreateRuntimeEnrollment(t.ctx, sqlc.CreateRuntimeEnrollmentParams{ID: id, TokenSha256: enrollment.TokenDigest, InstallationID: installation, MaxActive: int32(enrollment.MaxActive), MaxRetained: int32(enrollment.MaxRetained)}); err != nil { + if err := t.q.CreateRuntimeEnrollment(t.ctx, sqlc.CreateRuntimeEnrollmentParams{ID: id, TokenSha256: enrollment.TokenDigest, InstallationID: installation, MaxActive: optionalInt4(enrollment.MaxActive), MaxRetained: optionalInt4(enrollment.MaxRetained)}); err != nil { return time.Time{}, err } row, err := t.q.GetRuntimeEnrollment(t.ctx, enrollment.TokenDigest) diff --git a/services/core/internal/sandbox/node/enrollment.go b/services/core/internal/sandbox/node/enrollment.go index cf8dfc2e3..7b5a98acd 100644 --- a/services/core/internal/sandbox/node/enrollment.go +++ b/services/core/internal/sandbox/node/enrollment.go @@ -33,6 +33,9 @@ func Enroll(ctx context.Context, coreURL, dir, token string, input EnrollmentReq input.NodeID, input.Credential = stored.Identity.NodeID, stored.Credential input.Provider, input.BackendFingerprint = stored.Identity.Provider, stored.Identity.BackendFingerprint input.CoreURL = coreURL + var host Health + new(hostHealthSampler).fill(&host, dir) + input.CPUCount, input.EffectiveCPUCores, input.TotalMemoryBytes, input.AvailableDiskBytes = host.CPUCount, host.EffectiveCPUCores, host.TotalMemoryBytes, host.AvailableDiskBytes client := &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} // This also recovers a consumed registration whose success response was lost. target, err := endpoint(coreURL, "/api/v1/sandbox-node/identity") diff --git a/services/core/internal/sandbox/node/node_test.go b/services/core/internal/sandbox/node/node_test.go index 9bcc5c3fb..6cd59ea97 100644 --- a/services/core/internal/sandbox/node/node_test.go +++ b/services/core/internal/sandbox/node/node_test.go @@ -248,6 +248,9 @@ func TestEnrollmentLostResponseRecoversWithPersistedCredential(t *testing.T) { if body.CoreURL != "http://"+r.Host { t.Error("enrollment did not name the node's Core address", body.CoreURL) } + if body.CPUCount == nil || *body.CPUCount < 1 { + t.Error("enrollment did not report the host measurements", body) + } enrollments++ registered = true hijacker := w.(http.Hijacker) diff --git a/services/core/internal/sandbox/node/wire.go b/services/core/internal/sandbox/node/wire.go index 59324ec8e..1032d20d2 100644 --- a/services/core/internal/sandbox/node/wire.go +++ b/services/core/internal/sandbox/node/wire.go @@ -60,6 +60,11 @@ type EnrollmentRequest struct { BackendFingerprint string `json:"backend_fingerprint"` // The retained Core origin; Core refuses an enrollment whose address is not its public URL. CoreURL string `json:"core_url"` + // The host measurements of Health. Core derives from them each limit the enrollment token leaves unset. + CPUCount *int64 `json:"cpu_count,omitempty"` + EffectiveCPUCores *float64 `json:"effective_cpu_cores,omitempty"` + TotalMemoryBytes *int64 `json:"total_memory_bytes,omitempty"` + AvailableDiskBytes *int64 `json:"available_disk_bytes,omitempty"` } type EnrollmentResponse struct { diff --git a/services/core/migrations/000003_enrollment_limits_optional.sql b/services/core/migrations/000003_enrollment_limits_optional.sql new file mode 100644 index 000000000..40aec7410 --- /dev/null +++ b/services/core/migrations/000003_enrollment_limits_optional.sql @@ -0,0 +1,3 @@ +-- +goose Up +ALTER TABLE runtime_node_enrollments ALTER COLUMN max_active DROP DEFAULT, ALTER COLUMN max_active DROP NOT NULL; +ALTER TABLE runtime_node_enrollments ALTER COLUMN max_retained DROP DEFAULT, ALTER COLUMN max_retained DROP NOT NULL; diff --git a/services/core/tests/integration/admin_session_archive_race_test.go b/services/core/tests/integration/admin_session_archive_race_test.go index 177dc8ecb..d932eee93 100644 --- a/services/core/tests/integration/admin_session_archive_race_test.go +++ b/services/core/tests/integration/admin_session_archive_race_test.go @@ -25,7 +25,7 @@ func TestManagedSessionArchiveReleasesPendingNodePlacement(t *testing.T) { t.Fatal(err) } nodes := deploymentService(t, s) - token, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 1, MaxRetained: 1})) + token, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(1), MaxRetained: new(1)})) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/credential_matrix_http_test.go b/services/core/tests/integration/credential_matrix_http_test.go index 96e630f29..61d4ef3d6 100644 --- a/services/core/tests/integration/credential_matrix_http_test.go +++ b/services/core/tests/integration/credential_matrix_http_test.go @@ -113,7 +113,7 @@ func TestCredentialNamespaceMatrix(t *testing.T) { t.Fatal(err) } var enrollment api.SandboxEnrollmentToken - created("POST", "/core/v1/sandbox/enrollment-tokens", coreKey, `{}`, &enrollment) + created("POST", "/core/v1/sandbox/enrollment-tokens", coreKey, `{"max_active":2,"max_retained":2}`, &enrollment) nodeID, nodeCredential := uuid.NewString(), strings.Repeat("n", 64) enroll, _ := json.Marshal(deployment.Enrollment{NodeID: nodeID, Credential: nodeCredential, Name: "Matrix node", Provider: "docker", BackendFingerprint: strings.Repeat("b", 64), DeploymentGeneration: 1, SpecificationDigest: specification.Digest("docker"), CoreURL: "https://core.example"}) diff --git a/services/core/tests/integration/device_bootstrap_binding_test.go b/services/core/tests/integration/device_bootstrap_binding_test.go index f6187833c..e0295490b 100644 --- a/services/core/tests/integration/device_bootstrap_binding_test.go +++ b/services/core/tests/integration/device_bootstrap_binding_test.go @@ -14,7 +14,7 @@ import ( func TestDeviceCredentialCarriesPersistedAllocationNode(t *testing.T) { s, writer, d := managerFixture(t, 4, 8) nodes := deploymentService(t, s) - token, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 4, MaxRetained: 8})) + token, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(4), MaxRetained: new(8)})) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/runtime_lifecycle_nodes_test.go b/services/core/tests/integration/runtime_lifecycle_nodes_test.go index b87368a27..e8ecfa47c 100644 --- a/services/core/tests/integration/runtime_lifecycle_nodes_test.go +++ b/services/core/tests/integration/runtime_lifecycle_nodes_test.go @@ -16,7 +16,7 @@ import ( func lifecycleTestNode(t *testing.T, s *Store) string { t.Helper() nodes := deploymentService(t, s) - token, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 100, MaxRetained: 100})) + token, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(100), MaxRetained: new(100)})) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/runtime_node_capacity_test.go b/services/core/tests/integration/runtime_node_capacity_test.go index c53bc8c00..2e52f89f2 100644 --- a/services/core/tests/integration/runtime_node_capacity_test.go +++ b/services/core/tests/integration/runtime_node_capacity_test.go @@ -16,10 +16,10 @@ func TestRuntimeEnrollmentCapacityPrecedesResetConflict(t *testing.T) { if err := deploymentExecution(t, w).StartReset(SandboxResetTestContext(t.Context()), view.InstallationID, deployment.ResetRequest{ExpectedGeneration: view.Generation, Clear: "auto"}); err != nil { t.Fatal(err) } - if _, err := nodes.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 1, MaxRetained: 3}); !errors.Is(err, deployment.ErrResetInProgress) { + if _, err := nodes.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(1), MaxRetained: new(3)}); !errors.Is(err, deployment.ErrResetInProgress) { t.Fatal("valid capacity did not reach the reset guard", err) } - if _, err := nodes.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 3, MaxRetained: 2}); !errors.Is(err, deployment.ErrInvalidInput) { + if _, err := nodes.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(3), MaxRetained: new(2)}); !errors.Is(err, deployment.ErrInvalidInput) { t.Fatal("invalid capacity reported as a conflict", err) } } diff --git a/services/core/tests/integration/runtime_node_generations_test.go b/services/core/tests/integration/runtime_node_generations_test.go index be902f790..9a8a5e86a 100644 --- a/services/core/tests/integration/runtime_node_generations_test.go +++ b/services/core/tests/integration/runtime_node_generations_test.go @@ -67,7 +67,7 @@ func TestNodeGenerationsCapacityFallbackAndImmutablePending(t *testing.T) { t.Fatal(err) } pendingNode, pendingGeneration := placedGeneration(t, s, w, pending) - token, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 1, MaxRetained: 2})) + token, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(1), MaxRetained: new(2)})) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/runtime_node_lifecycle_fixture_test.go b/services/core/tests/integration/runtime_node_lifecycle_fixture_test.go index 0d304a63b..1444fbc12 100644 --- a/services/core/tests/integration/runtime_node_lifecycle_fixture_test.go +++ b/services/core/tests/integration/runtime_node_lifecycle_fixture_test.go @@ -134,7 +134,7 @@ func newNodeIsolationFixture(t *testing.T, mode string) *nodeIsolationFixture { } func (f *nodeIsolationFixture) enroll(id string) { f.t.Helper() - token, err := EnrollmentTestToken(f.nodes.CreateEnrollment(f.t.Context(), deployment.Capacity{MaxActive: 100, MaxRetained: 100})) + token, err := EnrollmentTestToken(f.nodes.CreateEnrollment(f.t.Context(), deployment.EnrollmentLimits{MaxActive: new(100), MaxRetained: new(100)})) if err != nil { f.t.Fatal(err) } diff --git a/services/core/tests/integration/runtime_nodes_test.go b/services/core/tests/integration/runtime_nodes_test.go index 0093253d3..ad231d148 100644 --- a/services/core/tests/integration/runtime_nodes_test.go +++ b/services/core/tests/integration/runtime_nodes_test.go @@ -229,7 +229,7 @@ func TestRuntimeNodesAtomicPlacementAndRetry(t *testing.T) { func TestRuntimeNodesEnrollmentAndEpoch(t *testing.T) { s, w, d := managerFixture(t, 2, 4) nodes := deploymentService(t, s) - token, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 2, MaxRetained: 4})) + token, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(2), MaxRetained: new(4)})) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/sandbox_deployment_switch_test.go b/services/core/tests/integration/sandbox_deployment_switch_test.go index e1f96a4e4..00216b66f 100644 --- a/services/core/tests/integration/sandbox_deployment_switch_test.go +++ b/services/core/tests/integration/sandbox_deployment_switch_test.go @@ -91,7 +91,7 @@ func TestSandboxDirectDeploymentOwnershipAndCleanSwitch(t *testing.T) { if err != nil || setup.Configuration.(*e2b.DeploymentConfiguration).APIKey != input.Configuration.(*e2b.DeploymentConfiguration).APIKey { t.Fatal("internal credential unavailable", err) } - if _, err := deploymentService(t, s).CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 2, MaxRetained: 8}); !errors.Is(err, deployment.ErrConflict) { + if _, err := deploymentService(t, s).CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(2), MaxRetained: new(8)}); !errors.Is(err, deployment.ErrConflict) { t.Fatal("cloud enrolled a machine", err) } tenant := uuid.NewString() @@ -155,7 +155,7 @@ func TestSandboxSwitchRetiresNodesAndEnrollment(t *testing.T) { if _, err := changes.Initialize(t.Context(), id, sandbox.Selection{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}); err != nil { t.Fatal(err) } - token, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 2, MaxRetained: 4})) + token, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(2), MaxRetained: new(4)})) if err != nil { t.Fatal(err) } @@ -163,7 +163,7 @@ func TestSandboxSwitchRetiresNodesAndEnrollment(t *testing.T) { if _, err := nodes.Enroll(t.Context(), token, node); err != nil { t.Fatal(err) } - unused, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 2, MaxRetained: 8})) + unused, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(2), MaxRetained: new(8)})) if err != nil { t.Fatal(err) } @@ -355,7 +355,7 @@ func TestUnspecifiedNodeDeploymentRejectedWithoutMutation(t *testing.T) { if _, err := changes.Initialize(t.Context(), id, selection); err != nil { t.Fatal(err) } - token, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 2, MaxRetained: 4})) + token, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(2), MaxRetained: new(4)})) if err != nil { t.Fatal(err) } @@ -382,7 +382,7 @@ func TestUnspecifiedNodeDeploymentRejectedWithoutMutation(t *testing.T) { if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString())); !errors.Is(err, placement.ErrAdmissionClosed) { t.Fatal("unspecified deployment admitted a fresh sandbox", err) } - if _, err := nodes.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 2, MaxRetained: 4}); !errors.Is(err, deployment.ErrConflict) { + if _, err := nodes.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(2), MaxRetained: new(4)}); !errors.Is(err, deployment.ErrConflict) { t.Fatal("unspecified deployment issued an enrollment token", err) } diff --git a/services/core/tests/integration/sandbox_deployment_switch_worker_test.go b/services/core/tests/integration/sandbox_deployment_switch_worker_test.go index 212751f8c..bb4e39edd 100644 --- a/services/core/tests/integration/sandbox_deployment_switch_worker_test.go +++ b/services/core/tests/integration/sandbox_deployment_switch_worker_test.go @@ -67,7 +67,7 @@ func TestSandboxWorkerSwitchesAndRecoversFailedActivation(t *testing.T) { if _, err := w.InitializeSandboxDeployment(t.Context(), sandbox.Selection{DeploymentSpec: SandboxDeploymentTestSpec("docker"), Provider: "docker"}); err == nil || preparations.Load() != prepared { t.Fatal("stale identical POST reached provider preparation", err) } - enrollment, err := EnrollmentTestToken(deployments.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 2, MaxRetained: 4})) + enrollment, err := EnrollmentTestToken(deployments.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(2), MaxRetained: new(4)})) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/sandbox_deployment_worker_test.go b/services/core/tests/integration/sandbox_deployment_worker_test.go index 39417be9f..e67932b5f 100644 --- a/services/core/tests/integration/sandbox_deployment_worker_test.go +++ b/services/core/tests/integration/sandbox_deployment_worker_test.go @@ -57,7 +57,7 @@ func TestSandboxDeploymentWorkerActivatesWithoutRestart(t *testing.T) { if _, err := w.ProvisionEnvironment(t.Context(), queued.TenantID, queued.Environment.ID, id); !errors.Is(err, placement.ErrNodeUnavailable) { t.Fatal("zero-node deployment provisioned compute", err) } - token, err := EnrollmentTestToken(deployments.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 4, MaxRetained: 16})) + token, err := EnrollmentTestToken(deployments.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(4), MaxRetained: new(16)})) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/sandbox_specification_lifecycle_test.go b/services/core/tests/integration/sandbox_specification_lifecycle_test.go index ff9903153..0ac2be436 100644 --- a/services/core/tests/integration/sandbox_specification_lifecycle_test.go +++ b/services/core/tests/integration/sandbox_specification_lifecycle_test.go @@ -45,7 +45,7 @@ func specificationNode(t *testing.T, s *Store, view deployment.View) deployment. func enrollNode(t *testing.T, s *Store, view deployment.View, capacity deployment.Capacity) deployment.Enrollment { t.Helper() nodes := deploymentService(t, s) - token, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), capacity)) + token, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: &capacity.MaxActive, MaxRetained: &capacity.MaxRetained})) if err != nil { t.Fatal(err) } @@ -61,7 +61,7 @@ func enrollNode(t *testing.T, s *Store, view deployment.View, capacity deploymen func TestSandboxSpecificationBootstrapReadDoesNotConsumeEnrollment(t *testing.T) { s, w, view, input := webSpecificationFixture(t, "docker") nodes := deploymentService(t, s) - token, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.Capacity{MaxActive: 2, MaxRetained: 4})) + token, err := EnrollmentTestToken(nodes.CreateEnrollment(t.Context(), deployment.EnrollmentLimits{MaxActive: new(2), MaxRetained: new(4)})) if err != nil { t.Fatal(err) }