package vmware import ( "bytes" "context" "crypto/tls" "crypto/x509" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/http/cookiejar" "net/url" "os" "strconv" "strings" "sync/atomic" "time" ) const ( // FeatureVMware allows explicit opt-out of the default-on VMware vCenter // platform integration. FeatureVMware = "PULSE_ENABLE_VMWARE" defaultTimeout = 10 * time.Second inventoryResponseLimitByte = 8 << 20 ) var supportedVIJSONReleases = []string{"9.0.0.0", "8.0.3", "8.0.2.0", "8.0.1.0"} var featureVMwareEnabled atomic.Bool func init() { featureVMwareEnabled.Store(parseFeatureEnabled(os.Getenv(FeatureVMware))) } // IsFeatureEnabled returns whether the VMware vCenter integration is enabled. func IsFeatureEnabled() bool { return featureVMwareEnabled.Load() } // SetFeatureEnabled allows tests to control the feature flag. func SetFeatureEnabled(enabled bool) { featureVMwareEnabled.Store(enabled) } // ResetFeatureEnabledFromEnv restores the feature flag from the current // environment configuration. func ResetFeatureEnabledFromEnv() { featureVMwareEnabled.Store(parseFeatureEnabled(os.Getenv(FeatureVMware))) } func parseFeatureEnabled(raw string) bool { switch strings.TrimSpace(strings.ToLower(raw)) { case "", "1", "true", "yes", "on": return true case "0", "false", "no", "off": return false default: return true } } // ConnectionError classifies VMware connection failures for API consumers. type ConnectionError struct { Category string Message string } func (e *ConnectionError) Error() string { if e == nil { return "" } return e.Message } // InventorySummary captures the minimum read-side floor proven by a successful // VMware connection test. type InventorySummary struct { Hosts int VMs int Datastores int Networks int VIRelease string Degraded bool Issues []InventoryEnrichmentIssue } // ClientConfig configures a VMware vCenter client. type ClientConfig struct { Host string Port int Username string Password string InsecureSkipVerify bool Timeout time.Duration } // Client executes phase-1 VMware connection validation. type Client struct { baseURL *url.URL httpClient *http.Client username string password string // tagCatalog memoizes CIS tag and category names across refreshes. The // poller keeps one client per vCenter connection, so the cache spans the // whole poll loop rather than one collection. tagCatalog vmwareTagCatalog } type viJSONServiceContentRefs struct { SessionManagerMoID string PerfManagerMoID string EventManagerMoID string } // NewClient constructs a VMware client from saved connection input. func NewClient(cfg ClientConfig) (*Client, error) { baseURL, err := normalizeBaseURL(cfg.Host, cfg.Port) if err != nil { return nil, &ConnectionError{Category: "invalid_config", Message: err.Error()} } jar, err := cookiejar.New(nil) if err != nil { return nil, fmt.Errorf("create cookie jar: %w", err) } timeout := cfg.Timeout if timeout <= 0 { timeout = defaultTimeout } transport := &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: cfg.InsecureSkipVerify, // operator-controlled onboarding setting }, } return &Client{ baseURL: baseURL, httpClient: &http.Client{ Timeout: timeout, Transport: transport, Jar: jar, }, username: strings.TrimSpace(cfg.Username), password: strings.TrimSpace(cfg.Password), }, nil } // Close releases idle resources held by the underlying HTTP client. func (c *Client) Close() { if c == nil || c.httpClient == nil { return } if transport, ok := c.httpClient.Transport.(*http.Transport); ok { transport.CloseIdleConnections() } } // TestConnection validates both the Automation API and VI JSON API families and // returns a minimal inventory summary on success. func (c *Client) TestConnection(ctx context.Context) (*InventorySummary, error) { inventory, _, err := c.collectInventoryBaseWithSession(ctx) if err != nil { return nil, err } release, refs, err := c.resolveVIJSONRelease(ctx) if err != nil { return nil, err } sessionID, err := c.loginVIJSON(ctx, release, refs.SessionManagerMoID) if err != nil { return nil, err } perfCounters, err := c.loadPerfCounterCatalog(ctx, release, sessionID, refs.PerfManagerMoID) if err != nil { return nil, err } issues, err := c.validateSignalFloor(ctx, release, sessionID, refs.PerfManagerMoID, refs.EventManagerMoID, inventory, perfCounters) if err != nil { return nil, err } return &InventorySummary{ Hosts: len(inventory.Hosts), VMs: len(inventory.VMs), Datastores: len(inventory.Datastores), Networks: len(inventory.Networks), VIRelease: release, Degraded: len(issues) > 0, Issues: issues, }, nil } // CollectInventory fetches the phase-1 VMware inventory floor used by the // supplemental-ingest provider. func (c *Client) CollectInventory(ctx context.Context) (*InventorySnapshot, error) { inventory, automationSessionID, err := c.collectInventoryBaseWithSession(ctx) if err != nil { return nil, err } release, refs, err := c.resolveVIJSONRelease(ctx) if err != nil { return nil, err } sessionID, err := c.loginVIJSON(ctx, release, refs.SessionManagerMoID) if err != nil { return nil, err } perfCounters, err := c.loadPerfCounterCatalog(ctx, release, sessionID, refs.PerfManagerMoID) if err != nil { return nil, err } signalIssues, err := c.enrichInventorySnapshot(ctx, automationSessionID, release, sessionID, refs.PerfManagerMoID, refs.EventManagerMoID, perfCounters, inventory) if err != nil { return nil, err } topologyIssues, err := c.enrichInventoryTopology(ctx, automationSessionID, release, sessionID, inventory) if err != nil { return nil, err } tagIssues, err := c.enrichInventoryTags(ctx, automationSessionID, inventory) if err != nil { return nil, err } inventory.VIRelease = strings.TrimSpace(release) inventory.CollectedAt = time.Now().UTC() inventory.EnrichmentIssues = append(inventory.EnrichmentIssues, signalIssues...) inventory.EnrichmentIssues = append(inventory.EnrichmentIssues, topologyIssues...) inventory.EnrichmentIssues = append(inventory.EnrichmentIssues, tagIssues...) return inventory, nil } func (c *Client) collectInventoryBaseWithSession(ctx context.Context) (*InventorySnapshot, string, error) { automationSessionID, err := c.createAutomationSession(ctx) if err != nil { return nil, "", err } var hosts []InventoryHost if err := c.listAutomationResources(ctx, automationSessionID, "/api/vcenter/host", "host inventory", &hosts); err != nil { return nil, "", err } var vms []InventoryVM if err := c.listAutomationResources(ctx, automationSessionID, "/api/vcenter/vm", "vm inventory", &vms); err != nil { return nil, "", err } var datastores []InventoryDatastore if err := c.listAutomationResources(ctx, automationSessionID, "/api/vcenter/datastore", "datastore inventory", &datastores); err != nil { return nil, "", err } var networks []InventoryNetwork if err := c.listAutomationResources(ctx, automationSessionID, "/api/vcenter/network", "network inventory", &networks); err != nil { return nil, "", err } return &InventorySnapshot{ Hosts: hosts, VMs: vms, Datastores: datastores, Networks: networks, }, automationSessionID, nil } func normalizeBaseURL(rawHost string, port int) (*url.URL, error) { host := strings.TrimSpace(rawHost) if host == "" { return nil, fmt.Errorf("vmware vcenter host is required") } if !strings.Contains(host, "://") { host = "https://" + host } parsed, err := url.Parse(host) if err != nil { return nil, fmt.Errorf("invalid vmware vcenter host: %w", err) } if parsed.Scheme != "https" { return nil, fmt.Errorf("vmware vcenter connections must use https") } if parsed.Host == "" { return nil, fmt.Errorf("vmware vcenter host is required") } if parsed.Path != "" && parsed.Path != "/" { return nil, fmt.Errorf("vmware vcenter host must not include a path") } if parsed.RawQuery != "" || parsed.Fragment != "" { return nil, fmt.Errorf("vmware vcenter host must not include query or fragment data") } if parsed.Port() == "" { if port <= 0 { port = 443 } parsed.Host = net.JoinHostPort(parsed.Hostname(), strconv.Itoa(port)) } parsed.Path = "" return parsed, nil } func (c *Client) createAutomationSession(ctx context.Context) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL.String()+"/api/session", nil) if err != nil { return "", fmt.Errorf("build automation session request: %w", err) } req.SetBasicAuth(c.username, c.password) req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return "", classifyTransportError("automation session", err) } defer resp.Body.Close() body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if readErr != nil { return "", fmt.Errorf("read automation session response: %w", readErr) } switch resp.StatusCode { case http.StatusOK, http.StatusCreated: default: 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), } } var sessionID string if err := json.Unmarshal(body, &sessionID); err != nil || strings.TrimSpace(sessionID) == "" { return "", &ConnectionError{Category: "endpoint", Message: "VMware Automation API returned an invalid session payload"} } return strings.TrimSpace(sessionID), nil } func (c *Client) listAutomationResources( ctx context.Context, sessionID string, path string, label string, target any, ) error { 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. func (c *Client) getSessionScopedJSON( ctx context.Context, sessionID string, path string, label string, target any, ) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL.String()+path, nil) if err != nil { return fmt.Errorf("build %s request: %w", label, err) } req.Header.Set("Accept", "application/json") req.Header.Set("vmware-api-session-id", sessionID) resp, err := c.httpClient.Do(req) if err != nil { return classifyTransportError(label, err) } defer resp.Body.Close() body, readErr := io.ReadAll(io.LimitReader(resp.Body, inventoryResponseLimitByte)) if readErr != nil { return fmt.Errorf("read %s response: %w", label, readErr) } switch resp.StatusCode { case http.StatusOK: default: return classifyReadStatusCode(label, resp.StatusCode) } if err := json.Unmarshal(body, target); err != nil { return &ConnectionError{Category: "endpoint", Message: fmt.Sprintf("VMware %s response was not valid JSON", label)} } return nil } // postSessionScopedJSON posts a JSON body to a session-authenticated vCenter // endpoint and decodes the response into target. The vSphere Automation API // models several reads as POST actions — CIS tag association lookups are the // current case — so they cannot go through getSessionScopedJSON. func (c *Client) postSessionScopedJSON( ctx context.Context, sessionID string, path string, label string, payload any, target any, ) error { body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal %s request: %w", label, err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL.String()+path, bytes.NewReader(body)) if err != nil { return fmt.Errorf("build %s request: %w", label, err) } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") req.Header.Set("vmware-api-session-id", sessionID) resp, err := c.httpClient.Do(req) if err != nil { return classifyTransportError(label, err) } defer resp.Body.Close() responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, inventoryResponseLimitByte)) if readErr != nil { return fmt.Errorf("read %s response: %w", label, readErr) } if resp.StatusCode != http.StatusOK { return classifyReadStatusCode(label, resp.StatusCode) } if err := json.Unmarshal(responseBody, target); err != nil { return &ConnectionError{Category: "endpoint", Message: fmt.Sprintf("VMware %s response was not valid JSON", label)} } return nil } func (c *Client) getAutomationJSON( ctx context.Context, sessionID string, path string, label string, target any, ) error { return c.getSessionScopedJSON(ctx, sessionID, path, label, target) } func (c *Client) resolveVIJSONRelease(ctx context.Context) (string, viJSONServiceContentRefs, error) { var lastErr error for _, release := range supportedVIJSONReleases { refs, err := c.fetchVIJSONServiceContentRefs(ctx, release) if err == nil { return release, refs, nil } lastErr = err if !isVIJSONReleaseProbeRetryable(err) { return "", viJSONServiceContentRefs{}, err } } if lastErr != nil && isVIJSONNotFound(lastErr) { return "", viJSONServiceContentRefs{}, &ConnectionError{ Category: "unsupported_version", Message: fmt.Sprintf( "VMware vCenter version is outside the implemented VI JSON probe floor; Pulse currently probes %s", strings.Join(supportedVIJSONReleases, ", "), ), } } if lastErr != nil { return "", viJSONServiceContentRefs{}, lastErr } return "", viJSONServiceContentRefs{}, &ConnectionError{ Category: "unsupported_version", Message: fmt.Sprintf( "VMware vCenter version is outside the implemented VI JSON probe floor; Pulse currently probes %s", strings.Join(supportedVIJSONReleases, ", "), ), } } // isVIJSONReleaseProbeRetryable reports whether a failed service-content probe // for one vim25 release string should fall through to the next release in the // list. vCenter answers a newer-than-supported release path with 404 on some // builds and HTTP 500 on others (8.0.x returns 500 for the 9.0.0.0 path), so // both "not_found" and generic endpoint failures continue the negotiation. // Auth, permission, TLS, and network errors abort: they will not change with a // different release string. func isVIJSONReleaseProbeRetryable(err error) bool { connectionErr, ok := err.(*ConnectionError) if !ok { return false } switch connectionErr.Category { case "not_found", "endpoint": return true default: return false } } func (c *Client) fetchVIJSONServiceContentRefs(ctx context.Context, release string) (viJSONServiceContentRefs, error) { endpoint := fmt.Sprintf("%s/sdk/vim25/%s/ServiceInstance/ServiceInstance/content", c.baseURL.String(), release) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return viJSONServiceContentRefs{}, fmt.Errorf("build vi-json service instance request: %w", err) } req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return viJSONServiceContentRefs{}, classifyTransportError("vi-json service content", err) } defer resp.Body.Close() body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if readErr != nil { return viJSONServiceContentRefs{}, fmt.Errorf("read vi-json service content response: %w", readErr) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return viJSONServiceContentRefs{}, classifyReadStatusCode("vi-json service content", resp.StatusCode) } var payload struct { SessionManager viJSONReference `json:"sessionManager"` PerfManager viJSONReference `json:"perfManager"` EventManager viJSONReference `json:"eventManager"` } if err := json.Unmarshal(body, &payload); err != nil { return viJSONServiceContentRefs{}, &ConnectionError{Category: "endpoint", Message: "VMware VI JSON API service-instance response was not valid JSON"} } refs := viJSONServiceContentRefs{ SessionManagerMoID: strings.TrimSpace(payload.SessionManager.Value), PerfManagerMoID: strings.TrimSpace(payload.PerfManager.Value), EventManagerMoID: strings.TrimSpace(payload.EventManager.Value), } if refs.SessionManagerMoID == "" { return viJSONServiceContentRefs{}, &ConnectionError{Category: "endpoint", Message: "VMware VI JSON API service-instance response did not include a session manager reference"} } if refs.PerfManagerMoID == "" { return viJSONServiceContentRefs{}, &ConnectionError{Category: "endpoint", Message: "VMware VI JSON API service-instance response did not include a performance manager reference"} } if refs.EventManagerMoID == "" { return viJSONServiceContentRefs{}, &ConnectionError{Category: "endpoint", Message: "VMware VI JSON API service-instance response did not include an event manager reference"} } return refs, nil } func (c *Client) loginVIJSON(ctx context.Context, release string, sessionManagerMoID string) (string, error) { body, err := json.Marshal(map[string]string{ "userName": c.username, "password": c.password, }) if err != nil { return "", fmt.Errorf("marshal vi-json login request: %w", err) } endpoint := fmt.Sprintf("%s/sdk/vim25/%s/SessionManager/%s/Login", c.baseURL.String(), release, sessionManagerMoID) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body))) if err != nil { return "", fmt.Errorf("build vi-json login request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return "", classifyTransportError("vi-json login", err) } defer resp.Body.Close() _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) switch resp.StatusCode { case http.StatusOK: sessionID := strings.TrimSpace(resp.Header.Get("vmware-api-session-id")) if sessionID == "" { return "", &ConnectionError{Category: "endpoint", Message: "VMware VI JSON API login succeeded without returning a session id"} } return sessionID, nil case http.StatusUnauthorized, http.StatusForbidden: return "", &ConnectionError{Category: "auth", Message: "VMware authentication failed while creating the VI JSON API session"} case http.StatusServiceUnavailable: return "", &ConnectionError{Category: "unavailable", Message: "VMware VI JSON API login is temporarily unavailable"} default: return "", &ConnectionError{Category: "endpoint", Message: fmt.Sprintf("VMware VI JSON API login failed with HTTP %d", resp.StatusCode)} } } func classifyReadStatusCode(label string, statusCode int) error { switch statusCode { case http.StatusUnauthorized: return &ConnectionError{Category: "auth", Message: fmt.Sprintf("VMware authentication failed while reading %s", label)} case http.StatusForbidden: return &ConnectionError{Category: "permission", Message: fmt.Sprintf("VMware permissions are insufficient for %s", label)} case http.StatusNotFound: return &ConnectionError{Category: "not_found", Message: fmt.Sprintf("VMware %s endpoint is unavailable", label)} case http.StatusServiceUnavailable: return &ConnectionError{Category: "unavailable", Message: fmt.Sprintf("VMware %s is temporarily unavailable", label)} default: return &ConnectionError{Category: "endpoint", Message: fmt.Sprintf("VMware %s request failed with HTTP %d", label, statusCode)} } } func classifyTransportError(stage string, err error) error { if err == nil { return nil } lower := strings.ToLower(err.Error()) if strings.Contains(lower, "x509") || strings.Contains(lower, "certificate") || strings.Contains(lower, "tls") { return &ConnectionError{Category: "tls", Message: fmt.Sprintf("VMware TLS validation failed during %s", stage)} } var unknownAuthority *x509.UnknownAuthorityError if errors.As(err, &unknownAuthority) { return &ConnectionError{Category: "tls", Message: fmt.Sprintf("VMware TLS validation failed during %s", stage)} } var netErr net.Error if errors.As(err, &netErr) { return &ConnectionError{Category: "network", Message: fmt.Sprintf("VMware network error during %s", stage)} } return &ConnectionError{Category: "network", Message: fmt.Sprintf("VMware connection failed during %s", stage)} }