Files
pulse/internal/vmware/client.go
T
rcourtman 2f6bb94ed3 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.
2026-07-16 19:52:14 +01:00

564 lines
19 KiB
Go

package vmware
import (
"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
}
// 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
}
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
}
if err := c.validateSignalFloor(ctx, release, sessionID, refs.PerfManagerMoID, refs.EventManagerMoID, inventory, perfCounters); 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,
}, 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
}
inventory.VIRelease = strings.TrimSpace(release)
inventory.CollectedAt = time.Now().UTC()
inventory.EnrichmentIssues = append(inventory.EnrichmentIssues, signalIssues...)
inventory.EnrichmentIssues = append(inventory.EnrichmentIssues, topologyIssues...)
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
}
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 !isVIJSONNotFound(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, ", "),
),
}
}
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)}
}