feat(ws-server): validate compute.provider vs cloud-provider SSOT (switch-provider PR1) #2420

Merged
agent-researcher merged 1 commits from feat/ws-compute-provider-validation into main 2026-06-08 20:12:46 +00:00
2 changed files with 51 additions and 0 deletions
@@ -41,6 +41,20 @@ var workspaceComputeInstanceAllowlist = map[string]struct{}{
"c6i.xlarge": {},
}
// workspaceComputeProviderAllowlist mirrors the controlplane cloud-provider SSOT
// (controlplane internal/cloudprovider.Supported = {aws, hetzner, gcp}).
// ws-server lives in a different repo and cannot import that package, so this is
// a DELIBERATE mirror; TestValidateWorkspaceCompute_Provider pins the exact set
// and this doc-comment names the SSOT, so a CP-side change forces a matching
// 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": {},
}
func validateWorkspaceCompute(compute models.WorkspaceCompute) error {
if compute.InstanceType != "" {
if _, ok := workspaceComputeInstanceAllowlist[compute.InstanceType]; !ok {
@@ -73,6 +87,15 @@ func validateWorkspaceCompute(compute models.WorkspaceCompute) error {
default:
return fmt.Errorf("unsupported compute.data_persistence (want persist|ephemeral)")
}
// Cloud backend for the box (multi-provider). "" = default (AWS). CP fail-
// closes an unwired provider with a 422 (PROVIDER_UNAVAILABLE); validating
// here gives a clean 400 before the round-trip and is the gate reused by the
// switch-provider flow. Mirrors the controlplane cloudprovider SSOT.
if compute.Provider != "" {
if _, ok := workspaceComputeProviderAllowlist[compute.Provider]; !ok {
return fmt.Errorf("unsupported compute.provider (want aws|gcp|hetzner)")
}
}
return nil
}
@@ -36,6 +36,34 @@ func TestValidateWorkspaceCompute_RejectsUnknownInstanceType(t *testing.T) {
}
}
// Multi-provider: compute.provider must be "" (default AWS) or one of the wired
// cloud backends. Pins the allowlist to the controlplane cloudprovider SSOT
// (Supported = {aws, hetzner, gcp}); if the SSOT changes, update both sides.
func TestValidateWorkspaceCompute_Provider(t *testing.T) {
for _, ok := range []string{"", "aws", "gcp", "hetzner"} {
c := models.WorkspaceCompute{Provider: ok}
if err := validateWorkspaceCompute(c); err != nil {
t.Errorf("provider=%q must be accepted: %v", ok, err)
}
}
for _, bad := range []string{"AWS", "azure", "digitalocean", "ec2", "google", "hetzner-cloud"} {
c := models.WorkspaceCompute{Provider: bad}
if err := validateWorkspaceCompute(c); err == nil {
t.Errorf("provider=%q must be rejected", bad)
}
}
// Pin the exact SSOT-mirrored set so a silent drift fails here.
want := map[string]struct{}{"aws": {}, "gcp": {}, "hetzner": {}}
if len(workspaceComputeProviderAllowlist) != len(want) {
t.Fatalf("provider allowlist drifted from SSOT {aws,gcp,hetzner}: %v", workspaceComputeProviderAllowlist)
}
for p := range want {
if _, ok := workspaceComputeProviderAllowlist[p]; !ok {
t.Fatalf("provider allowlist missing %q (SSOT drift)", p)
}
}
}
// internal#734: data_persistence enum. "" (auto), "persist", "ephemeral" are
// the only accepted values; anything else is a clear 400 before the CP call.
func TestValidateWorkspaceCompute_DataPersistence(t *testing.T) {