diff --git a/frontend-modern/src/components/Settings/__tests__/useVMwareSettingsPanelState.test.tsx b/frontend-modern/src/components/Settings/__tests__/useVMwareSettingsPanelState.test.tsx index f32934b94..29b37fa03 100644 --- a/frontend-modern/src/components/Settings/__tests__/useVMwareSettingsPanelState.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/useVMwareSettingsPanelState.test.tsx @@ -283,7 +283,7 @@ describe('useVMwareSettingsPanelState', () => { category: 'unsupported_version', code: 'vmware_connection_failed', guidance: - 'Use a supported vCenter release within the current VI JSON phase-1 floor, then retry this connection test.', + 'Pulse supports vCenter 8.0U1 and newer. Upgrade vCenter, then retry this connection test.', message: 'VMware vCenter 6.7 is below the supported VI JSON release floor', title: 'Unsupported vCenter version', tone: 'warning', diff --git a/frontend-modern/src/components/Settings/__tests__/vmwareConnectionFailurePresentation.branchcov0712.test.ts b/frontend-modern/src/components/Settings/__tests__/vmwareConnectionFailurePresentation.branchcov0712.test.ts index 8fe026d40..5d6e0340a 100644 --- a/frontend-modern/src/components/Settings/__tests__/vmwareConnectionFailurePresentation.branchcov0712.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/vmwareConnectionFailurePresentation.branchcov0712.test.ts @@ -92,7 +92,7 @@ describe('buildVMwareConnectionFailurePresentation — each named category switc title: 'Unsupported vCenter version', tone: 'warning', guidance: - 'Use a supported vCenter release within the current VI JSON phase-1 floor, then retry this connection test.', + 'Pulse supports vCenter 8.0U1 and newer. Upgrade vCenter, then retry this connection test.', }, { category: 'tls', diff --git a/frontend-modern/src/components/Settings/vmwareConnectionFailurePresentation.ts b/frontend-modern/src/components/Settings/vmwareConnectionFailurePresentation.ts index bc0e6475b..1b236ed31 100644 --- a/frontend-modern/src/components/Settings/vmwareConnectionFailurePresentation.ts +++ b/frontend-modern/src/components/Settings/vmwareConnectionFailurePresentation.ts @@ -35,7 +35,7 @@ export const buildVMwareConnectionFailurePresentation = ( code, category, guidance: - 'Use a supported vCenter release within the current VI JSON phase-1 floor, then retry this connection test.', + 'Pulse supports vCenter 8.0U1 and newer. Upgrade vCenter, then retry this connection test.', message, title: 'Unsupported vCenter version', tone: 'warning', diff --git a/internal/vmware/client.go b/internal/vmware/client.go index ec58ddd19..3e1b7fd7a 100644 --- a/internal/vmware/client.go +++ b/internal/vmware/client.go @@ -305,6 +305,12 @@ func (c *Client) createAutomationSession(ctx context.Context) (string, error) { if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { return "", &ConnectionError{Category: "auth", Message: "VMware authentication failed while creating the Automation API session"} } + if c.legacyCISSessionWorks(ctx) { + return "", &ConnectionError{ + Category: "unsupported_version", + Message: "This vCenter only answers the legacy /rest CIS API, which predates the JSON APIs Pulse uses. Pulse supports vCenter 8.0U1 and newer.", + } + } return "", &ConnectionError{ Category: "endpoint", Message: fmt.Sprintf("VMware Automation API session request failed with HTTP %d", resp.StatusCode), @@ -327,6 +333,41 @@ func (c *Client) listAutomationResources( return c.getAutomationJSON(ctx, sessionID, path, label, target) } +// legacyCISSessionWorks probes the pre-7.0 CIS REST session endpoint after an +// /api/session failure. vSphere 6.x releases (EOL) only expose the legacy +// /rest API, so a successful legacy login with the same credentials means the +// target is an old release rather than a broken endpoint. The probe session is +// deleted best-effort so it does not count against vCenter session limits. +func (c *Client) legacyCISSessionWorks(ctx context.Context) bool { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL.String()+"/rest/com/vmware/cis/session", nil) + if err != nil { + return false + } + req.SetBasicAuth(c.username, c.password) + req.Header.Set("Accept", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return false + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return false + } + var payload struct { + Value string `json:"value"` + } + if err := json.Unmarshal(body, &payload); err == nil && strings.TrimSpace(payload.Value) != "" { + if delReq, delErr := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL.String()+"/rest/com/vmware/cis/session", nil); delErr == nil { + delReq.Header.Set("vmware-api-session-id", strings.TrimSpace(payload.Value)) + if delResp, doErr := c.httpClient.Do(delReq); doErr == nil { + delResp.Body.Close() + } + } + } + return true +} + // getSessionScopedJSON fetches a session-authenticated vCenter JSON endpoint // (shared by the Automation API and VI/JSON API paths) into target with the // inventory response size cap and shared error classification. diff --git a/internal/vmware/client_test.go b/internal/vmware/client_test.go index 0095cc739..aa37a4ffe 100644 --- a/internal/vmware/client_test.go +++ b/internal/vmware/client_test.go @@ -1066,3 +1066,96 @@ func requireVISession(t *testing.T, r *http.Request) { t.Fatalf("vi-json session header = %q, want vi-session", got) } } + +func TestClientCreateAutomationSessionClassifiesLegacyCISVCenter(t *testing.T) { + // vSphere 6.x (#1585): /api/session fails because /api routes to the + // JSON-RPC servlet, while the legacy /rest CIS session API works. The + // failure must classify as unsupported_version, not a generic endpoint + // error. + mux := http.NewServeMux() + mux.HandleFunc("/api/session", func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "Missing Content-Type header", http.StatusInternalServerError) + }) + legacyDeleted := false + mux.HandleFunc("/rest/com/vmware/cis/session", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + if _, _, ok := r.BasicAuth(); !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write([]byte(`{"value":"legacy-session"}`)); err != nil { + t.Fatalf("write legacy session response: %v", err) + } + case http.MethodDelete: + legacyDeleted = true + w.WriteHeader(http.StatusOK) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) + server := httptest.NewTLSServer(mux) + defer server.Close() + + client, err := NewClient(ClientConfig{ + Host: server.URL, + Username: "admin", + Password: "secret", + InsecureSkipVerify: true, + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + _, err = client.createAutomationSession(context.Background()) + if err == nil { + t.Fatal("expected unsupported version error") + } + connectionErr, ok := err.(*ConnectionError) + if !ok { + t.Fatalf("expected ConnectionError, got %T", err) + } + if connectionErr.Category != "unsupported_version" { + t.Fatalf("connection error category = %q, want unsupported_version", connectionErr.Category) + } + if !strings.Contains(connectionErr.Message, "8.0U1") { + t.Fatalf("expected message to name the supported floor, got %q", connectionErr.Message) + } + if !legacyDeleted { + t.Fatal("expected probe to delete the legacy session it created") + } +} + +func TestClientCreateAutomationSessionKeepsEndpointErrorWithoutLegacyCIS(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/session", func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + }) + server := httptest.NewTLSServer(mux) + defer server.Close() + + client, err := NewClient(ClientConfig{ + Host: server.URL, + Username: "admin", + Password: "secret", + InsecureSkipVerify: true, + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + _, err = client.createAutomationSession(context.Background()) + if err == nil { + t.Fatal("expected endpoint error") + } + connectionErr, ok := err.(*ConnectionError) + if !ok { + t.Fatalf("expected ConnectionError, got %T", err) + } + if connectionErr.Category != "endpoint" { + t.Fatalf("connection error category = %q, want endpoint", connectionErr.Category) + } +}