diff --git a/workspace-server/internal/handlers/workspace_compute.go b/workspace-server/internal/handlers/workspace_compute.go index 7efd8b007..b2124e65a 100644 --- a/workspace-server/internal/handlers/workspace_compute.go +++ b/workspace-server/internal/handlers/workspace_compute.go @@ -221,6 +221,37 @@ 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). +// Public, no auth: the data is platform constraints, not org secrets. +func ComputeMetadata(c *gin.Context) { + // Deterministic order so tests (and UI dropdowns) are stable. + providers := []computeProviderMetadata{ + {ID: "aws", Label: "AWS (default)", DefaultInstance: "t3.medium", Instances: []string{ + "t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge", "m6i.large", "m6i.xlarge", "c6i.xlarge", + }}, + {ID: "gcp", Label: "GCP", DefaultInstance: "e2-standard-2", Instances: []string{ + "e2-small", "e2-medium", "e2-standard-2", "e2-standard-4", "e2-standard-8", + }}, + {ID: "hetzner", Label: "Hetzner", DefaultInstance: "cpx31", Instances: []string{ + "cpx11", "cpx21", "cpx31", "cpx41", "cpx51", "cax11", "cax21", "cax31", "cax41", + }}, + } + c.JSON(200, computeMetadataResponse{Providers: providers}) +} + func workspaceComputeIsZero(compute models.WorkspaceCompute) bool { return compute.InstanceType == "" && compute.Volume.RootGB == 0 && diff --git a/workspace-server/internal/handlers/workspace_compute_test.go b/workspace-server/internal/handlers/workspace_compute_test.go index 02359b30c..84be8af20 100644 --- a/workspace-server/internal/handlers/workspace_compute_test.go +++ b/workspace-server/internal/handlers/workspace_compute_test.go @@ -798,3 +798,45 @@ func TestWorkspaceDisplaySession_NonDisplayWorkspaceDoesNotProxy(t *testing.T) { t.Errorf("unmet sqlmock expectations: %v", err) } } + +func TestComputeMetadata_ReturnsProviderAllowlist(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/compute/metadata", nil) + + ComputeMetadata(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } + var resp computeMetadataResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if len(resp.Providers) != 3 { + t.Fatalf("expected 3 providers, got %d", len(resp.Providers)) + } + want := []struct { + id, label, defaultInstance string + instanceCount int + }{ + {"aws", "AWS (default)", "t3.medium", 7}, + {"gcp", "GCP", "e2-standard-2", 5}, + {"hetzner", "Hetzner", "cpx31", 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.Label != w.label { + t.Errorf("providers[%d].label = %q, want %q", i, p.Label, w.label) + } + if p.DefaultInstance != w.defaultInstance { + t.Errorf("providers[%d].default_instance = %q, want %q", i, p.DefaultInstance, w.defaultInstance) + } + if len(p.Instances) != w.instanceCount { + t.Errorf("providers[%d].instances len = %d, want %d", i, len(p.Instances), w.instanceCount) + } + } +} diff --git a/workspace-server/internal/router/compute_metadata_route_test.go b/workspace-server/internal/router/compute_metadata_route_test.go new file mode 100644 index 000000000..c32753ad5 --- /dev/null +++ b/workspace-server/internal/router/compute_metadata_route_test.go @@ -0,0 +1,124 @@ +package router + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/handlers" + "github.com/gin-gonic/gin" +) + +// compute_metadata_route_test.go — issue #2489 SSOT endpoint. +// +// The /compute/metadata route is the single point every consumer reads +// to learn cloud-provider + instance-type allowlists. Without this test, +// a future router refactor could silently drop the route (consumers +// degrade to cached / hard-coded defaults — exactly the drift the +// endpoint exists to prevent) or mount it under an auth group (which +// would 401 the canvas's pre-auth call from a logged-out browser tab). +// +// 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). +// 4. The in-tree Go consumer (handlers.workspaceComputeInstanceAllowlist) +// AGREE with the endpoint's value. + +func buildComputeMetadataEngine(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/compute/metadata", handlers.ComputeMetadata) + return r +} + +func TestComputeMetadata_Public_Returns200(t *testing.T) { + r := buildComputeMetadataEngine(t) + + req := httptest.NewRequest(http.MethodGet, "/compute/metadata", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d (body=%s)", w.Code, w.Body.String()) + } +} + +func TestComputeMetadata_ReturnsExpectedShape(t *testing.T) { + r := buildComputeMetadataEngine(t) + + req := httptest.NewRequest(http.MethodGet, "/compute/metadata", nil) + w := httptest.NewRecorder() + 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"` + } + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal response: %v (body=%s)", err, w.Body.String()) + } + + if len(got.Providers) != 3 { + t.Fatalf("expected 3 providers, got %d", len(got.Providers)) + } + want := []struct { + id, label, defaultInstance string + instanceCount int + }{ + {"aws", "AWS (default)", "t3.medium", 7}, + {"gcp", "GCP", "e2-standard-2", 5}, + {"hetzner", "Hetzner", "cpx31", 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.Label != w.label { + t.Errorf("providers[%d].label = %q, want %q", i, p.Label, w.label) + } + if p.DefaultInstance != w.defaultInstance { + t.Errorf("providers[%d].default_instance = %q, want %q", i, p.DefaultInstance, w.defaultInstance) + } + if len(p.Instances) != w.instanceCount { + t.Errorf("providers[%d].instances len = %d, want %d", i, len(p.Instances), w.instanceCount) + } + } +} + +func TestComputeMetadata_AgreesWithInTreeAllowlist(t *testing.T) { + // The endpoint must return the same instance sets that the PATCH + // validator uses. We probe the allowlist via the exported test + // helper TestValidateWorkspaceCompute_InstanceTypePerProvider (it + // pins the exact sets), but here we simply cross-check counts and + // key presence so the endpoint and the allowlist stay in sync. + // A more thorough check lives in handlers/workspace_compute_test.go. + r := buildComputeMetadataEngine(t) + + req := httptest.NewRequest(http.MethodGet, "/compute/metadata", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + var got struct { + Providers []struct { + ID string `json:"id"` + Instances []string `json:"instances"` + } `json:"providers"` + } + 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) + } + } +} diff --git a/workspace-server/internal/router/router.go b/workspace-server/internal/router/router.go index 460cf4bb7..ec1153dd9 100644 --- a/workspace-server/internal/router/router.go +++ b/workspace-server/internal/router/router.go @@ -132,6 +132,13 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi c.JSON(200, uploads.DefaultUploadLimits()) }) + // Compute metadata — public, no auth. SSOT for cloud-provider + + // instance-type allowlists so the canvas ContainerConfigTab (and any + // other client) renders selectors from the same source the PATCH + // validator uses. Prevents drift where the UI offers an instance the + // backend rejects (#2489). + r.GET("/compute/metadata", handlers.ComputeMetadata) + // /admin/liveness — per-subsystem last-tick timestamps. Operators read this // to catch stuck-but-not-crashed goroutines (the failure mode that caused // the 12h scheduler outage of 2026-04-14, issue #85). Any subsystem whose