fix(compute): consolidate cloud-provider + instance-type SSOT (#2489)
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 9s
CI / Python Lint & Test (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 13s
E2E API Smoke Test / detect-changes (pull_request) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 13s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
Harness Replays / detect-changes (pull_request) Successful in 8s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 13s
E2E Chat / detect-changes (pull_request) Successful in 19s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
Harness Replays / Harness Replays (pull_request) Successful in 7s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 16s
E2E Chat / E2E Chat (pull_request) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
gate-check-v3 / gate-check (pull_request_target) Successful in 14s
sop-checklist / review-refire (pull_request_target) Has been skipped
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request_target) Successful in 12s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m18s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m28s
CI / Platform (Go) (pull_request) Successful in 4m17s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (stub) (pull_request) Failing after 4m15s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 5m14s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory) (pull_request) Failing after 3m59s
CI / Canvas (Next.js) (pull_request) Successful in 9m17s
CI / Canvas Deploy Status (pull_request) Successful in 2s
CI / all-required (pull_request) Successful in 2s
qa-review / approved (pull_request_target) Approved via pull_request_review trigger
qa-review / approved (pull_request_review) Successful in 6s
security-review / approved (pull_request_target) Approved via pull_request_review trigger
security-review / approved (pull_request_review) Successful in 10s

Cloud-provider and instance-type metadata was hardcoded in two places that
could drift: the canvas ContainerConfigTab.tsx and the workspace-server
workspace_compute.go allowlist. The UI could offer a (provider, instance-type)
the backend allowlist then rejected with a 400.

Approach (a): the workspace-server is now the single source of truth. It exposes
GET /workspaces/:id/compute-options (under the existing WorkspaceAuth group)
returning {providers, instanceTypes, defaults} derived directly from the
validation allowlist. The canvas fetches it on mount and populates its dropdowns
from that data, falling back to an in-bundle mirror only if the fetch fails.

Backend:
- workspace_compute.go: ordered provider/instance-type lists are now the
  canonical SSOT; the O(1) validation allowlist (and the provider allowlist) are
  DERIVED from them in init(), so the rendered list and the validated set cannot
  diverge. Added buildComputeOptions() + the ComputeOptions handler.
- router.go: wired GET /workspaces/:id/compute-options under WorkspaceAuth.
- Tests: allowlist-derived-from-ordered-SSOT, defaults-valid-for-provider, and
  an endpoint test asserting every advertised option passes validateWorkspaceCompute.

Canvas:
- ContainerConfigTab.tsx: dropdowns derive from the fetched compute-options;
  FALLBACK_COMPUTE_OPTIONS is the offline mirror, not the source of truth.
- Tests: fetch populates dropdowns from the SSOT (server-only type appears);
  graceful fallback on fetch failure.

