diff --git a/apps/api/internal/capacitycontroller/kubernetes.go b/apps/api/internal/capacitycontroller/kubernetes.go index b1d4e56..e6732ac 100644 --- a/apps/api/internal/capacitycontroller/kubernetes.go +++ b/apps/api/internal/capacitycontroller/kubernetes.go @@ -179,6 +179,13 @@ func (client *KubernetesClient) SiteState(ctx context.Context, site string) (Kub return KubernetesSiteState{}, err } if len(nodes.Items) == 0 { + // A zero-replica Deployment with no eligible nodes represents an + // explicitly disabled site. It must remain visible to the planner, but it + // has no node or metrics budget to collect. A non-zero Deployment still + // fails closed so replicas cannot disappear from capacity accounting. + if state.CurrentReplicas == 0 { + return state, nil + } return KubernetesSiteState{}, fmt.Errorf("site %s has no matching nodes", site) } var pods struct { diff --git a/apps/api/internal/capacitycontroller/kubernetes_test.go b/apps/api/internal/capacitycontroller/kubernetes_test.go index 33a0597..8890c1a 100644 --- a/apps/api/internal/capacitycontroller/kubernetes_test.go +++ b/apps/api/internal/capacitycontroller/kubernetes_test.go @@ -88,6 +88,71 @@ func TestKubernetesClientReadsSiteAndMutatesOnlyWorkerScale(t *testing.T) { } } +func TestKubernetesClientAllowsDisabledSiteWithoutWorkerNodes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch { + case request.Method == http.MethodGet && strings.Contains(request.URL.Path, "/deployments/easyai-worker-ningbo"): + _, _ = w.Write([]byte(`{ + "spec":{"replicas":0,"template":{"spec":{"containers":[{ + "name":"worker","image":"registry.invalid/gateway@sha256:abc", + "env":[{"name":"AI_GATEWAY_REVISION","value":"release-sha"}], + "resources":{"requests":{"memory":"1536Mi","cpu":"500m"}} + }]}}} + }`)) + case request.Method == http.MethodGet && request.URL.Path == "/api/v1/nodes": + _, _ = w.Write([]byte(`{"items":[]}`)) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + tokenFile := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenFile, []byte("test-token"), 0o600); err != nil { + t.Fatal(err) + } + client, err := NewKubernetesClient(KubernetesConfig{ + Namespace: "easyai", APIServer: server.URL, TokenFile: tokenFile, HTTPClient: server.Client(), + }) + if err != nil { + t.Fatal(err) + } + state, err := client.SiteState(context.Background(), "ningbo") + if err != nil { + t.Fatal(err) + } + if state.Site != "ningbo" || state.CurrentReplicas != 0 || len(state.Nodes) != 0 { + t.Fatalf("site state=%+v", state) + } +} + +func TestKubernetesClientRejectsActiveSiteWithoutWorkerNodes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch { + case request.Method == http.MethodGet && strings.Contains(request.URL.Path, "/deployments/easyai-worker-ningbo"): + _, _ = w.Write([]byte(`{"spec":{"replicas":1,"template":{"spec":{"containers":[]}}}}`)) + case request.Method == http.MethodGet && request.URL.Path == "/api/v1/nodes": + _, _ = w.Write([]byte(`{"items":[]}`)) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + tokenFile := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenFile, []byte("test-token"), 0o600); err != nil { + t.Fatal(err) + } + client, err := NewKubernetesClient(KubernetesConfig{ + Namespace: "easyai", APIServer: server.URL, TokenFile: tokenFile, HTTPClient: server.Client(), + }) + if err != nil { + t.Fatal(err) + } + if _, err := client.SiteState(context.Background(), "ningbo"); err == nil || + !strings.Contains(err.Error(), "site ningbo has no matching nodes") { + t.Fatalf("expected missing Worker node error, got %v", err) + } +} + func TestQuantityParsing(t *testing.T) { if value, err := parseBinaryQuantity("1536Mi"); err != nil || value != 1536<<20 { t.Fatalf("memory=%d err=%v", value, err)