fix(workspace-server): derive image-refresh runtime allowlist from providers SSOT (google-adk drift) (#578) #2348
@@ -9,6 +9,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/providers"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/provisioner"
|
||||
)
|
||||
|
||||
@@ -41,10 +43,53 @@ func NewWorkspaceImageService(docker *dockerclient.Client) *WorkspaceImageServic
|
||||
return &WorkspaceImageService{docker: docker}
|
||||
}
|
||||
|
||||
// AllRuntimes is the canonical list mirroring docs/workspace-runtime-package.md.
|
||||
// Update both when a new template is added.
|
||||
var AllRuntimes = []string{
|
||||
"claude-code", "codex", "hermes", "openclaw",
|
||||
// AllRuntimes is the canonical set of workspace runtimes this tenant will
|
||||
// pull/recreate template images for. It is DERIVED from the same providers
|
||||
// manifest SSOT (internal/providers/providers.yaml `runtimes:` block, mirrored
|
||||
// from CP's providers.yaml) that the rest of the platform routes against —
|
||||
// NOT a second hand-maintained list.
|
||||
//
|
||||
// Why derive instead of hardcode (controlplane#578): the old hardcoded slice
|
||||
// here ({claude-code, codex, hermes, openclaw}) silently DRIFTED from CP, which
|
||||
// already accepts `google-adk` for pin-promote/redeploy. A google-adk pin would
|
||||
// be accepted CP-side, then this tenant's POST /admin/workspace-images/refresh
|
||||
// ?runtime=google-adk rejected it 400 ("unknown runtime"), so google-adk image
|
||||
// fixes never deployed. Deriving from the manifest makes the tenant allowlist
|
||||
// and the CP allowlist provably the same set — they can't drift again.
|
||||
//
|
||||
// imageRefreshFallbackRuntimes is used ONLY if the embedded providers manifest
|
||||
// fails to load (which would be a build/CI failure caught by the providers
|
||||
// package's own tests, never a healthy prod). It preserves the historical
|
||||
// behavior — plus google-adk — so a manifest regression can never take the
|
||||
// refresh endpoint fully offline. Kept in lockstep with the providers.yaml
|
||||
// `runtimes:` keys; the drift guard in admin_workspace_images_test.go asserts
|
||||
// the two match.
|
||||
var imageRefreshFallbackRuntimes = []string{
|
||||
"claude-code", "codex", "google-adk", "hermes", "openclaw",
|
||||
}
|
||||
|
||||
// AllRuntimes is computed once at package init from the providers SSOT.
|
||||
var AllRuntimes = loadImageRefreshRuntimes()
|
||||
|
||||
// loadImageRefreshRuntimes returns the sorted runtime names declared in the
|
||||
// providers manifest, falling back to imageRefreshFallbackRuntimes if the
|
||||
// manifest can't be loaded.
|
||||
func loadImageRefreshRuntimes() []string {
|
||||
m, err := providers.LoadManifest()
|
||||
if err != nil || len(m.Runtimes) == 0 {
|
||||
if err != nil {
|
||||
log.Printf("workspace-images: providers.LoadManifest failed (%v); falling back to static runtime allowlist", err)
|
||||
}
|
||||
out := append([]string(nil), imageRefreshFallbackRuntimes...)
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
out := make([]string, 0, len(m.Runtimes))
|
||||
for rt := range m.Runtimes {
|
||||
out = append(out, rt)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// RefreshResult is the per-call outcome surfaced to HTTP callers AND logged
|
||||
@@ -197,7 +242,7 @@ func (s *WorkspaceImageService) Refresh(ctx context.Context, runtimes []string,
|
||||
|
||||
// AdminWorkspaceImagesHandler serves POST /admin/workspace-images/refresh.
|
||||
//
|
||||
// ?runtime=claude-code (optional; default = all 8 templates)
|
||||
// ?runtime=claude-code (optional; default = all runtimes in AllRuntimes)
|
||||
// &recreate=true|false (default true; false = pull only)
|
||||
//
|
||||
// Returns JSON {pulled: [...], failed: [...], recreated: [...]}
|
||||
|
||||
@@ -3,7 +3,14 @@ package handlers
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/providers"
|
||||
)
|
||||
|
||||
func TestGHCRAuthHeader_NoEnvReturnsEmpty(t *testing.T) {
|
||||
@@ -92,6 +99,119 @@ func TestGHCRAuthHeader_RespectsRegistryEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeListContains is a tiny membership helper for the runtime-allowlist tests.
|
||||
func runtimeListContains(s []string, v string) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestAllRuntimes_IncludesGoogleADK is the direct regression for
|
||||
// controlplane#578: a google-adk pin promote/redeploy is accepted CP-side, so
|
||||
// the tenant image-refresh allowlist MUST also accept google-adk or the image
|
||||
// fix never deploys (tenant returned 400 "unknown runtime"). google-adk lives
|
||||
// in the providers SSOT, so the derived AllRuntimes must contain it.
|
||||
func TestAllRuntimes_IncludesGoogleADK(t *testing.T) {
|
||||
if !runtimeListContains(AllRuntimes, "google-adk") {
|
||||
t.Fatalf("AllRuntimes must include google-adk (controlplane#578 drift); got %v", AllRuntimes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllRuntimes_MatchesProvidersSSOT is the drift guard. AllRuntimes is
|
||||
// derived from providers.LoadManifest().Runtimes — assert it equals exactly the
|
||||
// runtime keys the providers manifest (mirrored from CP's providers.yaml)
|
||||
// declares. If CP adds/removes a runtime, this test fails RED until the tenant
|
||||
// re-derives, so the tenant image-refresh allowlist can never silently drift
|
||||
// from the CP pin-promote allowlist again.
|
||||
func TestAllRuntimes_MatchesProvidersSSOT(t *testing.T) {
|
||||
m, err := providers.LoadManifest()
|
||||
if err != nil {
|
||||
t.Fatalf("providers.LoadManifest: %v", err)
|
||||
}
|
||||
want := make([]string, 0, len(m.Runtimes))
|
||||
for rt := range m.Runtimes {
|
||||
want = append(want, rt)
|
||||
}
|
||||
sort.Strings(want)
|
||||
|
||||
got := append([]string(nil), AllRuntimes...)
|
||||
sort.Strings(got)
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("AllRuntimes drift: got %v, want %v (providers SSOT)", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("AllRuntimes drift at %d: got %v, want %v (providers SSOT)", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageRefreshFallbackMatchesSSOT pins the static fallback (used only when
|
||||
// the embedded manifest fails to load) to the providers SSOT. If a runtime is
|
||||
// added to providers.yaml but not to imageRefreshFallbackRuntimes, this fails
|
||||
// RED — so a manifest-load failure can't silently drop a supported runtime.
|
||||
func TestImageRefreshFallbackMatchesSSOT(t *testing.T) {
|
||||
m, err := providers.LoadManifest()
|
||||
if err != nil {
|
||||
t.Fatalf("providers.LoadManifest: %v", err)
|
||||
}
|
||||
want := make([]string, 0, len(m.Runtimes))
|
||||
for rt := range m.Runtimes {
|
||||
want = append(want, rt)
|
||||
}
|
||||
sort.Strings(want)
|
||||
|
||||
got := append([]string(nil), imageRefreshFallbackRuntimes...)
|
||||
sort.Strings(got)
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("fallback drift: got %v, want %v (providers SSOT)", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("fallback drift at %d: got %v, want %v (providers SSOT)", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefresh_RejectsUnknownRuntime asserts a genuinely unknown runtime still
|
||||
// 400s (the guard isn't removed) AND that the 400 body lists google-adk in
|
||||
// known_runtimes (proving the allowlist now advertises it). This exercises the
|
||||
// gin handler's reject branch, which runs entirely before any Docker call.
|
||||
func TestRefresh_RejectsUnknownRuntime(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// nil docker client is safe: the unknown-runtime branch returns 400
|
||||
// before svc.Refresh (which is the only path that touches Docker).
|
||||
h := &AdminWorkspaceImagesHandler{svc: &WorkspaceImageService{}}
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/admin/workspace-images/refresh", h.Refresh)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/workspace-images/refresh?runtime=not-a-real-runtime", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unknown runtime: got status %d, want 400; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
KnownRuntimes []string `json:"known_runtimes"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode 400 body: %v (raw=%s)", err, rec.Body.String())
|
||||
}
|
||||
if !runtimeListContains(body.KnownRuntimes, "google-adk") {
|
||||
t.Errorf("400 known_runtimes must advertise google-adk (controlplane#578); got %v", body.KnownRuntimes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGHCRAuthHeader_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
|
||||
// .env lines often have trailing newlines or accidental spaces. Without
|
||||
|
||||
Reference in New Issue
Block a user