Files
pulse/internal/vmware/client.go
T
pulse-triage[bot] 1b7bb40b0e fix(vmware): tolerate absent JSON values and surface API fault detail
The vSphere JSON API represents an absent or null property (for example the parent of a top-level folder) as a successful response with an empty body, which was decoded as a spurious 'not valid JSON' enrichment error. Non-2xx responses also carry a JSON fault body that was discarded. See #2070.

Change-source: pulse-maintainer
2026-09-20 00:04:07 +01:00

713 lines
24 KiB
Go

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
)
// Prefer the canonical four-component release; retain the legacy spelling
// for endpoints that already accept it before falling back to older schemas.
var supportedVIJSONReleases = []string{"9.0.0.0", "8.0.3.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 classifyReadStatusCodeWithBody(label, resp.StatusCode, body)
}
return decodeVIJSONBody(body, label, target)
}
// 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 classifyReadStatusCodeWithBody(label, resp.StatusCode, responseBody)
}
return decodeVIJSONBody(responseBody, label, target)
}
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 {
return classifyReadStatusCodeWithBody(label, statusCode, nil)
}
// classifyReadStatusCodeWithBody classifies a non-2xx read and, when the body is
// present, appends the vSphere JSON API fault detail. The JSON API answers
// client-side argument problems (an unknown argument name or shape) with an
// HTTP 5xx plus a JSON fault body, so the faultstring/invalidProperty is the only
// actionable part of the response; discarding it leaves the operator with an
// opaque "HTTP 500".
func classifyReadStatusCodeWithBody(label string, statusCode int, body []byte) 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:
message := fmt.Sprintf("VMware %s request failed with HTTP %d", label, statusCode)
if detail := viJSONFaultDetail(body); detail != "" {
message = fmt.Sprintf("%s (%s)", message, detail)
}
return &ConnectionError{Category: "endpoint", Message: message}
}
}
// viJSONFault models the fault envelope the vSphere JSON API returns in the body
// of a rejected request.
type viJSONFault struct {
TypeName string `json:"_typeName"`
FaultString string `json:"faultstring"`
InvalidProperty string `json:"invalidProperty"`
}
const viJSONFaultDetailLimit = 200
// viJSONFaultDetail extracts a bounded, single-line summary of a vSphere JSON
// API fault body. It returns "" when the body is not a recognisable fault so the
// caller falls back to the plain status line. The text is server-controlled, so
// it is collapsed to one line and length-bounded before it reaches logs or the
// connection status surface.
func viJSONFaultDetail(body []byte) string {
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 || trimmed[0] != '{' {
return ""
}
var fault viJSONFault
if err := json.Unmarshal(trimmed, &fault); err != nil {
return ""
}
parts := make([]string, 0, 3)
if value := viJSONFaultFieldText(fault.FaultString); value != "" {
parts = append(parts, value)
}
if value := viJSONFaultFieldText(fault.InvalidProperty); value != "" {
parts = append(parts, "invalidProperty="+value)
}
if value := viJSONFaultFieldText(fault.TypeName); value != "" {
parts = append(parts, "type="+value)
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, ", ")
}
func viJSONFaultFieldText(raw string) string {
raw = strings.Join(strings.Fields(raw), " ")
if len(raw) > viJSONFaultDetailLimit {
raw = raw[:viJSONFaultDetailLimit]
}
return raw
}
// decodeVIJSONBody decodes a vSphere JSON API response body into target. A
// successful response with an empty body represents an absent/null value (for
// example the parent of a top-level folder), not a malformed response, so it
// leaves target at its zero value instead of raising a spurious "not valid JSON"
// enrichment error.
func decodeVIJSONBody(body []byte, label string, target any) error {
if len(bytes.TrimSpace(body)) == 0 {
return nil
}
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 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)}
}