feat(canvas#2489): drive CreateWorkspaceDialog compute defaults from /compute/metadata SSOT #2881

Merged
devops-engineer merged 4 commits from feat/2489-canvas-display-defaults-ssot into main 2026-06-15 11:05:15 +00:00
9 changed files with 472 additions and 167 deletions
+73 -23
View File
@@ -3,6 +3,14 @@
import { useState, useEffect, useRef, useCallback, useId, useMemo } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { api } from "@/lib/api";
import {
FALLBACK_COMPUTE_OPTIONS,
type ComputeOptions,
defaultInstanceForProvider,
displayDefaultForProvider,
instanceTypesForProvider,
parseComputeOptions,
} from "@/lib/compute-options";
import { isSaaSTenant } from "@/lib/tenant";
import { ExternalConnectModal, type ExternalConnectionInfo } from "./ExternalConnectModal";
import {
@@ -49,6 +57,17 @@ interface TemplateSpec {
}
const DEFAULT_RUNTIME = "claude-code";
// Human-readable labels for cloud-provider IDs. Provider ordering and
// instance-type allowlists are SSOT from GET /compute/metadata; labels are
// pure UI chrome and live next to the only consumer (#2489).
const PROVIDER_LABELS: Record<string, string> = {
aws: "AWS (default)",
gcp: "GCP",
hetzner: "Hetzner",
};
const providerLabel = (id: string): string => PROVIDER_LABELS[id] ?? id;
const RUNTIME_OPTIONS = [
{ value: "claude-code", label: "Claude Code" },
{ value: "codex", label: "OpenAI Codex CLI" },
@@ -57,19 +76,7 @@ const RUNTIME_OPTIONS = [
{ value: "openclaw", label: "OpenClaw" },
];
const BASE_RUNTIME_TEMPLATE_IDS = new Set(["claude-code-default", "codex", "google-adk", "hermes", "openclaw"]);
const DEFAULT_HEADLESS_INSTANCE_TYPE = "t3.medium";
const DEFAULT_HEADLESS_ROOT_GB = 30;
const DEFAULT_DISPLAY_INSTANCE_TYPE = "t3.xlarge";
// Per-workspace cloud/compute backend (multi-provider RFC). "aws" is the default
// EC2 path; "gcp"/"hetzner" route to the matching CP WorkspaceProvisioner. A
// workspace whose cloud differs from its tenant's is reached over a per-workspace
// Cloudflare tunnel (runtime#95). Distinct from the LLM/model provider.
const CLOUD_PROVIDER_OPTIONS = [
{ value: "aws", label: "AWS (default)" },
{ value: "gcp", label: "GCP" },
{ value: "hetzner", label: "Hetzner" },
];
const DEFAULT_DISPLAY_ROOT_GB = 80;
export function CreateWorkspaceButton() {
@@ -84,19 +91,35 @@ export function CreateWorkspaceButton() {
const [error, setError] = useState<string | null>(null);
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
const [displayEnabled, setDisplayEnabled] = useState(false);
const [displayInstanceType, setDisplayInstanceType] = useState(DEFAULT_DISPLAY_INSTANCE_TYPE);
const [displayInstanceType, setDisplayInstanceType] = useState(
displayDefaultForProvider(FALLBACK_COMPUTE_OPTIONS),
);
const [displayRootGB, setDisplayRootGB] = useState(String(DEFAULT_DISPLAY_ROOT_GB));
const [displayResolution, setDisplayResolution] = useState("1920x1080");
// Cloud/compute backend for the workspace box (multi-provider, per-workspace).
// "aws" default; "gcp"/"hetzner" route to the matching CP WorkspaceProvisioner
// (a non-tenant-cloud box is reached over a per-workspace tunnel, runtime#95).
const [cloudProvider, setCloudProvider] = useState("aws");
// SSOT provider + instance-type metadata from GET /compute/metadata. Starts from
// the offline fallback and is replaced once the fetch resolves; on fetch error we
// keep the fallback so the dialog stays usable.
const [computeOptions, setComputeOptions] = useState<ComputeOptions>(FALLBACK_COMPUTE_OPTIONS);
// Templates fetched from /api/templates — drives the dynamic provider
// filter below. Same data source ConfigTab uses (PR #2454). When the
// selected template declares `runtime_config.providers` in its
// config.yaml, the modal surfaces only those providers in the
// <select>. Provider/model options are derived from template models.
const [templateSpecs, setTemplateSpecs] = useState<TemplateSpec[]>([]);
// Keep the selected display instance type valid when the cloud provider or SSOT
// options change. If the current value is not offered for the provider, fall back
// to the provider's SSOT display default.
useEffect(() => {
const valid = instanceTypesForProvider(computeOptions, cloudProvider);
if (!valid.includes(displayInstanceType)) {
setDisplayInstanceType(displayDefaultForProvider(computeOptions, cloudProvider));
}
}, [cloudProvider, computeOptions, displayInstanceType]);
// External-runtime path: skip docker provision, mint a workspace_auth_token,
// and surface the connection snippet in a modal after create. When
// isExternal is true the template and model fields are hidden (they're
@@ -277,13 +300,12 @@ export function CreateWorkspaceButton() {
setBudgetLimit("");
setError(null);
setDisplayEnabled(false);
setDisplayInstanceType(DEFAULT_DISPLAY_INSTANCE_TYPE);
setDisplayRootGB(String(DEFAULT_DISPLAY_ROOT_GB));
setDisplayResolution("1920x1080");
setCloudProvider("aws");
setExternalRuntime("external");
setLLMSelection({ providerId: "", model: "", envVars: [] });
setLLMSecret("");
api
.get<WorkspaceOption[]>("/workspaces")
.then((ws) => setWorkspaces(ws))
@@ -292,6 +314,33 @@ export function CreateWorkspaceButton() {
.get<TemplateSpec[]>("/templates")
.then((rows) => setTemplateSpecs(Array.isArray(rows) ? rows : []))
.catch(() => { /* keep empty; create stays blocked until the catalog loads */ });
// Load SSOT compute metadata, then reset provider + display defaults from it.
// We fetch fresh each time the dialog opens because the server SSOT can change
// without a page reload.
api
.get<unknown>("/compute/metadata")
.then((resp) => {
const parsed = parseComputeOptions(resp);
if (parsed) {
setComputeOptions(parsed);
const nextProvider = parsed.providers[0] ?? "aws";
setCloudProvider(nextProvider);
setDisplayInstanceType(displayDefaultForProvider(parsed, nextProvider));
} else {
resetToFallbackCompute();
}
})
.catch(() => {
resetToFallbackCompute();
});
function resetToFallbackCompute() {
setComputeOptions(FALLBACK_COMPUTE_OPTIONS);
setCloudProvider("aws");
setDisplayInstanceType(displayDefaultForProvider(FALLBACK_COMPUTE_OPTIONS));
}
// defaultTier is stable for the session (derived from window.location),
// safe to omit from deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -376,7 +425,7 @@ export function CreateWorkspaceButton() {
...(isSaaS ? { provider: cloudProvider } : {}),
}
: {
instance_type: DEFAULT_HEADLESS_INSTANCE_TYPE,
instance_type: defaultInstanceForProvider(computeOptions, cloudProvider),
volume: { root_gb: DEFAULT_HEADLESS_ROOT_GB },
display: { mode: "none" },
...(isSaaS ? { provider: cloudProvider } : {}),
@@ -631,9 +680,9 @@ export function CreateWorkspaceButton() {
onChange={(e) => setCloudProvider(e.target.value)}
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
>
{CLOUD_PROVIDER_OPTIONS.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
{computeOptions.providers.map((p) => (
<option key={p} value={p}>
{providerLabel(p)}
</option>
))}
</select>
@@ -661,10 +710,11 @@ export function CreateWorkspaceButton() {
onChange={(e) => setDisplayInstanceType(e.target.value)}
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-2 py-2 text-xs text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
>
<option value="t3.large">t3.large</option>
<option value="t3.xlarge">t3.xlarge</option>
<option value="m6i.xlarge">m6i.xlarge</option>
<option value="c6i.xlarge">c6i.xlarge</option>
{instanceTypesForProvider(computeOptions, cloudProvider).map((it) => (
<option key={it} value={it}>
{it}
</option>
))}
</select>
</div>
<div>
@@ -16,6 +16,25 @@ import { api } from "@/lib/api";
const mockGet = vi.mocked(api.get);
const mockPost = vi.mocked(api.post);
const SAMPLE_COMPUTE_METADATA = {
providers: ["aws", "hetzner", "gcp"],
instanceTypes: {
aws: ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge", "m6i.large", "m6i.xlarge", "c6i.xlarge"],
hetzner: ["cpx11", "cpx21", "cpx31", "cpx41", "cpx51", "cax11", "cax21", "cax31", "cax41"],
gcp: ["e2-small", "e2-medium", "e2-standard-2", "e2-standard-4", "e2-standard-8"],
},
defaults: {
aws: "t3.medium",
hetzner: "cpx31",
gcp: "e2-standard-2",
},
display_defaults: {
aws: "t3.xlarge",
hetzner: "cpx41",
gcp: "e2-standard-4",
},
};
const SAMPLE_WORKSPACES = [
{ id: "ws-1", name: "Platform Team", tier: 1 },
{ id: "ws-2", name: "Research Agent", tier: 2 },
@@ -103,6 +122,10 @@ beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return SAMPLE_TEMPLATES as any;
}
if (url === "/compute/metadata") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return SAMPLE_COMPUTE_METADATA as any;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return SAMPLE_WORKSPACES as any;
});
@@ -298,6 +321,120 @@ describe("CreateWorkspaceDialog", () => {
});
});
it("drives display instance-type options from /compute/metadata SSOT", async () => {
await openDialog();
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "Desktop Agent" },
});
fireEvent.click(screen.getByLabelText("Enable display"));
const instanceSelect = screen.getByLabelText("Instance") as HTMLSelectElement;
await waitFor(() => {
const optionValues = Array.from(instanceSelect.options).map((o) => o.value);
expect(optionValues).toEqual(SAMPLE_COMPUTE_METADATA.instanceTypes.aws);
});
});
it("consumes the SSOT display default instead of the in-bundle fallback", async () => {
// Override the /compute/metadata mock so AWS display_default differs from the
// bundled FALLBACK_COMPUTE_OPTIONS. This proves the dialog reads the live SSOT
// value and does not silently fall back to the offline bundle.
mockGet.mockImplementation(async (url: string) => {
if (url === "/compute/metadata") {
return {
providers: ["aws"],
instanceTypes: { aws: ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge"] },
defaults: { aws: "t3.medium" },
display_defaults: { aws: "t3.2xlarge" },
};
}
if (url === "/templates") return SAMPLE_TEMPLATES as any;
return SAMPLE_WORKSPACES as any;
});
await openDialog();
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "SSOT Display Agent" },
});
fireEvent.click(screen.getByLabelText("Enable display"));
const instanceSelect = screen.getByLabelText("Instance") as HTMLSelectElement;
await waitFor(() => expect(instanceSelect.value).toBe("t3.2xlarge"));
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
fireEvent.click(createBtn!);
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
expect(body.compute).toEqual({
instance_type: "t3.2xlarge",
volume: { root_gb: 80 },
display: {
mode: "desktop-control",
protocol: "novnc",
width: 1920,
height: 1080,
},
});
});
it("consumes a non-AWS SSOT display default when the cloud provider changes", async () => {
// Make the canvas think it is running on a SaaS tenant so the cloud-provider
// selector is rendered. Hetzner's SSOT display default (cpx51) differs from
// the in-bundle fallback (cpx41), proving the dropdown reads display_defaults
// for the selected provider rather than always defaulting to AWS.
const originalLocation = window.location;
vi.stubGlobal("location", { ...originalLocation, hostname: "acme.moleculesai.app" });
mockGet.mockImplementation(async (url: string) => {
if (url === "/compute/metadata") {
return {
providers: ["aws", "hetzner"],
instanceTypes: {
aws: ["t3.medium", "t3.xlarge"],
hetzner: ["cpx31", "cpx41", "cpx51"],
},
defaults: { aws: "t3.medium", hetzner: "cpx31" },
display_defaults: { aws: "t3.xlarge", hetzner: "cpx51" },
};
}
if (url === "/templates") return SAMPLE_TEMPLATES as any;
return SAMPLE_WORKSPACES as any;
});
await openDialog();
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "Hetzner Display Agent" },
});
fireEvent.change(screen.getByLabelText("Cloud provider") as HTMLSelectElement, {
target: { value: "hetzner" },
});
fireEvent.click(screen.getByLabelText("Enable display"));
const instanceSelect = screen.getByLabelText("Instance") as HTMLSelectElement;
await waitFor(() => expect(instanceSelect.value).toBe("cpx51"));
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
fireEvent.click(createBtn!);
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
expect(body.compute).toEqual({
instance_type: "cpx51",
volume: { root_gb: 80 },
display: {
mode: "desktop-control",
protocol: "novnc",
width: 1920,
height: 1080,
},
provider: "hetzner",
});
vi.stubGlobal("location", originalLocation);
});
it("sends BYOK API key secrets when API key auth mode is selected", async () => {
await openDialog();
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
@@ -2,49 +2,19 @@
import { useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import {
FALLBACK_COMPUTE_OPTIONS,
type ComputeOptions,
defaultInstanceForProvider,
instanceTypesForProvider,
normalizeProvider,
parseComputeOptions,
} from "@/lib/compute-options";
import { runtimeDisplayName } from "@/lib/runtime-names";
import { isSaaSTenant } from "@/lib/tenant";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import type { WorkspaceCompute } from "@/store/socket";
// Cloud-provider + instance-type metadata (core#2489).
//
// SSOT lives in the workspace-server (workspace_compute.go's allowlist + defaults)
// and is fetched at runtime from GET /compute/metadata (public, workspace-
// independent endpoint — the data is platform constraints, not org secrets), so
// the UI can never offer a (provider, instance-type) the PATCH validation then
// rejects with a 400. The constants below are ONLY a minimal offline fallback
// used until the fetch resolves (or if it fails) — they mirror the server SSOT
// but are not the source of truth. When the fetch succeeds, its data replaces
// them entirely.
//
// Response shape (workspace-server):
// { providers: [{ id: "aws", label: "AWS (default)", default_instance: "t3.medium",
// instances: ["t3.medium", ...] }, ...] }
type ComputeOptions = {
providers: string[];
instanceTypes: Record<string, string[]>;
defaults: Record<string, string>;
labels: Record<string, string>;
};
const FALLBACK_COMPUTE_OPTIONS: ComputeOptions = {
providers: ["aws", "hetzner", "gcp"],
instanceTypes: {
aws: ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge", "m6i.large", "m6i.xlarge", "c6i.xlarge"],
hetzner: ["cpx11", "cpx21", "cpx31", "cpx41", "cpx51", "cax11", "cax21", "cax31", "cax41"],
gcp: ["e2-small", "e2-medium", "e2-standard-2", "e2-standard-4", "e2-standard-8"],
},
defaults: { aws: "t3.medium", hetzner: "cpx31", gcp: "e2-standard-2" },
labels: { aws: "AWS (default)", gcp: "GCP", hetzner: "Hetzner" },
};
const normalizeProvider = (p?: string): string => (p === "gcp" || p === "hetzner" ? p : "aws");
const instanceTypesForProvider = (opts: ComputeOptions, p?: string): string[] =>
opts.instanceTypes[normalizeProvider(p)] ?? opts.instanceTypes.aws ?? FALLBACK_COMPUTE_OPTIONS.instanceTypes.aws;
const defaultInstanceForProvider = (opts: ComputeOptions, p?: string): string =>
opts.defaults[normalizeProvider(p)] ?? "t3.medium";
const RUNTIME_OPTIONS = ["claude-code", "codex", "hermes", "openclaw", "kimi", "kimi-cli", "external"];
const RESOLUTIONS = ["1280x720", "1440x900", "1920x1080", "2560x1440"];
const DEFAULT_HEADLESS_ROOT_GB = 30;
@@ -121,33 +91,14 @@ export function ContainerConfigTab({ workspaceId, data }: Props) {
// /compute/metadata is a public, workspace-independent endpoint (the data
// is platform constraints, not org secrets) — no need to refetch on
// workspaceId change; one fetch per tab mount is enough.
const resp = await api.get<{
providers?: Array<{ id: string; label?: string; default_instance?: string; instances?: string[] }>;
}>("/compute/metadata");
const resp = await api.get<unknown>("/compute/metadata");
if (cancelled) return;
// Defensive: only adopt a well-formed payload; otherwise keep the fallback.
// Map the server's per-provider object shape into the flat internal
// ComputeOptions shape the helpers + selectors consume.
if (resp && Array.isArray(resp.providers) && resp.providers.length > 0) {
const providers: string[] = [];
const instanceTypes: Record<string, string[]> = {};
const defaults: Record<string, string> = {};
const labels: Record<string, string> = {};
for (const p of resp.providers) {
if (!p || typeof p.id !== "string" || !p.id) continue;
providers.push(p.id);
if (Array.isArray(p.instances) && p.instances.length > 0) instanceTypes[p.id] = p.instances;
if (typeof p.default_instance === "string" && p.default_instance) defaults[p.id] = p.default_instance;
if (typeof p.label === "string" && p.label) labels[p.id] = p.label;
}
if (providers.length > 0) {
setComputeOptions({
providers,
instanceTypes: Object.keys(instanceTypes).length > 0 ? instanceTypes : FALLBACK_COMPUTE_OPTIONS.instanceTypes,
defaults: Object.keys(defaults).length > 0 ? defaults : FALLBACK_COMPUTE_OPTIONS.defaults,
labels: Object.keys(labels).length > 0 ? labels : FALLBACK_COMPUTE_OPTIONS.labels,
});
}
const parsed = parseComputeOptions(resp);
if (parsed) {
setComputeOptions(parsed);
}
} catch {
// Fetch failed (offline / older server) — keep FALLBACK_COMPUTE_OPTIONS.
@@ -274,7 +225,7 @@ export function ContainerConfigTab({ workspaceId, data }: Props) {
label="Cloud provider"
value={normalizeProvider(form.provider)}
options={computeOptions.providers}
optionLabel={(v) => computeOptions.labels[v] ?? v}
optionLabel={(v) => cloudProviderLabel(v)}
// Switching cloud resets the instance type to the new provider's
// default (an AWS t3.* is invalid on Hetzner, etc.) — also keeps the
// instance-type dropdown below in sync with the provider's sizes.
@@ -372,14 +372,22 @@ describe("ContainerConfigTab", () => {
// dropdowns: a server-only instance type appears once the fetch resolves.
it("populates instance-type options from the /compute/metadata SSOT endpoint", async () => {
apiGet.mockResolvedValueOnce({
providers: [
// Real server response shape: { id, label, default_instance, instances }.
// The "z9.future" instance is server-only — the in-bundle fallback doesn't
// list it; once the fetch resolves, it appears in the dropdown.
{ id: "aws", label: "AWS (default)", default_instance: "t3.medium", instances: ["t3.medium", "t3.large", "z9.future"] },
{ id: "hetzner", label: "Hetzner", default_instance: "cpx31", instances: ["cpx31"] },
{ id: "gcp", label: "GCP", default_instance: "e2-standard-2", instances: ["e2-standard-2"] },
],
providers: ["aws", "hetzner", "gcp"],
instanceTypes: {
aws: ["t3.medium", "t3.large", "z9.future"],
hetzner: ["cpx31"],
gcp: ["e2-standard-2"],
},
defaults: {
aws: "t3.medium",
hetzner: "cpx31",
gcp: "e2-standard-2",
},
display_defaults: {
aws: "t3.xlarge",
hetzner: "cpx41",
gcp: "e2-standard-4",
},
});
render(
@@ -458,7 +466,8 @@ describe("ContainerConfigTab", () => {
// gcp instances (5): e2-small, e2-medium, e2-standard-2,
// e2-standard-4, e2-standard-8
// gcp default: e2-standard-2
// labels: aws="AWS (default)", gcp="GCP", hetzner="Hetzner"
// display defaults: aws="t3.xlarge", gcp="e2-standard-4",
// hetzner="cpx41"
//
// The test exercises the fallback path by making the live fetch
// fail; the assertions then read what the dropdowns actually
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import {
parseComputeOptions,
defaultInstanceForProvider,
displayDefaultForProvider,
instanceTypesForProvider,
normalizeProvider,
} from "../compute-options";
describe("compute-options", () => {
const serverResponse = {
providers: ["aws", "hetzner", "gcp"],
instanceTypes: {
aws: ["t3.medium", "t3.large", "t3.xlarge"],
hetzner: ["cpx31", "cpx41"],
gcp: ["e2-standard-2", "e2-standard-4"],
},
defaults: {
aws: "t3.medium",
hetzner: "cpx31",
gcp: "e2-standard-2",
},
display_defaults: {
aws: "t3.xlarge",
hetzner: "cpx41",
gcp: "e2-standard-4",
},
};
it("parses /compute/metadata into ComputeOptions", () => {
const opts = parseComputeOptions(serverResponse);
expect(opts).not.toBeNull();
expect(opts?.providers).toEqual(["aws", "hetzner", "gcp"]);
expect(opts?.defaults).toEqual({ aws: "t3.medium", hetzner: "cpx31", gcp: "e2-standard-2" });
expect(opts?.displayDefaults).toEqual({
aws: "t3.xlarge",
hetzner: "cpx41",
gcp: "e2-standard-4",
});
expect(opts?.instanceTypes.aws).toEqual(["t3.medium", "t3.large", "t3.xlarge"]);
});
it("falls back to in-bundle defaults when display_defaults is missing", () => {
const partial = {
providers: ["aws"],
instanceTypes: { aws: ["t3.medium"] },
defaults: { aws: "t3.medium" },
};
const opts = parseComputeOptions(partial);
expect(opts).toBeNull();
});
it("returns null for malformed payloads", () => {
expect(parseComputeOptions(null)).toBeNull();
expect(parseComputeOptions({})).toBeNull();
expect(parseComputeOptions({ providers: [] })).toBeNull();
expect(parseComputeOptions({ providers: [""], instanceTypes: {}, defaults: {}, display_defaults: {} })).toBeNull();
});
it("helpers resolve per-provider values", () => {
const opts = parseComputeOptions(serverResponse)!;
expect(defaultInstanceForProvider(opts, "aws")).toBe("t3.medium");
expect(displayDefaultForProvider(opts, "aws")).toBe("t3.xlarge");
expect(instanceTypesForProvider(opts, "hetzner")).toEqual(["cpx31", "cpx41"]);
expect(normalizeProvider(undefined)).toBe("aws");
});
});
+107
View File
@@ -0,0 +1,107 @@
// Cloud-provider + instance-type metadata (core#2489).
//
// SSOT lives in the workspace-server (workspace_compute.go allowlist + defaults)
// and is fetched at runtime from GET /compute/metadata (public, workspace-
// independent endpoint — the data is platform constraints, not org secrets), so
// the UI can never offer a (provider, instance-type) the PATCH validation then
// rejects with a 400. The constants below are ONLY a minimal offline fallback
// used until the fetch resolves (or if it fails) — they mirror the server SSOT
// but are not the source of truth. When the fetch succeeds, its data replaces
// them entirely.
//
// Response shape (workspace-server GET /compute/metadata):
// {
// providers: ["aws", "gcp", "hetzner"],
// instanceTypes: { aws: ["t3.medium", ...], ... },
// defaults: { aws: "t3.medium", ... },
// display_defaults: { aws: "t3.xlarge", ... }
// }
export type ComputeOptions = {
providers: string[];
instanceTypes: Record<string, string[]>;
defaults: Record<string, string>;
displayDefaults: Record<string, string>;
};
export const FALLBACK_COMPUTE_OPTIONS: ComputeOptions = {
providers: ["aws", "hetzner", "gcp"],
instanceTypes: {
aws: ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge", "m6i.large", "m6i.xlarge", "c6i.xlarge"],
hetzner: ["cpx11", "cpx21", "cpx31", "cpx41", "cpx51", "cax11", "cax21", "cax31", "cax41"],
gcp: ["e2-small", "e2-medium", "e2-standard-2", "e2-standard-4", "e2-standard-8"],
},
defaults: { aws: "t3.medium", hetzner: "cpx31", gcp: "e2-standard-2" },
displayDefaults: { aws: "t3.xlarge", hetzner: "cpx41", gcp: "e2-standard-4" },
};
export const normalizeProvider = (p?: string): string =>
p === "gcp" || p === "hetzner" ? p : "aws";
export const instanceTypesForProvider = (opts: ComputeOptions, p?: string): string[] =>
opts.instanceTypes[normalizeProvider(p)] ??
opts.instanceTypes.aws ??
FALLBACK_COMPUTE_OPTIONS.instanceTypes.aws;
export const defaultInstanceForProvider = (opts: ComputeOptions, p?: string): string =>
opts.defaults[normalizeProvider(p)] ?? FALLBACK_COMPUTE_OPTIONS.defaults.aws;
export const displayDefaultForProvider = (opts: ComputeOptions, p?: string): string =>
opts.displayDefaults[normalizeProvider(p)] ?? FALLBACK_COMPUTE_OPTIONS.displayDefaults.aws;
// Build ComputeOptions from the workspace-server /compute/metadata response.
// Returns null when the payload is not well-formed, so callers can keep the
// fallback.
export function parseComputeOptions(resp: unknown): ComputeOptions | null {
if (!resp || typeof resp !== "object") return null;
const {
providers,
instanceTypes,
defaults,
display_defaults,
} = resp as {
providers?: unknown;
instanceTypes?: unknown;
defaults?: unknown;
display_defaults?: unknown;
};
if (!Array.isArray(providers) || providers.length === 0) return null;
if (!instanceTypes || typeof instanceTypes !== "object") return null;
if (!defaults || typeof defaults !== "object") return null;
if (!display_defaults || typeof display_defaults !== "object") return null;
const providerIds: string[] = [];
for (const p of providers) {
if (typeof p === "string" && p) providerIds.push(p);
}
if (providerIds.length === 0) return null;
const pickStrings = (obj: unknown): Record<string, string> => {
if (!obj || typeof obj !== "object") return {};
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
if (typeof v === "string" && v) out[k] = v;
}
return out;
};
const pickStringArrays = (obj: unknown): Record<string, string[]> => {
if (!obj || typeof obj !== "object") return {};
const out: Record<string, string[]> = {};
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
if (Array.isArray(v)) {
const filtered = v.filter((item): item is string => typeof item === "string" && Boolean(item));
if (filtered.length > 0) out[k] = filtered;
}
}
return out;
};
return {
providers: providerIds,
instanceTypes: pickStringArrays(instanceTypes),
defaults: pickStrings(defaults),
displayDefaults: pickStrings(display_defaults),
};
}
@@ -399,17 +399,6 @@ func validateWorkspaceDisplayDimensions(width, height int) error {
return nil
}
type computeProviderMetadata struct {
ID string `json:"id"`
Label string `json:"label"`
DefaultInstance string `json:"default_instance"`
Instances []string `json:"instances"`
}
type computeMetadataResponse struct {
Providers []computeProviderMetadata `json:"providers"`
}
// ComputeMetadata handles GET /compute/metadata — SSOT for cloud-provider +
// instance-type allowlists consumed by the canvas ContainerConfigTab (and any
// other client that needs to render a provider/instance selector).
@@ -427,30 +416,24 @@ type computeMetadataResponse struct {
func ComputeMetadata(c *gin.Context) {
// Render in the canvas-UX order (distinct from the validation
// order — see workspaceComputeMetadataRenderOrder doc), pulling
// the label + default + instance-types for each from the SSOT
// maps. Three lookups per provider; O(providers) total.
providers := make([]computeProviderMetadata, 0, len(workspaceComputeMetadataRenderOrder))
// the label + default + display-default + instance-types for each
// from the SSOT maps. O(providers) total.
providers := make([]string, 0, len(workspaceComputeMetadataRenderOrder))
instanceTypes := make(map[string][]string, len(workspaceComputeMetadataRenderOrder))
defaults := make(map[string]string, len(workspaceComputeMetadataRenderOrder))
displayDefaults := make(map[string]string, len(workspaceComputeMetadataRenderOrder))
for _, id := range workspaceComputeMetadataRenderOrder {
// Label is required (panicked in init() if missing). If a
// future provider is added without a label, this is a
// boot-time crash, not a silent empty label.
label := workspaceComputeProviderLabels[id]
// Default + instance-types are also required — same
// SSOT-consistency rationale. A provider without a
// default or with zero instance types would fail the
// validation step downstream, so we want the metadata
// endpoint to surface that as a panic at boot, not as
// a silent empty render.
defaultInstance := workspaceComputeDefaultInstanceByProvider[id]
instances := workspaceComputeInstanceTypesOrdered[id]
providers = append(providers, computeProviderMetadata{
ID: id,
Label: label,
DefaultInstance: defaultInstance,
Instances: instances,
})
providers = append(providers, id)
instanceTypes[id] = workspaceComputeInstanceTypesOrdered[id]
defaults[id] = workspaceComputeDefaultInstanceByProvider[id]
displayDefaults[id] = workspaceComputeDisplayDefaultByProvider[id]
}
c.JSON(200, computeMetadataResponse{Providers: providers})
c.JSON(200, workspaceComputeOptionsResponse{
Providers: providers,
InstanceTypes: instanceTypes,
Defaults: defaults,
DisplayDefaults: displayDefaults,
})
}
func workspaceComputeIsZero(compute models.WorkspaceCompute) bool {
@@ -838,7 +838,7 @@ func TestComputeMetadata_ReturnsProviderAllowlist(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp computeMetadataResponse
var resp workspaceComputeOptionsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
@@ -846,26 +846,28 @@ func TestComputeMetadata_ReturnsProviderAllowlist(t *testing.T) {
t.Fatalf("expected 3 providers, got %d", len(resp.Providers))
}
want := []struct {
id, label, defaultInstance string
instanceCount int
id string
defaultInstance string
displayDefault string
instanceCount int
}{
{"aws", "AWS (default)", "t3.medium", 7},
{"gcp", "GCP", "e2-standard-2", 5},
{"hetzner", "Hetzner", "cpx31", 9},
{"aws", "t3.medium", "t3.xlarge", 7},
{"gcp", "e2-standard-2", "e2-standard-4", 5},
{"hetzner", "cpx31", "cpx41", 9},
}
for i, w := range want {
p := resp.Providers[i]
if p.ID != w.id {
t.Errorf("providers[%d].id = %q, want %q", i, p.ID, w.id)
if p != w.id {
t.Errorf("providers[%d] = %q, want %q", i, p, w.id)
}
if p.Label != w.label {
t.Errorf("providers[%d].label = %q, want %q", i, p.Label, w.label)
if got := resp.Defaults[p]; got != w.defaultInstance {
t.Errorf("defaults[%q] = %q, want %q", p, got, w.defaultInstance)
}
if p.DefaultInstance != w.defaultInstance {
t.Errorf("providers[%d].default_instance = %q, want %q", i, p.DefaultInstance, w.defaultInstance)
if got := resp.DisplayDefaults[p]; got != w.displayDefault {
t.Errorf("display_defaults[%q] = %q, want %q", p, got, w.displayDefault)
}
if len(p.Instances) != w.instanceCount {
t.Errorf("providers[%d].instances len = %d, want %d", i, len(p.Instances), w.instanceCount)
if got := len(resp.InstanceTypes[p]); got != w.instanceCount {
t.Errorf("instanceTypes[%q] len = %d, want %d", p, got, w.instanceCount)
}
}
}
@@ -22,7 +22,8 @@ import (
// The contract being pinned:
// 1. The route is registered and reachable.
// 2. The route is PUBLIC — no AdminAuth, no WorkspaceAuth.
// 3. The wire shape matches the canvas's expectation (same JSON keys).
// 3. The wire shape matches the canvas's expectation (same JSON keys):
// { providers, instanceTypes, defaults, display_defaults }.
// 4. The in-tree Go consumer (handlers.workspaceComputeInstanceAllowlist)
// AGREE with the endpoint's value.
@@ -54,12 +55,10 @@ func TestComputeMetadata_ReturnsExpectedShape(t *testing.T) {
r.ServeHTTP(w, req)
var got struct {
Providers []struct {
ID string `json:"id"`
Label string `json:"label"`
DefaultInstance string `json:"default_instance"`
Instances []string `json:"instances"`
} `json:"providers"`
Providers []string `json:"providers"`
InstanceTypes map[string][]string `json:"instanceTypes"`
Defaults map[string]string `json:"defaults"`
DisplayDefaults map[string]string `json:"display_defaults"`
}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal response: %v (body=%s)", err, w.Body.String())
@@ -69,26 +68,28 @@ func TestComputeMetadata_ReturnsExpectedShape(t *testing.T) {
t.Fatalf("expected 3 providers, got %d", len(got.Providers))
}
want := []struct {
id, label, defaultInstance string
instanceCount int
id string
defaultInstance string
displayDefault string
instanceCount int
}{
{"aws", "AWS (default)", "t3.medium", 7},
{"gcp", "GCP", "e2-standard-2", 5},
{"hetzner", "Hetzner", "cpx31", 9},
{"aws", "t3.medium", "t3.xlarge", 7},
{"gcp", "e2-standard-2", "e2-standard-4", 5},
{"hetzner", "cpx31", "cpx41", 9},
}
for i, w := range want {
p := got.Providers[i]
if p.ID != w.id {
t.Errorf("providers[%d].id = %q, want %q", i, p.ID, w.id)
if p != w.id {
t.Errorf("providers[%d] = %q, want %q", i, p, w.id)
}
if p.Label != w.label {
t.Errorf("providers[%d].label = %q, want %q", i, p.Label, w.label)
if got := got.Defaults[p]; got != w.defaultInstance {
t.Errorf("defaults[%q] = %q, want %q", p, got, w.defaultInstance)
}
if p.DefaultInstance != w.defaultInstance {
t.Errorf("providers[%d].default_instance = %q, want %q", i, p.DefaultInstance, w.defaultInstance)
if got := got.DisplayDefaults[p]; got != w.displayDefault {
t.Errorf("display_defaults[%q] = %q, want %q", p, got, w.displayDefault)
}
if len(p.Instances) != w.instanceCount {
t.Errorf("providers[%d].instances len = %d, want %d", i, len(p.Instances), w.instanceCount)
if got := len(got.InstanceTypes[p]); got != w.instanceCount {
t.Errorf("instanceTypes[%q] len = %d, want %d", p, got, w.instanceCount)
}
}
}
@@ -107,18 +108,16 @@ func TestComputeMetadata_AgreesWithInTreeAllowlist(t *testing.T) {
r.ServeHTTP(w, req)
var got struct {
Providers []struct {
ID string `json:"id"`
Instances []string `json:"instances"`
} `json:"providers"`
Providers []string `json:"providers"`
InstanceTypes map[string][]string `json:"instanceTypes"`
}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal response: %v (body=%s)", err, w.Body.String())
}
for _, p := range got.Providers {
if len(p.Instances) == 0 {
t.Errorf("provider %q has empty instances", p.ID)
if len(got.InstanceTypes[p]) == 0 {
t.Errorf("provider %q has empty instances", p)
}
}
}