Preserves existing behavior: provider switch (recreate-on-change), the
destructive window.confirm, isSaaS gating, and the deterministic provider-switch
tests all still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
core-devops
2026-06-09 12:04:25 -07:00
parent 42f77aba28
commit e9dea8233b
5 changed files with 373 additions and 45 deletions
@@ -7,29 +7,44 @@ import { isSaaSTenant } from "@/lib/tenant";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import type { WorkspaceCompute } from "@/store/socket";
// Machine sizes keyed by cloud provider — an AWS t3.* is meaningless on Hetzner,
// etc. MUST mirror the workspace-server workspaceComputeInstanceAllowlist (which
// mirrors the CP provider configs); the PATCH validation rejects a mismatch 400.
const INSTANCE_TYPES_BY_PROVIDER: Record<string, string[]> = {
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"],
// 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 /workspaces/:id/compute-options, 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.
type ComputeOptions = {
providers: string[];
instanceTypes: Record<string, string[]>;
defaults: Record<string, string>;
};
const DEFAULT_INSTANCE_BY_PROVIDER: Record<string, string> = {
aws: "t3.medium", hetzner: "cpx31", gcp: "e2-standard-2",
};
const normalizeProvider = (p?: string): string => (p === "gcp" || p === "hetzner" ? p : "aws");
const instanceTypesForProvider = (p?: string): string[] =>
INSTANCE_TYPES_BY_PROVIDER[normalizeProvider(p)] ?? INSTANCE_TYPES_BY_PROVIDER.aws;
const defaultInstanceForProvider = (p?: string): string =>
DEFAULT_INSTANCE_BY_PROVIDER[normalizeProvider(p)] ?? "t3.medium";
// Editable cloud-provider options (multi-provider RFC) — mirrors CreateWorkspaceDialog.
const CLOUD_PROVIDER_OPTIONS = [
{ value: "aws", label: "AWS (default)" },
{ value: "gcp", label: "GCP" },
{ value: "hetzner", label: "Hetzner" },
];
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" },
};
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";
// Human labels for the cloud-provider selector. The option VALUES come from the
// fetched SSOT (opts.providers); this only supplies display text + the default tag.
const CLOUD_PROVIDER_LABELS: Record<string, string> = {
aws: "AWS (default)",
gcp: "GCP",
hetzner: "Hetzner",
};
const cloudProviderOptionLabel = (v: string): string => CLOUD_PROVIDER_LABELS[v] ?? v;
const RUNTIME_OPTIONS = ["claude-code", "codex", "hermes", "openclaw", "kimi", "kimi-cli", "external"];
const RESOLUTIONS = ["1280x720", "1440x900", "1920x1080", "2560x1440"];
@@ -87,6 +102,12 @@ export function ContainerConfigTab({ workspaceId, data }: Props) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
// core#2489: provider + instance-type dropdowns are populated from the
// workspace-server SSOT (GET /workspaces/:id/compute-options) so they can't
// drift from what the PATCH validation accepts. Start from the offline fallback
// and replace it once the fetch resolves; on fetch error we keep the fallback
// (the dropdowns still work, just from the in-bundle mirror).
const [computeOptions, setComputeOptions] = useState<ComputeOptions>(FALLBACK_COMPUTE_OPTIONS);
useEffect(() => {
setForm(initial);
@@ -94,6 +115,30 @@ export function ContainerConfigTab({ workspaceId, data }: Props) {
setSuccess(false);
}, [initial]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const opts = await api.get<Partial<ComputeOptions>>(`/workspaces/${workspaceId}/compute-options`);
if (cancelled) return;
// Defensive: only adopt a well-formed payload; otherwise keep the fallback.
if (opts && Array.isArray(opts.providers) && opts.providers.length > 0 && opts.instanceTypes && opts.defaults) {
setComputeOptions({
providers: opts.providers,
instanceTypes: opts.instanceTypes,
defaults: opts.defaults,
});
}
} catch {
// Fetch failed (offline / older server) — keep FALLBACK_COMPUTE_OPTIONS.
// The dropdowns stay usable; worst case they show the in-bundle mirror.
}
})();
return () => {
cancelled = true;
};
}, [workspaceId]);
const workspaceAccess = formatAccess(data.workspaceAccess);
const maxConcurrentTasks = data.maxConcurrentTasks ? String(data.maxConcurrentTasks) : "platform-managed";
const deliveryMode = data.deliveryMode || "push";
@@ -208,8 +253,8 @@ export function ContainerConfigTab({ workspaceId, data }: Props) {
id="cloud-provider"
label="Cloud provider"
value={normalizeProvider(form.provider)}
options={CLOUD_PROVIDER_OPTIONS.map((p) => p.value)}
optionLabel={(v) => CLOUD_PROVIDER_OPTIONS.find((p) => p.value === v)?.label ?? v}
options={computeOptions.providers}
optionLabel={cloudProviderOptionLabel}
// 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.
@@ -217,9 +262,9 @@ export function ContainerConfigTab({ workspaceId, data }: Props) {
setForm((s) => ({
...s,
provider,
instanceType: instanceTypesForProvider(provider).includes(s.instanceType)
instanceType: instanceTypesForProvider(computeOptions, provider).includes(s.instanceType)
? s.instanceType
: defaultInstanceForProvider(provider),
: defaultInstanceForProvider(computeOptions, provider),
}))
}
/>
@@ -228,7 +273,7 @@ export function ContainerConfigTab({ workspaceId, data }: Props) {
id="instance-type"
label="Instance type"
value={form.instanceType}
options={instanceTypesForProvider(form.provider)}
options={instanceTypesForProvider(computeOptions, form.provider)}
onChange={(instanceType) => setForm((s) => ({ ...s, instanceType }))}
/>
<label className="grid gap-1" htmlFor="root-volume-gb">
@@ -348,7 +393,10 @@ function formFromData(data: {
return {
runtime: data.runtime || "claude-code",
provider,
instanceType: data.instanceType || defaultInstanceForProvider(provider),
// Falls back to the offline default only when no instance type is persisted;
// the server SSOT default matches FALLBACK_COMPUTE_OPTIONS, and the dropdown
// re-syncs to the fetched options once they resolve.
instanceType: data.instanceType || defaultInstanceForProvider(FALLBACK_COMPUTE_OPTIONS, provider),
rootGB: String(data.rootGB || DEFAULT_HEADLESS_ROOT_GB),
displayEnabled: !!data.displayMode && data.displayMode !== "none",
displayMode: data.displayMode && data.displayMode !== "none" ? data.displayMode : "desktop-control",
@@ -3,12 +3,14 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const apiPatch = vi.fn();
const apiGet = vi.fn();
const updateNodeData = vi.fn();
const restartWorkspace = vi.fn();
vi.mock("@/lib/api", () => ({
api: {
patch: (path: string, body: unknown) => apiPatch(path, body),
get: (path: string) => apiGet(path),
},
}));
@@ -38,6 +40,12 @@ afterEach(() => {
beforeEach(() => {
apiPatch.mockReset();
apiGet.mockReset();
// Default: compute-options fetch rejects → component keeps its in-bundle
// fallback SSOT. Existing assertions (t3.medium / cpx31 / provider list) are
// satisfied by the fallback, which mirrors the server. Individual tests that
// exercise the fetch path override this with mockResolvedValueOnce.
apiGet.mockRejectedValue(new Error("no compute-options in this test"));
restartWorkspace.mockReset();
updateNodeData.mockReset();
});
@@ -358,6 +366,76 @@ describe("ContainerConfigTab", () => {
confirmSpy.mockRestore();
});
// core#2489: the provider + instance-type dropdowns are populated from the
// workspace-server SSOT (GET /workspaces/:id/compute-options), so the UI can't
// offer an option the backend then rejects. This proves the fetch drives the
// dropdowns: a server-only instance type appears once the fetch resolves.
it("populates instance-type options from the compute-options SSOT endpoint", async () => {
apiGet.mockResolvedValueOnce({
providers: ["aws", "hetzner", "gcp"],
instanceTypes: {
aws: ["t3.medium", "t3.large", "z9.future"], // z9.future is server-only
hetzner: ["cpx31"],
gcp: ["e2-standard-2"],
},
defaults: { aws: "t3.medium", hetzner: "cpx31", gcp: "e2-standard-2" },
});
render(
<ContainerConfigTab
workspaceId="ws-opts"
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 0,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
compute: { instance_type: "t3.large", provider: "aws", volume: { root_gb: 30 } },
}}
/>,
);
await waitFor(() => expect(apiGet).toHaveBeenCalledWith("/workspaces/ws-opts/compute-options"));
// The server-only instance type appears in the dropdown after the fetch.
await waitFor(() =>
expect(
Array.from(screen.getByLabelText("Instance type").querySelectorAll("option")).map((o) => o.getAttribute("value")),
).toContain("z9.future"),
);
});
// core#2489: if the compute-options fetch fails, the dropdowns must stay usable
// via the in-bundle fallback (no crash, no empty selector).
it("falls back to the in-bundle option set when the compute-options fetch fails", async () => {
apiGet.mockRejectedValueOnce(new Error("network down"));
render(
<ContainerConfigTab
workspaceId="ws-opts"
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 0,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
compute: { instance_type: "t3.large", provider: "aws", volume: { root_gb: 30 } },
}}
/>,
);
await waitFor(() => expect(apiGet).toHaveBeenCalled());
// Fallback list still renders the known AWS sizes.
const values = Array.from(
screen.getByLabelText("Instance type").querySelectorAll("option"),
).map((o) => o.getAttribute("value"));
expect(values).toContain("t3.medium");
expect(values).toContain("m6i.xlarge");
});
it("does not treat a non-provider edit as a recreate (no confirm; aws default omitted)", async () => {
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
render(
@@ -31,28 +31,72 @@ type workspaceDisplayResponse struct {
Status string `json:"status,omitempty"`
}
// workspaceComputeInstanceAllowlist is keyed by cloud provider (multi-provider /
// in-place switch): each provider's box accepts only that provider's machine
// sizes (an AWS t3.* is meaningless on Hetzner, and vice-versa). Mirrors the CP
// provider SSOT — keep in lock-step with the controlplane provider configs
// (Hetzner ServerType cpx*/cax*, GCP MachineType e2-*, AWS EC2 t3*/m6i*/c6i*).
// TestValidateWorkspaceCompute_Provider / _InstanceTypePerProvider pin the sets.
// "" provider = AWS default.
var workspaceComputeInstanceAllowlist = map[string]map[string]struct{}{
// SSOT for cloud-provider + instance-type metadata (core#2489).
//
// This file is the SINGLE source of truth the workspace-server validates
// against AND the canvas Container-Config tab renders its dropdowns from (via
// GET /workspaces/:id/compute-options, see ComputeOptions below). Previously the
// canvas hardcoded a parallel copy of these lists in ContainerConfigTab.tsx; the
// two could drift so the UI offered a (provider, instance-type) the backend
// allowlist then rejected with a 400. The canvas now derives its options from
// this endpoint, so drift is impossible by construction.
//
// The ordered slices below are the canonical form. workspaceComputeInstanceAllowlist
// (the O(1) validation set) is DERIVED from them in init(), so the ordered list
// the canvas renders and the set the backend validates can never disagree.
//
// Mirrors the CP provider SSOT — keep in lock-step with the controlplane provider
// configs (Hetzner ServerType cpx*/cax*, GCP MachineType e2-*, AWS EC2
// t3*/m6i*/c6i*). TestValidateWorkspaceCompute_Provider / _InstanceTypePerProvider
// pin the sets. "" provider = AWS default.
// workspaceComputeProvidersOrdered is the canonical provider order (AWS first =
// default). The canvas renders the provider dropdown in this order.
var workspaceComputeProvidersOrdered = []string{"aws", "hetzner", "gcp"}
// workspaceComputeInstanceTypesOrdered lists each provider's machine sizes in the
// order the canvas should render them. An AWS t3.* is meaningless on Hetzner, and
// vice-versa, so the set is provider-scoped.
var workspaceComputeInstanceTypesOrdered = map[string][]string{
"aws": {
"t3.medium": {}, "t3.large": {}, "t3.xlarge": {}, "t3.2xlarge": {},
"m6i.large": {}, "m6i.xlarge": {}, "c6i.xlarge": {},
"t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge",
"m6i.large", "m6i.xlarge", "c6i.xlarge",
},
"hetzner": {
"cpx11": {}, "cpx21": {}, "cpx31": {}, "cpx41": {}, "cpx51": {},
"cax11": {}, "cax21": {}, "cax31": {}, "cax41": {},
"cpx11", "cpx21", "cpx31", "cpx41", "cpx51",
"cax11", "cax21", "cax31", "cax41",
},
"gcp": {
"e2-small": {}, "e2-medium": {},
"e2-standard-2": {}, "e2-standard-4": {}, "e2-standard-8": {},
"e2-small", "e2-medium",
"e2-standard-2", "e2-standard-4", "e2-standard-8",
},
}
// workspaceComputeDefaultInstanceByProvider is the per-provider default machine
// size the canvas pre-selects when switching providers (an AWS t3.* is invalid on
// Hetzner, so the switch resets to the new provider's default).
var workspaceComputeDefaultInstanceByProvider = map[string]string{
"aws": "t3.medium",
"hetzner": "cpx31",
"gcp": "e2-standard-2",
}
// workspaceComputeInstanceAllowlist is the O(1) validation set, keyed by cloud
// provider. DERIVED from workspaceComputeInstanceTypesOrdered in init() so the
// ordered list (what the canvas renders) and the set (what the backend validates)
// stay in lock-step — you cannot add an instance type to one without the other.
var workspaceComputeInstanceAllowlist = map[string]map[string]struct{}{}
func init() {
for provider, types := range workspaceComputeInstanceTypesOrdered {
set := make(map[string]struct{}, len(types))
for _, t := range types {
set[t] = struct{}{}
}
workspaceComputeInstanceAllowlist[provider] = set
}
}
// normalizeCloudProvider maps "" → "aws" so the in-place switch comparison
// treats the default and an explicit "aws" as the same cloud (no spurious switch).
func normalizeCloudProvider(p string) string {
@@ -88,10 +132,15 @@ func instanceTypeAllowedForProvider(provider, instanceType string) bool {
// change here (and the CP itself fail-closes an unwired provider with a 422).
// "" = default (AWS) and is always accepted. This is the gate the switch-provider
// flow reuses to reject a bad provider with a clean 400 before any CP round-trip.
var workspaceComputeProviderAllowlist = map[string]struct{}{
"aws": {},
"gcp": {},
"hetzner": {},
// DERIVED from workspaceComputeProvidersOrdered (the SSOT, core#2489) in init() so
// the set the backend validates and the ordered list the canvas renders cannot
// drift.
var workspaceComputeProviderAllowlist = map[string]struct{}{}
func init() {
for _, p := range workspaceComputeProvidersOrdered {
workspaceComputeProviderAllowlist[p] = struct{}{}
}
}
func validateWorkspaceCompute(compute models.WorkspaceCompute) error {
@@ -262,6 +311,55 @@ func withStoredCompute(ctx context.Context, workspaceID string, payload models.C
return payload
}
// workspaceComputeOptionsResponse is the SSOT payload the canvas Container-Config
// tab consumes to populate its provider + instance-type dropdowns (core#2489).
// It is derived entirely from the allowlist + defaults in this file, so the UI
// can never offer a (provider, instance-type) the backend then rejects.
type workspaceComputeOptionsResponse struct {
// Providers in canonical render order (AWS first = default).
Providers []string `json:"providers"`
// InstanceTypes per provider, in canonical render order.
InstanceTypes map[string][]string `json:"instanceTypes"`
// Defaults maps each provider → its default instance type (the canvas
// pre-selects this when switching providers).
Defaults map[string]string `json:"defaults"`
}
// buildComputeOptions assembles the SSOT response from the allowlist + defaults.
// Pure (no DB / no gin) so it can be unit-tested directly and reused.
func buildComputeOptions() workspaceComputeOptionsResponse {
providers := make([]string, len(workspaceComputeProvidersOrdered))
copy(providers, workspaceComputeProvidersOrdered)
instanceTypes := make(map[string][]string, len(workspaceComputeInstanceTypesOrdered))
for _, p := range providers {
src := workspaceComputeInstanceTypesOrdered[p]
dst := make([]string, len(src))
copy(dst, src)
instanceTypes[p] = dst
}
defaults := make(map[string]string, len(workspaceComputeDefaultInstanceByProvider))
for k, v := range workspaceComputeDefaultInstanceByProvider {
defaults[k] = v
}
return workspaceComputeOptionsResponse{
Providers: providers,
InstanceTypes: instanceTypes,
Defaults: defaults,
}
}
// ComputeOptions handles GET /workspaces/:id/compute-options. It returns the
// cloud-provider + instance-type metadata the canvas Container-Config tab renders
// its dropdowns from — the SAME data validateWorkspaceCompute enforces (core#2489).
// Static (derived from the in-binary allowlist), so it needs no DB round-trip; the
// :id is scoped only by the WorkspaceAuth middleware on the route group.
func (h *WorkspaceHandler) ComputeOptions(c *gin.Context) {
c.JSON(200, buildComputeOptions())
}
// Display handles GET /workspaces/:id/display.
func (h *WorkspaceHandler) Display(c *gin.Context) {
workspaceID := c.Param("id")
@@ -375,6 +375,103 @@ func TestWithStoredCompute_LoadsComputeForRestartPayloads(t *testing.T) {
}
}
// core#2489: the allowlist (validation set) MUST be derived from the ordered
// lists the canvas renders, so the UI and the backend can never disagree about
// which (provider, instance-type) pairs are valid. This pins that the derived
// set exactly matches the ordered source — adding to one without the other fails.
func TestComputeOptions_AllowlistDerivedFromOrderedSSOT(t *testing.T) {
// Every ordered instance type is in the validation set (and vice-versa).
for provider, types := range workspaceComputeInstanceTypesOrdered {
set, ok := workspaceComputeInstanceAllowlist[provider]
if !ok {
t.Fatalf("allowlist missing provider %q present in ordered SSOT", provider)
}
if len(set) != len(types) {
t.Fatalf("provider %q: ordered list (%d) and allowlist set (%d) drifted", provider, len(types), len(set))
}
for _, it := range types {
if _, ok := set[it]; !ok {
t.Fatalf("provider %q: ordered instance %q missing from validation allowlist", provider, it)
}
}
}
// No extra providers in the set that aren't in the ordered list.
if len(workspaceComputeInstanceAllowlist) != len(workspaceComputeInstanceTypesOrdered) {
t.Fatalf("allowlist has providers not present in the ordered SSOT")
}
// Provider allowlist derived from the ordered providers.
if len(workspaceComputeProviderAllowlist) != len(workspaceComputeProvidersOrdered) {
t.Fatalf("provider allowlist (%d) drifted from ordered providers (%d)", len(workspaceComputeProviderAllowlist), len(workspaceComputeProvidersOrdered))
}
for _, p := range workspaceComputeProvidersOrdered {
if _, ok := workspaceComputeProviderAllowlist[p]; !ok {
t.Fatalf("provider allowlist missing ordered provider %q", p)
}
}
}
// core#2489: the per-provider defaults the canvas pre-selects on a provider switch
// MUST themselves be valid instance types for that provider — otherwise the switch
// produces a PATCH the backend immediately rejects.
func TestComputeOptions_DefaultsAreValidForTheirProvider(t *testing.T) {
for provider, def := range workspaceComputeDefaultInstanceByProvider {
if !instanceTypeAllowedForProvider(provider, def) {
t.Errorf("default instance %q for provider %q is not in that provider's allowlist", def, provider)
}
}
// Every provider must have a default (so the switch never lands on "").
for _, p := range workspaceComputeProvidersOrdered {
if workspaceComputeDefaultInstanceByProvider[p] == "" {
t.Errorf("provider %q has no default instance type", p)
}
}
}
// core#2489: the GET /compute-options endpoint returns exactly the SSOT data the
// canvas renders dropdowns from. Every (provider, instance-type) it advertises
// MUST pass validateWorkspaceCompute — the whole point of the consolidation.
func TestWorkspaceComputeOptions_ReturnsSSOTAndEveryOptionValidates(t *testing.T) {
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-opts"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-opts/compute-options", nil)
handler.ComputeOptions(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp workspaceComputeOptionsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse compute-options response: %v", err)
}
// AWS first (default) in the provider order.
if len(resp.Providers) == 0 || resp.Providers[0] != "aws" {
t.Fatalf("providers = %v, want aws first", resp.Providers)
}
// Every advertised (provider, instance-type) must pass backend validation.
for _, provider := range resp.Providers {
types, ok := resp.InstanceTypes[provider]
if !ok || len(types) == 0 {
t.Fatalf("compute-options advertised provider %q with no instance types", provider)
}
for _, it := range types {
if !instanceTypeAllowedForProvider(provider, it) {
t.Errorf("compute-options advertised %q/%q which the backend rejects (DRIFT)", provider, it)
}
}
def := resp.Defaults[provider]
if def == "" {
t.Errorf("compute-options missing default for provider %q", provider)
} else if !instanceTypeAllowedForProvider(provider, def) {
t.Errorf("compute-options default %q for %q fails backend validation", def, provider)
}
}
}
func TestWorkspaceDisplay_NonDisplayWorkspaceReturnsUnavailable(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
@@ -234,6 +234,13 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
// this specific workspace, or a control-plane-verified tenant session.
wsAuth.PATCH("", wh.Update)
// Compute options — SSOT for the canvas Container-Config tab's cloud-
// provider + instance-type dropdowns (core#2489). Returns the same
// provider/instance metadata validateWorkspaceCompute enforces, so the UI
// can never offer a (provider, instance-type) the PATCH then rejects with
// a 400. Static (derived from the in-binary allowlist) — no DB round-trip.
wsAuth.GET("/compute-options", wh.ComputeOptions)
// Lifecycle
wsAuth.GET("/state", wh.State)
wsAuth.POST("/restart", wh.Restart)