Classify legacy-CIS-only vCenters as unsupported version

A vSphere 6.x target fails the connect test at the Automation session
step with a generic "HTTP 500" message, because /api on those releases
routes to the JSON-RPC servlet. When /api/session fails with a non-auth
status, probe the legacy /rest CIS session API with the same
credentials; if that login works the target predates the JSON APIs
Pulse uses, so the test now reports the unsupported-version warning
naming the vCenter 8.0U1 floor instead of pointing at credentials. The
probe deletes the session it creates. Also reword the frontend guidance
for that category in plain terms.

Related to #1585.
This commit is contained in:
rcourtman
2026-07-16 19:52:14 +01:00
parent fda64e0318
commit 2f6bb94ed3
5 changed files with 137 additions and 3 deletions
@@ -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',
@@ -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',
@@ -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',
+41
View File
@@ -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.
+93
View File
@@ -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)
}
}