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
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user