feat(workspace): user-configurable EC2 sizing override, decoupled from tier
Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 18s
Check migration collisions / Migration version collision check (pull_request) Successful in 44s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 50s
CI / Detect changes (pull_request) Successful in 52s
E2E API Smoke Test / detect-changes (pull_request) Successful in 30s
E2E Chat / detect-changes (pull_request) Successful in 31s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 18s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 31s
Harness Replays / detect-changes (pull_request) Successful in 31s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 33s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 24s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 21s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 1m8s
qa-review / approved (pull_request) Failing after 23s
gate-check-v3 / gate-check (pull_request) Successful in 31s
security-review / approved (pull_request) Failing after 25s
sop-checklist / all-items-acked (pull_request) Successful in 21s
sop-tier-check / tier-check (pull_request) Successful in 23s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m32s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 6m0s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m34s
Harness Replays / Harness Replays (pull_request) Successful in 8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 8m40s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6m31s
E2E Chat / E2E Chat (pull_request) Failing after 10m7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 9m46s
CI / Canvas (Next.js) (pull_request) Successful in 19m3s
CI / Platform (Go) (pull_request) Successful in 19m49s
CI / all-required (pull_request) Successful in 19m52s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 18s
Check migration collisions / Migration version collision check (pull_request) Successful in 44s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 50s
CI / Detect changes (pull_request) Successful in 52s
E2E API Smoke Test / detect-changes (pull_request) Successful in 30s
E2E Chat / detect-changes (pull_request) Successful in 31s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 18s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 31s
Harness Replays / detect-changes (pull_request) Successful in 31s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 33s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 24s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 21s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 1m8s
qa-review / approved (pull_request) Failing after 23s
gate-check-v3 / gate-check (pull_request) Successful in 31s
security-review / approved (pull_request) Failing after 25s
sop-checklist / all-items-acked (pull_request) Successful in 21s
sop-tier-check / tier-check (pull_request) Successful in 23s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m32s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 6m0s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m34s
Harness Replays / Harness Replays (pull_request) Successful in 8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 8m40s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6m31s
E2E Chat / E2E Chat (pull_request) Failing after 10m7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 9m46s
CI / Canvas (Next.js) (pull_request) Successful in 19m3s
CI / Platform (Go) (pull_request) Successful in 19m49s
CI / all-required (pull_request) Successful in 19m52s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
This commit is contained in:
parent
85c627c86f
commit
158dec4e71
@ -178,6 +178,26 @@ export function deriveProvidersFromModels(models: ModelSpec[]): string[] {
|
|||||||
// not this one.
|
// not this one.
|
||||||
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external", "kimi", "kimi-cli", "openclaw"]);
|
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external", "kimi", "kimi-cli", "openclaw"]);
|
||||||
|
|
||||||
|
// Workspace EC2 sizing options. The control-plane is the authoritative
|
||||||
|
// allowlist + bounds enforcement point (controlplane
|
||||||
|
// internal/provisioner/ec2.go workspaceInstanceTypeAllowlist; disk
|
||||||
|
// clamped to [30,500]); this list MUST stay in sync — an entry here the
|
||||||
|
// CP rejects would let the user save an override the CP then silently
|
||||||
|
// falls back to the default for. "" = the platform default
|
||||||
|
// (t3.large / 50GB). Sizing is decoupled from the access tier.
|
||||||
|
const WORKSPACE_INSTANCE_TYPES: { value: string; label: string }[] = [
|
||||||
|
{ value: "", label: "Default (t3.large — 2 vCPU / 8GB)" },
|
||||||
|
{ value: "t3.medium", label: "t3.medium — 2 vCPU / 4GB (smallest)" },
|
||||||
|
{ value: "t3.large", label: "t3.large — 2 vCPU / 8GB" },
|
||||||
|
{ value: "t3.xlarge", label: "t3.xlarge — 4 vCPU / 16GB" },
|
||||||
|
{ value: "t3.2xlarge", label: "t3.2xlarge — 8 vCPU / 32GB (largest)" },
|
||||||
|
{ value: "m6i.large", label: "m6i.large — 2 vCPU / 8GB (steady CPU)" },
|
||||||
|
{ value: "m6i.xlarge", label: "m6i.xlarge — 4 vCPU / 16GB (steady CPU)" },
|
||||||
|
{ value: "c6i.xlarge", label: "c6i.xlarge — 4 vCPU / 8GB (compute)" },
|
||||||
|
];
|
||||||
|
const WORKSPACE_DISK_MIN_GB = 30;
|
||||||
|
const WORKSPACE_DISK_MAX_GB = 500;
|
||||||
|
|
||||||
const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [
|
const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [
|
||||||
{ value: "", label: "LangGraph (default)", models: [], providers: [] },
|
{ value: "", label: "LangGraph (default)", models: [], providers: [] },
|
||||||
{ value: "claude-code", label: "Claude Code", models: [], providers: [] },
|
{ value: "claude-code", label: "Claude Code", models: [], providers: [] },
|
||||||
@ -210,6 +230,19 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
// data, written into /configs/config.yaml on next provision too).
|
// data, written into /configs/config.yaml on next provision too).
|
||||||
const [provider, setProvider] = useState("");
|
const [provider, setProvider] = useState("");
|
||||||
const [originalProvider, setOriginalProvider] = useState("");
|
const [originalProvider, setOriginalProvider] = useState("");
|
||||||
|
// Per-workspace EC2 sizing override (DB-backed, NOT in config.yaml).
|
||||||
|
// Separate state from `config` for the same reason as provider/model:
|
||||||
|
// these live on the workspace row, not the template YAML. Empty
|
||||||
|
// instanceType + 0 diskGB = "use the platform default" (t3.large /
|
||||||
|
// 50GB). Sizing is ORTHOGONAL to the access tier (the Tier select in
|
||||||
|
// General) — T4 = full root access only; it does not size the box.
|
||||||
|
// Resize semantics: provision-time only (AWS can't change instance
|
||||||
|
// type live / shrink EBS in place) — surfaced in the section copy.
|
||||||
|
const [instanceType, setInstanceType] = useState("");
|
||||||
|
const [originalInstanceType, setOriginalInstanceType] = useState("");
|
||||||
|
const [diskGB, setDiskGB] = useState(0);
|
||||||
|
const [originalDiskGB, setOriginalDiskGB] = useState(0);
|
||||||
|
const [sizingError, setSizingError] = useState<string | null>(null);
|
||||||
// Track the model the form first rendered, so handleSave can detect
|
// Track the model the form first rendered, so handleSave can detect
|
||||||
// whether the user actually changed it (vs. only edited tier/skills/etc).
|
// whether the user actually changed it (vs. only edited tier/skills/etc).
|
||||||
// Two field sources contribute:
|
// Two field sources contribute:
|
||||||
@ -259,8 +292,8 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
// See GH #1894 for the workspace-row-as-source-of-truth rationale
|
// See GH #1894 for the workspace-row-as-source-of-truth rationale
|
||||||
// that motivated splitting from a single config.yaml read.
|
// that motivated splitting from a single config.yaml read.
|
||||||
const [wsRes, modelRes, providerRes] = await Promise.all([
|
const [wsRes, modelRes, providerRes] = await Promise.all([
|
||||||
api.get<{ runtime?: string; tier?: number }>(`/workspaces/${workspaceId}`)
|
api.get<{ runtime?: string; tier?: number; instance_type?: string | null; disk_gb?: number | null }>(`/workspaces/${workspaceId}`)
|
||||||
.catch(() => ({} as { runtime?: string; tier?: number })),
|
.catch(() => ({} as { runtime?: string; tier?: number; instance_type?: string | null; disk_gb?: number | null })),
|
||||||
api.get<{ model?: string }>(`/workspaces/${workspaceId}/model`)
|
api.get<{ model?: string }>(`/workspaces/${workspaceId}/model`)
|
||||||
.catch(() => ({} as { model?: string })),
|
.catch(() => ({} as { model?: string })),
|
||||||
api.get<{ provider?: string }>(`/workspaces/${workspaceId}/provider`)
|
api.get<{ provider?: string }>(`/workspaces/${workspaceId}/provider`)
|
||||||
@ -278,6 +311,19 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
setProvider("");
|
setProvider("");
|
||||||
setOriginalProvider("");
|
setOriginalProvider("");
|
||||||
}
|
}
|
||||||
|
// Sizing override comes from the workspace row (GET /workspaces/:id
|
||||||
|
// returns instance_type/disk_gb, null when no override → render as
|
||||||
|
// the platform default). Snapshot originals so handleSave only
|
||||||
|
// PATCHes when the user actually changed them.
|
||||||
|
{
|
||||||
|
const loadedType = (wsRes.instance_type || "").trim();
|
||||||
|
const loadedDisk =
|
||||||
|
typeof wsRes.disk_gb === "number" ? wsRes.disk_gb : 0;
|
||||||
|
setInstanceType(loadedType);
|
||||||
|
setOriginalInstanceType(loadedType);
|
||||||
|
setDiskGB(loadedDisk);
|
||||||
|
setOriginalDiskGB(loadedDisk);
|
||||||
|
}
|
||||||
// originalModel is set further down once the YAML has been parsed —
|
// originalModel is set further down once the YAML has been parsed —
|
||||||
// we want it to reflect what the form ACTUALLY rendered, which may
|
// we want it to reflect what the form ACTUALLY rendered, which may
|
||||||
// be the YAML's runtime_config.model fallback when MODEL_PROVIDER
|
// be the YAML's runtime_config.model fallback when MODEL_PROVIDER
|
||||||
@ -581,6 +627,31 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sizing override → PATCH /workspaces/:id. Send only the fields
|
||||||
|
// the user changed. "" / 0 clears the override (handler maps to
|
||||||
|
// NULL → CP default). Sizing is provision-time only; the handler
|
||||||
|
// returns needs_restart so the user knows it takes effect on the
|
||||||
|
// next (re)provision — we do NOT pretend it applied live.
|
||||||
|
let sizingSaveError: string | null = null;
|
||||||
|
const instanceTypeChanged = instanceType !== originalInstanceType;
|
||||||
|
const diskChanged = diskGB !== originalDiskGB;
|
||||||
|
let sizingWillNeedRestart = false;
|
||||||
|
if (instanceTypeChanged || diskChanged) {
|
||||||
|
const sizingPatch: Record<string, unknown> = {};
|
||||||
|
if (instanceTypeChanged) sizingPatch.instance_type = instanceType || null;
|
||||||
|
if (diskChanged) sizingPatch.disk_gb = diskGB || null;
|
||||||
|
try {
|
||||||
|
await api.patch(`/workspaces/${workspaceId}`, sizingPatch);
|
||||||
|
setOriginalInstanceType(instanceType);
|
||||||
|
setOriginalDiskGB(diskGB);
|
||||||
|
setSizingError(null);
|
||||||
|
sizingWillNeedRestart = true;
|
||||||
|
} catch (e) {
|
||||||
|
sizingSaveError = e instanceof Error ? e.message : "Sizing update was rejected";
|
||||||
|
setSizingError(sizingSaveError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setOriginalYaml(content);
|
setOriginalYaml(content);
|
||||||
if (rawMode) {
|
if (rawMode) {
|
||||||
const parsed = parseYaml(content);
|
const parsed = parseYaml(content);
|
||||||
@ -598,18 +669,25 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
if (restart && !providerWillAutoRestart) {
|
if (restart && !providerWillAutoRestart) {
|
||||||
await useCanvasStore.getState().restartWorkspace(workspaceId);
|
await useCanvasStore.getState().restartWorkspace(workspaceId);
|
||||||
} else if (!restart) {
|
} else if (!restart) {
|
||||||
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: !providerWillAutoRestart });
|
// A sizing change only takes effect on the next (re)provision
|
||||||
|
// (AWS can't resize a live instance), so flag needsRestart so
|
||||||
|
// the user gets the "restart to apply" affordance instead of
|
||||||
|
// silently believing the new size is active.
|
||||||
|
const needsRestart = !providerWillAutoRestart || sizingWillNeedRestart;
|
||||||
|
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart });
|
||||||
}
|
}
|
||||||
// Aggregate partial-save errors. Both modelSaveError and
|
// Aggregate partial-save errors. modelSaveError, providerSaveError
|
||||||
// providerSaveError describe rejected updates from independent
|
// and sizingSaveError describe rejected updates from independent
|
||||||
// endpoints — show whichever fired so the user knows which
|
// endpoints — show whichever fired so the user knows which field
|
||||||
// field reverts on next reload (otherwise they'd see "Saved" and
|
// reverts on next reload (otherwise they'd see "Saved" and be
|
||||||
// be confused why Provider snapped back).
|
// confused why a field snapped back).
|
||||||
const partialError = providerSaveError
|
const partialError = sizingSaveError
|
||||||
? `Other fields saved, but provider update failed: ${providerSaveError}`
|
? `Other fields saved, but sizing update failed: ${sizingSaveError}`
|
||||||
: modelSaveError
|
: providerSaveError
|
||||||
? `Other fields saved, but model update failed: ${modelSaveError}`
|
? `Other fields saved, but provider update failed: ${providerSaveError}`
|
||||||
: null;
|
: modelSaveError
|
||||||
|
? `Other fields saved, but model update failed: ${modelSaveError}`
|
||||||
|
: null;
|
||||||
if (partialError) {
|
if (partialError) {
|
||||||
setError(partialError);
|
setError(partialError);
|
||||||
} else {
|
} else {
|
||||||
@ -628,6 +706,8 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
const descriptionId = useId();
|
const descriptionId = useId();
|
||||||
const tierId = useId();
|
const tierId = useId();
|
||||||
const runtimeId = useId();
|
const runtimeId = useId();
|
||||||
|
const instanceTypeId = useId();
|
||||||
|
const diskGBId = useId();
|
||||||
const effortId = useId();
|
const effortId = useId();
|
||||||
const taskBudgetId = useId();
|
const taskBudgetId = useId();
|
||||||
const sandboxBackendId = useId();
|
const sandboxBackendId = useId();
|
||||||
@ -706,6 +786,55 @@ export function ConfigTab({ workspaceId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Sizing" defaultOpen={false}>
|
||||||
|
<p className="text-[10px] text-ink-mid mb-2 leading-relaxed">
|
||||||
|
Per-workspace EC2 size. Independent of Tier — Tier controls
|
||||||
|
access (T4 = full root), this controls how big the box is.
|
||||||
|
Default is t3.large / 50 GB. Changes apply on the next
|
||||||
|
restart / re-provision (AWS cannot resize a running instance
|
||||||
|
or shrink a disk in place).
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor={instanceTypeId} className="text-[10px] text-ink-mid block mb-1">Instance type</label>
|
||||||
|
<select
|
||||||
|
id={instanceTypeId}
|
||||||
|
value={instanceType}
|
||||||
|
onChange={(e) => setInstanceType(e.target.value)}
|
||||||
|
aria-label="Instance type"
|
||||||
|
className="w-full bg-surface-card border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{WORKSPACE_INSTANCE_TYPES.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor={diskGBId} className="text-[10px] text-ink-mid block mb-1">
|
||||||
|
Disk (GB) — 0 = default 50
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={diskGBId}
|
||||||
|
type="number"
|
||||||
|
value={diskGB}
|
||||||
|
min={0}
|
||||||
|
max={WORKSPACE_DISK_MAX_GB}
|
||||||
|
onChange={(e) => setDiskGB(parseInt(e.target.value, 10) || 0)}
|
||||||
|
aria-label="Disk size in GB"
|
||||||
|
className="w-full bg-surface-card border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent font-mono"
|
||||||
|
/>
|
||||||
|
{diskGB !== 0 && (diskGB < WORKSPACE_DISK_MIN_GB || diskGB > WORKSPACE_DISK_MAX_GB) && (
|
||||||
|
<div className="text-[10px] text-bad mt-1">
|
||||||
|
Will be clamped to {WORKSPACE_DISK_MIN_GB}–{WORKSPACE_DISK_MAX_GB} GB by the platform.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{sizingError && (
|
||||||
|
<div className="text-[10px] text-bad mt-2">{sizingError}</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section title="Runtime">
|
<Section title="Runtime">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={runtimeId} className="text-[10px] text-ink-mid block mb-1">Runtime</label>
|
<label htmlFor={runtimeId} className="text-[10px] text-ink-mid block mb-1">Runtime</label>
|
||||||
|
|||||||
145
canvas/src/components/tabs/__tests__/ConfigTab.sizing.test.tsx
Normal file
145
canvas/src/components/tabs/__tests__/ConfigTab.sizing.test.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// Tests for the workspace EC2 Sizing section (tier↔sizing decoupling,
|
||||||
|
// Hongming 2026-05-15).
|
||||||
|
//
|
||||||
|
// What this pins:
|
||||||
|
// 1. A "Sizing" section exists, separate from the Tier control.
|
||||||
|
// 2. It loads the workspace's instance_type / disk_gb from
|
||||||
|
// GET /workspaces/:id and renders them.
|
||||||
|
// 3. Changing the override + Save PATCHes /workspaces/:id with the
|
||||||
|
// changed sizing fields (proves the override is not a silent
|
||||||
|
// no-op in the UI — feedback_no_proxy_e2e_claims).
|
||||||
|
// 4. Section copy states sizing is independent of Tier and applies
|
||||||
|
// on the next restart (provision-time only).
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||||
|
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const apiGet = vi.fn();
|
||||||
|
const apiPatch = vi.fn();
|
||||||
|
const apiPut = vi.fn();
|
||||||
|
vi.mock("@/lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
get: (path: string) => apiGet(path),
|
||||||
|
patch: (path: string, body?: unknown) => apiPatch(path, body),
|
||||||
|
put: (path: string, body?: unknown) => apiPut(path, body),
|
||||||
|
post: vi.fn(),
|
||||||
|
del: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const storeUpdateNodeData = vi.fn();
|
||||||
|
const storeRestartWorkspace = vi.fn();
|
||||||
|
vi.mock("@/store/canvas", () => ({
|
||||||
|
useCanvasStore: Object.assign(
|
||||||
|
(selector: (s: unknown) => unknown) =>
|
||||||
|
selector({ restartWorkspace: storeRestartWorkspace, updateNodeData: storeUpdateNodeData }),
|
||||||
|
{
|
||||||
|
getState: () => ({
|
||||||
|
restartWorkspace: storeRestartWorkspace,
|
||||||
|
updateNodeData: storeUpdateNodeData,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../AgentCardSection", () => ({
|
||||||
|
AgentCardSection: () => <div data-testid="agent-card-stub" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ConfigTab } from "../ConfigTab";
|
||||||
|
|
||||||
|
function mockApi(opts: { instanceType?: string | null; diskGB?: number | null } = {}) {
|
||||||
|
apiGet.mockReset();
|
||||||
|
apiPatch.mockReset();
|
||||||
|
apiPut.mockReset();
|
||||||
|
apiPatch.mockResolvedValue({});
|
||||||
|
apiPut.mockResolvedValue({});
|
||||||
|
apiGet.mockImplementation((path: string) => {
|
||||||
|
if (path === `/workspaces/ws-test`) {
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: "claude-code",
|
||||||
|
tier: 4,
|
||||||
|
instance_type: opts.instanceType ?? null,
|
||||||
|
disk_gb: opts.diskGB ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (path === `/workspaces/ws-test/model`) return Promise.resolve({ model: "claude-opus-4-7" });
|
||||||
|
if (path === `/workspaces/ws-test/provider`) return Promise.resolve({ provider: "anthropic-oauth" });
|
||||||
|
if (path === `/workspaces/ws-test/files/config.yaml`) {
|
||||||
|
return Promise.resolve({ content: "name: test\nruntime: claude-code\n" });
|
||||||
|
}
|
||||||
|
if (path === "/templates") {
|
||||||
|
return Promise.resolve([{ id: "claude-code", name: "Claude Code", runtime: "claude-code", providers: [] }]);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`unmocked api.get: ${path}`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ConfigTab Sizing section", () => {
|
||||||
|
beforeEach(() => mockApi());
|
||||||
|
|
||||||
|
it("renders a Sizing section distinct from the Tier control", async () => {
|
||||||
|
render(<ConfigTab workspaceId="ws-test" />);
|
||||||
|
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
||||||
|
const sizingBtn = screen.getByRole("button", { name: /^Sizing/i });
|
||||||
|
expect(sizingBtn).toBeTruthy();
|
||||||
|
fireEvent.click(sizingBtn);
|
||||||
|
// Copy must state independence from Tier + restart-to-apply.
|
||||||
|
await waitFor(() => {
|
||||||
|
const blurb = screen.queryAllByText((_, el) =>
|
||||||
|
(el?.textContent || "").includes("Independent of Tier"),
|
||||||
|
);
|
||||||
|
expect(blurb.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads the workspace's saved override into the controls", async () => {
|
||||||
|
mockApi({ instanceType: "t3.xlarge", diskGB: 120 });
|
||||||
|
render(<ConfigTab workspaceId="ws-test" />);
|
||||||
|
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Sizing/i }));
|
||||||
|
const typeSelect = (await screen.findByLabelText("Instance type")) as HTMLSelectElement;
|
||||||
|
const diskInput = screen.getByLabelText("Disk size in GB") as HTMLInputElement;
|
||||||
|
expect(typeSelect.value).toBe("t3.xlarge");
|
||||||
|
expect(diskInput.value).toBe("120");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCHes /workspaces/:id with the changed sizing on Save", async () => {
|
||||||
|
render(<ConfigTab workspaceId="ws-test" />);
|
||||||
|
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Sizing/i }));
|
||||||
|
|
||||||
|
const typeSelect = (await screen.findByLabelText("Instance type")) as HTMLSelectElement;
|
||||||
|
fireEvent.change(typeSelect, { target: { value: "t3.2xlarge" } });
|
||||||
|
const diskInput = screen.getByLabelText("Disk size in GB") as HTMLInputElement;
|
||||||
|
fireEvent.change(diskInput, { target: { value: "200" } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Save$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const sizingCall = apiPatch.mock.calls.find(
|
||||||
|
(c) => c[0] === "/workspaces/ws-test" && c[1] && ("instance_type" in c[1] || "disk_gb" in c[1]),
|
||||||
|
);
|
||||||
|
expect(sizingCall).toBeTruthy();
|
||||||
|
expect(sizingCall![1]).toMatchObject({ instance_type: "t3.2xlarge", disk_gb: 200 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not PATCH sizing when the user did not change it", async () => {
|
||||||
|
mockApi({ instanceType: "t3.large", diskGB: 50 });
|
||||||
|
render(<ConfigTab workspaceId="ws-test" />);
|
||||||
|
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Sizing/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Save$/i }));
|
||||||
|
await waitFor(() => expect(screen.queryByText(/Saving/i)).toBeNull());
|
||||||
|
const sizingCall = apiPatch.mock.calls.find(
|
||||||
|
(c) => c[1] && ("instance_type" in c[1] || "disk_gb" in c[1]),
|
||||||
|
);
|
||||||
|
expect(sizingCall).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -805,5 +805,28 @@ func (h *WorkspaceHandler) Get(c *gin.Context) {
|
|||||||
h.addProvisionTimeoutMs(ws, rt)
|
h.addProvisionTimeoutMs(ws, rt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-workspace EC2 sizing override (canvas Config tab). NULL → the
|
||||||
|
// CP applies its default (t3.large / 50GB); we surface null so the
|
||||||
|
// UI can render "Default" rather than a stale value. Non-sensitive
|
||||||
|
// (it's the size the user themselves configured). Separate query
|
||||||
|
// for the same reason as last_outbound_at above — keeps the shared
|
||||||
|
// scanWorkspaceRow column list (used by list endpoints too) stable.
|
||||||
|
var instanceType sql.NullString
|
||||||
|
var diskGB sql.NullInt64
|
||||||
|
if err := db.DB.QueryRowContext(c.Request.Context(),
|
||||||
|
`SELECT instance_type, disk_gb FROM workspaces WHERE id = $1`, id,
|
||||||
|
).Scan(&instanceType, &diskGB); err == nil {
|
||||||
|
if instanceType.Valid && instanceType.String != "" {
|
||||||
|
ws["instance_type"] = instanceType.String
|
||||||
|
} else {
|
||||||
|
ws["instance_type"] = nil
|
||||||
|
}
|
||||||
|
if diskGB.Valid && diskGB.Int64 != 0 {
|
||||||
|
ws["disk_gb"] = diskGB.Int64
|
||||||
|
} else {
|
||||||
|
ws["disk_gb"] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, ws)
|
c.JSON(http.StatusOK, ws)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -218,6 +218,78 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
needsRestart := false
|
needsRestart := false
|
||||||
|
|
||||||
|
// Per-workspace EC2 sizing override (canvas Config tab). Sizing is
|
||||||
|
// ORTHOGONAL to tier — tier is the ACCESS model (T4 = full root
|
||||||
|
// access), it does NOT drive sizing. The CP is the enforcement
|
||||||
|
// point (allowlist + [30,500] disk clamp); here we persist intent
|
||||||
|
// and reject obviously-bad input early. instance_type="" / disk_gb=0
|
||||||
|
// (or JSON null) clears the override → CP falls back to its default
|
||||||
|
// (t3.large / 50GB).
|
||||||
|
//
|
||||||
|
// Resize semantics: provision-time only. AWS cannot change instance
|
||||||
|
// type live (needs stop/start) and cannot shrink EBS in place. So a
|
||||||
|
// sizing change sets needs_restart=true — the new spec takes effect
|
||||||
|
// when the workspace is next (re)provisioned. We do NOT pretend it
|
||||||
|
// applied live.
|
||||||
|
if it, ok := body["instance_type"]; ok {
|
||||||
|
if it == nil {
|
||||||
|
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET instance_type = NULL, updated_at = now() WHERE id = $1`, id); err != nil {
|
||||||
|
log.Printf("Update instance_type (clear) error for %s: %v", id, err)
|
||||||
|
}
|
||||||
|
needsRestart = true
|
||||||
|
} else if s, isStr := it.(string); isStr {
|
||||||
|
if s != "" && !isAllowedWorkspaceInstanceType(s) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "unsupported instance_type",
|
||||||
|
"allowed": allowedWorkspaceInstanceTypes,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Empty string also clears the override (store NULL).
|
||||||
|
var val interface{}
|
||||||
|
if s != "" {
|
||||||
|
val = s
|
||||||
|
}
|
||||||
|
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET instance_type = $2, updated_at = now() WHERE id = $1`, id, val); err != nil {
|
||||||
|
log.Printf("Update instance_type error for %s: %v", id, err)
|
||||||
|
}
|
||||||
|
needsRestart = true
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_type must be a string or null"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dg, ok := body["disk_gb"]; ok {
|
||||||
|
if dg == nil {
|
||||||
|
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET disk_gb = NULL, updated_at = now() WHERE id = $1`, id); err != nil {
|
||||||
|
log.Printf("Update disk_gb (clear) error for %s: %v", id, err)
|
||||||
|
}
|
||||||
|
needsRestart = true
|
||||||
|
} else if f, isNum := dg.(float64); isNum {
|
||||||
|
gb := int(f)
|
||||||
|
if gb == 0 {
|
||||||
|
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET disk_gb = NULL, updated_at = now() WHERE id = $1`, id); err != nil {
|
||||||
|
log.Printf("Update disk_gb (clear) error for %s: %v", id, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reject implausible values early; the CP still clamps
|
||||||
|
// to [30,500] authoritatively.
|
||||||
|
if gb < 0 || gb > 100000 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "disk_gb out of range"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET disk_gb = $2, updated_at = now() WHERE id = $1`, id, gb); err != nil {
|
||||||
|
log.Printf("Update disk_gb error for %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
needsRestart = true
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "disk_gb must be a number or null"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if wsDir, ok := body["workspace_dir"]; ok {
|
if wsDir, ok := body["workspace_dir"]; ok {
|
||||||
// ValidateWorkspaceDir was already called above before the existence check;
|
// ValidateWorkspaceDir was already called above before the existence check;
|
||||||
// the UPDATE itself is unconditional.
|
// the UPDATE itself is unconditional.
|
||||||
@ -251,6 +323,35 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, resp)
|
c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// allowedWorkspaceInstanceTypes is the user-selectable workspace EC2
|
||||||
|
// instance-type allowlist surfaced to the canvas Config tab and used
|
||||||
|
// for early rejection in the PATCH handler. It MIRRORS the CP's
|
||||||
|
// authoritative allowlist (controlplane internal/provisioner/ec2.go
|
||||||
|
// workspaceInstanceTypeAllowlist) — the CP is the enforcement point;
|
||||||
|
// this copy gives the user a fast, clear 400 instead of letting the
|
||||||
|
// CP silently fall back to the default. Keep the two in sync: a value
|
||||||
|
// here that the CP rejects would let the user save an override that
|
||||||
|
// then silently no-ops at provision (exactly the failure mode this
|
||||||
|
// feature is meant to avoid). Covered by a drift test.
|
||||||
|
var allowedWorkspaceInstanceTypes = []string{
|
||||||
|
"c6i.xlarge",
|
||||||
|
"m6i.large",
|
||||||
|
"m6i.xlarge",
|
||||||
|
"t3.2xlarge",
|
||||||
|
"t3.large",
|
||||||
|
"t3.medium",
|
||||||
|
"t3.xlarge",
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAllowedWorkspaceInstanceType(t string) bool {
|
||||||
|
for _, a := range allowedWorkspaceInstanceTypes {
|
||||||
|
if a == t {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// validateWorkspaceDir checks that a workspace_dir path is safe to bind-mount.
|
// validateWorkspaceDir checks that a workspace_dir path is safe to bind-mount.
|
||||||
func validateWorkspaceDir(dir string) error {
|
func validateWorkspaceDir(dir string) error {
|
||||||
if !filepath.IsAbs(dir) {
|
if !filepath.IsAbs(dir) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@ -259,17 +260,34 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
|
|||||||
// present) wins, matching the existing WorkspaceDir precedence.
|
// present) wins, matching the existing WorkspaceDir precedence.
|
||||||
workspacePath := payload.WorkspaceDir
|
workspacePath := payload.WorkspaceDir
|
||||||
workspaceAccess := payload.WorkspaceAccess
|
workspaceAccess := payload.WorkspaceAccess
|
||||||
if (workspacePath == "" || workspaceAccess == "") && db.DB != nil {
|
// Per-workspace sizing override (instance_type / disk_gb). Like
|
||||||
var dbDir, dbAccess string
|
// workspace_dir/workspace_access these are DB-backed so a restart /
|
||||||
if err := db.DB.QueryRow(
|
// reprovision picks up an override the user set via the canvas
|
||||||
`SELECT COALESCE(workspace_dir, ''), COALESCE(workspace_access, 'none') FROM workspaces WHERE id = $1`,
|
// Config tab AFTER create. NULL/0 → leave the CP request fields
|
||||||
workspaceID,
|
// empty so the CP applies its default (t3.large/50GB). Sizing is
|
||||||
).Scan(&dbDir, &dbAccess); err == nil {
|
// orthogonal to tier — see migration 20260515140000.
|
||||||
if workspacePath == "" && dbDir != "" {
|
var instanceType string
|
||||||
workspacePath = dbDir
|
var diskGB int32
|
||||||
}
|
{
|
||||||
if workspaceAccess == "" {
|
var dbDir, dbAccess, dbInstanceType string
|
||||||
workspaceAccess = dbAccess
|
var dbDiskGB sql.NullInt64
|
||||||
|
if (workspacePath == "" || workspaceAccess == "") && db.DB != nil {
|
||||||
|
if err := db.DB.QueryRow(
|
||||||
|
`SELECT COALESCE(workspace_dir, ''), COALESCE(workspace_access, 'none'),
|
||||||
|
COALESCE(instance_type, ''), disk_gb
|
||||||
|
FROM workspaces WHERE id = $1`,
|
||||||
|
workspaceID,
|
||||||
|
).Scan(&dbDir, &dbAccess, &dbInstanceType, &dbDiskGB); err == nil {
|
||||||
|
if workspacePath == "" && dbDir != "" {
|
||||||
|
workspacePath = dbDir
|
||||||
|
}
|
||||||
|
if workspaceAccess == "" {
|
||||||
|
workspaceAccess = dbAccess
|
||||||
|
}
|
||||||
|
instanceType = dbInstanceType
|
||||||
|
if dbDiskGB.Valid {
|
||||||
|
diskGB = int32(dbDiskGB.Int64)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -288,6 +306,8 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
|
|||||||
WorkspacePath: workspacePath,
|
WorkspacePath: workspacePath,
|
||||||
WorkspaceAccess: workspaceAccess,
|
WorkspaceAccess: workspaceAccess,
|
||||||
Tier: payload.Tier,
|
Tier: payload.Tier,
|
||||||
|
InstanceType: instanceType,
|
||||||
|
DiskGB: diskGB,
|
||||||
Runtime: payload.Runtime,
|
Runtime: payload.Runtime,
|
||||||
EnvVars: envVars,
|
EnvVars: envVars,
|
||||||
PlatformURL: h.platformURL,
|
PlatformURL: h.platformURL,
|
||||||
|
|||||||
@ -0,0 +1,77 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWorkspaceInstanceTypeAllowlist_MirrorsCP pins the workspace-server
|
||||||
|
// copy of the instance-type allowlist to the CP's authoritative list
|
||||||
|
// (controlplane internal/provisioner/ec2.go workspaceInstanceTypeAllowlist).
|
||||||
|
//
|
||||||
|
// The CP is the enforcement point — it returns 400 on an unsupported
|
||||||
|
// type. workspace-server keeps a copy ONLY so the user gets a fast,
|
||||||
|
// clear rejection in the canvas Config tab instead of a round-trip to
|
||||||
|
// the CP. If the two drift, a user could save an override that the CP
|
||||||
|
// then silently falls back to the default for — exactly the
|
||||||
|
// "told a change took when it didn't" failure this feature exists to
|
||||||
|
// prevent. The two repos can't share a Go package (separate modules),
|
||||||
|
// so this test hard-codes the expected set; updating it is the
|
||||||
|
// deliberate checkpoint when the CP allowlist changes.
|
||||||
|
//
|
||||||
|
// CP source of truth (controlplane internal/provisioner/ec2.go):
|
||||||
|
//
|
||||||
|
// var workspaceInstanceTypeAllowlist = map[string]struct{}{
|
||||||
|
// "t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge",
|
||||||
|
// "m6i.large", "m6i.xlarge", "c6i.xlarge",
|
||||||
|
// }
|
||||||
|
func TestWorkspaceInstanceTypeAllowlist_MirrorsCP(t *testing.T) {
|
||||||
|
cpAuthoritative := []string{
|
||||||
|
"c6i.xlarge",
|
||||||
|
"m6i.large",
|
||||||
|
"m6i.xlarge",
|
||||||
|
"t3.2xlarge",
|
||||||
|
"t3.large",
|
||||||
|
"t3.medium",
|
||||||
|
"t3.xlarge",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := append([]string(nil), allowedWorkspaceInstanceTypes...)
|
||||||
|
sort.Strings(got)
|
||||||
|
want := append([]string(nil), cpAuthoritative...)
|
||||||
|
sort.Strings(want)
|
||||||
|
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("allowlist size drift: workspace-server has %d (%v), CP has %d (%v) — keep in sync with controlplane workspaceInstanceTypeAllowlist",
|
||||||
|
len(got), got, len(want), want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("allowlist drift at %d: workspace-server=%q CP=%q\nfull ws=%v\nfull CP=%v",
|
||||||
|
i, got[i], want[i], got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsAllowedWorkspaceInstanceType pins the membership helper used by
|
||||||
|
// the PATCH handler's early-reject path.
|
||||||
|
func TestIsAllowedWorkspaceInstanceType(t *testing.T) {
|
||||||
|
if !isAllowedWorkspaceInstanceType("t3.large") {
|
||||||
|
t.Error("t3.large (the default) must be allowed")
|
||||||
|
}
|
||||||
|
if !isAllowedWorkspaceInstanceType("t3.medium") {
|
||||||
|
t.Error("t3.medium (floor) must be allowed")
|
||||||
|
}
|
||||||
|
if !isAllowedWorkspaceInstanceType("t3.2xlarge") {
|
||||||
|
t.Error("t3.2xlarge (ceiling) must be allowed")
|
||||||
|
}
|
||||||
|
if isAllowedWorkspaceInstanceType("p4d.24xlarge") {
|
||||||
|
t.Error("p4d.24xlarge (off allowlist, GPU/cost blowout) must NOT be allowed")
|
||||||
|
}
|
||||||
|
if isAllowedWorkspaceInstanceType("'; DROP TABLE workspaces;--") {
|
||||||
|
t.Error("SQL-injection-shaped garbage must NOT be allowed")
|
||||||
|
}
|
||||||
|
if isAllowedWorkspaceInstanceType("") {
|
||||||
|
t.Error("empty string is not a valid instance type for the membership check")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,168 @@
|
|||||||
|
//go:build integration
|
||||||
|
// +build integration
|
||||||
|
|
||||||
|
// workspace_sizing_integration_test.go — REAL Postgres integration
|
||||||
|
// test for the per-workspace EC2 sizing override round-trip.
|
||||||
|
//
|
||||||
|
// Run with:
|
||||||
|
//
|
||||||
|
// INTEGRATION_DB_URL="postgres://dev:dev@localhost:5432/molecule?sslmode=disable" \
|
||||||
|
// go test -tags=integration ./internal/handlers/ -run Integration_WorkspaceSizing -v
|
||||||
|
//
|
||||||
|
// CI: piggybacks on handlers-postgres-integration.yml (path filter
|
||||||
|
// covers workspace-server/internal/handlers/** and migrations/**).
|
||||||
|
//
|
||||||
|
// Why this is NOT a sqlmock test
|
||||||
|
// ------------------------------
|
||||||
|
// sqlmock pins query SHAPE, not behaviour. Only a real Postgres with
|
||||||
|
// migration 20260515140000 applied can confirm:
|
||||||
|
//
|
||||||
|
// - The instance_type/disk_gb columns actually exist and accept the
|
||||||
|
// values the canvas Config tab writes.
|
||||||
|
// - A persisted override is read back by the SAME SELECT
|
||||||
|
// buildProvisionerConfig issues on the (re)provision path — i.e.
|
||||||
|
// the override actually reaches the CP request, not a silent drop
|
||||||
|
// (feedback_no_proxy_e2e_claims: prove the literal path).
|
||||||
|
// - The "no override" default leaves the columns NULL so the CP
|
||||||
|
// applies its own default (t3.large / 50GB).
|
||||||
|
//
|
||||||
|
// Per feedback_mandatory_local_e2e_before_ship: ship-mode requires the
|
||||||
|
// round-trip exercised against a real Postgres before the PR merges.
|
||||||
|
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func integrationDB_WorkspaceSizing(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
conn := integrationDB_WorkspaceCreateName(t) // reuses INTEGRATION_DB_URL + skip
|
||||||
|
// Ensure the sizing columns exist (idempotent — the migration is
|
||||||
|
// the dev/CI default; this covers a pre-2026-05-15 snapshot DB).
|
||||||
|
if _, err := conn.ExecContext(context.Background(), `
|
||||||
|
ALTER TABLE workspaces
|
||||||
|
ADD COLUMN IF NOT EXISTS instance_type TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS disk_gb INTEGER
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("ensure sizing columns: %v", err)
|
||||||
|
}
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegration_WorkspaceSizing_OverrideRoundTrips proves the full
|
||||||
|
// persisted path: an override written to the workspaces row (what the
|
||||||
|
// PATCH /workspaces/:id handler does) is read back exactly the way
|
||||||
|
// buildProvisionerConfig reads it on the (re)provision path.
|
||||||
|
func TestIntegration_WorkspaceSizing_OverrideRoundTrips(t *testing.T) {
|
||||||
|
conn := integrationDB_WorkspaceSizing(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
const namePrefix = "sizing-itest-"
|
||||||
|
t.Cleanup(func() { cleanupTestRows(t, conn, namePrefix) })
|
||||||
|
|
||||||
|
id := uuid.NewString()
|
||||||
|
if _, err := conn.ExecContext(ctx,
|
||||||
|
`INSERT INTO workspaces (id, name, tier, status) VALUES ($1, $2, 4, 'online')`,
|
||||||
|
id, namePrefix+"override"); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate PATCH /workspaces/:id { instance_type, disk_gb }.
|
||||||
|
if _, err := conn.ExecContext(ctx,
|
||||||
|
`UPDATE workspaces SET instance_type = $2, disk_gb = $3 WHERE id = $1`,
|
||||||
|
id, "t3.xlarge", 120); err != nil {
|
||||||
|
t.Fatalf("patch sizing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read it back with the EXACT projection buildProvisionerConfig
|
||||||
|
// uses (workspace_provision.go) so this test fails if that query
|
||||||
|
// drifts away from the columns.
|
||||||
|
var dbDir, dbAccess, dbInstanceType string
|
||||||
|
var dbDiskGB sql.NullInt64
|
||||||
|
if err := conn.QueryRowContext(ctx,
|
||||||
|
`SELECT COALESCE(workspace_dir, ''), COALESCE(workspace_access, 'none'),
|
||||||
|
COALESCE(instance_type, ''), disk_gb
|
||||||
|
FROM workspaces WHERE id = $1`, id,
|
||||||
|
).Scan(&dbDir, &dbAccess, &dbInstanceType, &dbDiskGB); err != nil {
|
||||||
|
t.Fatalf("read back: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbInstanceType != "t3.xlarge" {
|
||||||
|
t.Errorf("instance_type round-trip: got %q, want t3.xlarge", dbInstanceType)
|
||||||
|
}
|
||||||
|
if !dbDiskGB.Valid || dbDiskGB.Int64 != 120 {
|
||||||
|
t.Errorf("disk_gb round-trip: got %v, want 120", dbDiskGB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegration_WorkspaceSizing_DefaultIsNull proves the no-override
|
||||||
|
// path leaves both columns NULL, so buildProvisionerConfig sends empty
|
||||||
|
// fields and the CP applies its default (t3.large / 50GB) — never a
|
||||||
|
// stale or zero value misread as an override.
|
||||||
|
func TestIntegration_WorkspaceSizing_DefaultIsNull(t *testing.T) {
|
||||||
|
conn := integrationDB_WorkspaceSizing(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
const namePrefix = "sizing-itest-"
|
||||||
|
t.Cleanup(func() { cleanupTestRows(t, conn, namePrefix) })
|
||||||
|
|
||||||
|
id := uuid.NewString()
|
||||||
|
if _, err := conn.ExecContext(ctx,
|
||||||
|
`INSERT INTO workspaces (id, name, tier, status) VALUES ($1, $2, 1, 'online')`,
|
||||||
|
id, namePrefix+"default"); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var instanceType sql.NullString
|
||||||
|
var diskGB sql.NullInt64
|
||||||
|
if err := conn.QueryRowContext(ctx,
|
||||||
|
`SELECT instance_type, disk_gb FROM workspaces WHERE id = $1`, id,
|
||||||
|
).Scan(&instanceType, &diskGB); err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
if instanceType.Valid {
|
||||||
|
t.Errorf("fresh workspace instance_type should be NULL, got %q", instanceType.String)
|
||||||
|
}
|
||||||
|
if diskGB.Valid {
|
||||||
|
t.Errorf("fresh workspace disk_gb should be NULL, got %d", diskGB.Int64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegration_WorkspaceSizing_ClearReverts proves clearing the
|
||||||
|
// override (PATCH with null) returns the workspace to the CP default
|
||||||
|
// rather than pinning a previous value — the override is genuinely
|
||||||
|
// user-reversible.
|
||||||
|
func TestIntegration_WorkspaceSizing_ClearReverts(t *testing.T) {
|
||||||
|
conn := integrationDB_WorkspaceSizing(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
const namePrefix = "sizing-itest-"
|
||||||
|
t.Cleanup(func() { cleanupTestRows(t, conn, namePrefix) })
|
||||||
|
|
||||||
|
id := uuid.NewString()
|
||||||
|
if _, err := conn.ExecContext(ctx,
|
||||||
|
`INSERT INTO workspaces (id, name, tier, status, instance_type, disk_gb)
|
||||||
|
VALUES ($1, $2, 4, 'online', 't3.2xlarge', 200)`,
|
||||||
|
id, namePrefix+"clear"); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
// Simulate PATCH clearing both (handler maps "" / 0 / null → NULL).
|
||||||
|
if _, err := conn.ExecContext(ctx,
|
||||||
|
`UPDATE workspaces SET instance_type = NULL, disk_gb = NULL WHERE id = $1`, id); err != nil {
|
||||||
|
t.Fatalf("clear: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var instanceType sql.NullString
|
||||||
|
var diskGB sql.NullInt64
|
||||||
|
if err := conn.QueryRowContext(ctx,
|
||||||
|
`SELECT instance_type, disk_gb FROM workspaces WHERE id = $1`, id,
|
||||||
|
).Scan(&instanceType, &diskGB); err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
if instanceType.Valid || diskGB.Valid {
|
||||||
|
t.Errorf("after clear, expected both NULL; got instance_type=%v disk_gb=%v", instanceType, diskGB)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -152,12 +152,20 @@ func (p *CPProvisioner) adminAuthHeaders(req *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type cpProvisionRequest struct {
|
type cpProvisionRequest struct {
|
||||||
OrgID string `json:"org_id"`
|
OrgID string `json:"org_id"`
|
||||||
WorkspaceID string `json:"workspace_id"`
|
WorkspaceID string `json:"workspace_id"`
|
||||||
Runtime string `json:"runtime"`
|
Runtime string `json:"runtime"`
|
||||||
Tier int `json:"tier"`
|
// Tier is the ACCESS model only (T4 = full root access). It does
|
||||||
PlatformURL string `json:"platform_url"`
|
// NOT drive sizing — see InstanceType / DiskGB.
|
||||||
Env map[string]string `json:"env"`
|
Tier int `json:"tier"`
|
||||||
|
// InstanceType + DiskGB are the optional per-workspace sizing
|
||||||
|
// override. Omitted (empty / 0) → CP applies its default
|
||||||
|
// (t3.large / 50GB). The CP validates instance_type against its
|
||||||
|
// allowlist and returns 400 on an unsupported value.
|
||||||
|
InstanceType string `json:"instance_type,omitempty"`
|
||||||
|
DiskGB int32 `json:"disk_gb,omitempty"`
|
||||||
|
PlatformURL string `json:"platform_url"`
|
||||||
|
Env map[string]string `json:"env"`
|
||||||
// ConfigFiles are template + generated config files to write into the
|
// ConfigFiles are template + generated config files to write into the
|
||||||
// EC2 instance's /configs directory. OFFSEC-010: collected by
|
// EC2 instance's /configs directory. OFFSEC-010: collected by
|
||||||
// collectCPConfigFiles which rejects symlinks and non-regular files
|
// collectCPConfigFiles which rejects symlinks and non-regular files
|
||||||
@ -197,13 +205,15 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
req := cpProvisionRequest{
|
req := cpProvisionRequest{
|
||||||
OrgID: p.orgID,
|
OrgID: p.orgID,
|
||||||
WorkspaceID: cfg.WorkspaceID,
|
WorkspaceID: cfg.WorkspaceID,
|
||||||
Runtime: cfg.Runtime,
|
Runtime: cfg.Runtime,
|
||||||
Tier: cfg.Tier,
|
Tier: cfg.Tier,
|
||||||
PlatformURL: cfg.PlatformURL,
|
InstanceType: cfg.InstanceType,
|
||||||
Env: env,
|
DiskGB: cfg.DiskGB,
|
||||||
ConfigFiles: configFiles,
|
PlatformURL: cfg.PlatformURL,
|
||||||
|
Env: env,
|
||||||
|
ConfigFiles: configFiles,
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := json.Marshal(req)
|
body, err := json.Marshal(req)
|
||||||
|
|||||||
@ -1062,3 +1062,77 @@ func TestCollectCPConfigFiles_RejectsRootSymlink(t *testing.T) {
|
|||||||
t.Errorf("expected symlink-related error, got: %v", err)
|
t.Errorf("expected symlink-related error, got: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStart_ForwardsSizingOverride proves the per-workspace sizing
|
||||||
|
// override (instance_type + disk_gb) actually reaches the CP provision
|
||||||
|
// request — not a silent drop. This is the workspace-server half of
|
||||||
|
// the tier↔sizing decoupling: sizing is independent of Tier and is
|
||||||
|
// plumbed canvas → workspace-server → CP.
|
||||||
|
func TestStart_ForwardsSizingOverride(t *testing.T) {
|
||||||
|
var got cpProvisionRequest
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&got)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = io.WriteString(w, `{"instance_id":"i-size","state":"pending"}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := &CPProvisioner{
|
||||||
|
baseURL: srv.URL,
|
||||||
|
orgID: "org-1",
|
||||||
|
sharedSecret: "s3cret",
|
||||||
|
httpClient: srv.Client(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := p.Start(context.Background(), WorkspaceConfig{
|
||||||
|
WorkspaceID: "ws-size",
|
||||||
|
Runtime: "claude-code",
|
||||||
|
Tier: 4, // access tier — must NOT influence sizing
|
||||||
|
InstanceType: "t3.xlarge",
|
||||||
|
DiskGB: 120,
|
||||||
|
PlatformURL: "http://tenant",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Start: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.InstanceType != "t3.xlarge" {
|
||||||
|
t.Errorf("CP request instance_type = %q, want t3.xlarge (override dropped)", got.InstanceType)
|
||||||
|
}
|
||||||
|
if got.DiskGB != 120 {
|
||||||
|
t.Errorf("CP request disk_gb = %d, want 120 (override dropped)", got.DiskGB)
|
||||||
|
}
|
||||||
|
// Tier still forwarded (access model), independently of sizing.
|
||||||
|
if got.Tier != 4 {
|
||||||
|
t.Errorf("CP request tier = %d, want 4 (access model must still forward)", got.Tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStart_NoSizingOverrideOmitsFields proves the default path sends
|
||||||
|
// NO instance_type / disk_gb so the CP applies its own default
|
||||||
|
// (t3.large / 50GB) rather than receiving a zero-value that could be
|
||||||
|
// misread.
|
||||||
|
func TestStart_NoSizingOverrideOmitsFields(t *testing.T) {
|
||||||
|
var rawBody []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rawBody, _ = io.ReadAll(r.Body)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = io.WriteString(w, `{"instance_id":"i-def","state":"pending"}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
p := &CPProvisioner{
|
||||||
|
baseURL: srv.URL, orgID: "org-1", sharedSecret: "s", httpClient: srv.Client(),
|
||||||
|
}
|
||||||
|
if _, err := p.Start(context.Background(), WorkspaceConfig{
|
||||||
|
WorkspaceID: "ws-def", Runtime: "python", Tier: 1, PlatformURL: "http://t",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Start: %v", err)
|
||||||
|
}
|
||||||
|
// omitempty on InstanceType/DiskGB → absent from the JSON entirely.
|
||||||
|
if strings.Contains(string(rawBody), "instance_type") {
|
||||||
|
t.Errorf("default request leaked instance_type into body: %s", rawBody)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(rawBody), "disk_gb") {
|
||||||
|
t.Errorf("default request leaked disk_gb into body: %s", rawBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -78,12 +78,21 @@ const (
|
|||||||
|
|
||||||
// WorkspaceConfig holds the parameters needed to provision a workspace container.
|
// WorkspaceConfig holds the parameters needed to provision a workspace container.
|
||||||
type WorkspaceConfig struct {
|
type WorkspaceConfig struct {
|
||||||
WorkspaceID string
|
WorkspaceID string
|
||||||
TemplatePath string // Host path to template dir to copy from (e.g. claude-code-default/)
|
TemplatePath string // Host path to template dir to copy from (e.g. claude-code-default/)
|
||||||
ConfigFiles map[string][]byte // Generated config files to write into /configs volume
|
ConfigFiles map[string][]byte // Generated config files to write into /configs volume
|
||||||
PluginsPath string // Host path to plugins directory (mounted at /plugins)
|
PluginsPath string // Host path to plugins directory (mounted at /plugins)
|
||||||
WorkspacePath string // Host path to bind-mount as /workspace (if empty, uses Docker named volume)
|
WorkspacePath string // Host path to bind-mount as /workspace (if empty, uses Docker named volume)
|
||||||
Tier int
|
Tier int
|
||||||
|
// InstanceType + DiskGB are the optional per-workspace,
|
||||||
|
// user-configurable EC2 sizing override (canvas Config tab,
|
||||||
|
// DB-backed). Empty / 0 = "use the CP default" (t3.large / 50GB).
|
||||||
|
// Decoupled from Tier by design — Tier is the ACCESS model only
|
||||||
|
// (T4 = full root access), it does NOT drive sizing. Only the
|
||||||
|
// SaaS CP provisioner path consumes these; the local Docker
|
||||||
|
// provisioner ignores them (sizing is an EC2 concept).
|
||||||
|
InstanceType string
|
||||||
|
DiskGB int32
|
||||||
Runtime string // "langgraph" (default) or "claude-code", "codex", "ollama", "custom"
|
Runtime string // "langgraph" (default) or "claude-code", "codex", "ollama", "custom"
|
||||||
EnvVars map[string]string // Additional env vars (API keys, etc.)
|
EnvVars map[string]string // Additional env vars (API keys, etc.)
|
||||||
PlatformURL string
|
PlatformURL string
|
||||||
|
|||||||
@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE workspaces
|
||||||
|
DROP COLUMN IF EXISTS instance_type,
|
||||||
|
DROP COLUMN IF EXISTS disk_gb;
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
-- Per-workspace, user-configurable EC2 sizing override.
|
||||||
|
--
|
||||||
|
-- Sizing is ORTHOGONAL to the access tier. The access `tier` column
|
||||||
|
-- (T1..T4; T4 = full root-level access to the dedicated EC2) controls
|
||||||
|
-- how much of the box the agent can touch — it has NOTHING to do with
|
||||||
|
-- how big the box is. Sizing used to be wrongly derived from tier in
|
||||||
|
-- the control-plane provisioner (workspaceTierResources); that coupling
|
||||||
|
-- is removed (controlplane PR #173). These columns carry the optional
|
||||||
|
-- user override the canvas Config tab sets.
|
||||||
|
--
|
||||||
|
-- NULL / 0 = "no override — use the CP default" (t3.large + 50GB).
|
||||||
|
-- The CP clamps instance_type to its allowlist (t3.medium..t3.2xlarge)
|
||||||
|
-- and disk_gb to [30, 500]; these columns are the persisted intent,
|
||||||
|
-- the CP is the enforcement point.
|
||||||
|
--
|
||||||
|
-- Resize semantics: provision-time only. AWS cannot change instance
|
||||||
|
-- type live (needs stop/start) and cannot shrink EBS in place — the
|
||||||
|
-- new spec takes effect on the next provision/restart, surfaced in the
|
||||||
|
-- canvas Config copy.
|
||||||
|
|
||||||
|
ALTER TABLE workspaces
|
||||||
|
ADD COLUMN IF NOT EXISTS instance_type TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS disk_gb INTEGER;
|
||||||
Loading…
Reference in New Issue
Block a user