feat(canvas#2489): drive CreateWorkspaceDialog compute defaults from /compute/metadata SSOT #2881
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user