Files
pulse/internal/vmware/client.go
T
rcourtman eac9ffcabf feat(vmware): ingest real vCenter tags instead of provenance placeholders
The vSphere adapter filled `Resource.Tags` with six fixed strings on every
resource — `vmware`, `vsphere`, `<kind>`, `source:vcenter`,
`connection:<name>`, `power:<state>` — and never read vCenter's own tag and
category system. Every VM in an estate returned a byte-identical set, so the
workload Tags column rendered the same dots on every row and filtering on any
of them selected everything. Commit 6b78feba8 default-hid the column and said
in as many words that the hide was a stopgap awaiting this fix.

`internal/vmware/client_tags.go` reads the CIS tagging service. That is a
different endpoint family from the `/api/vcenter/...` inventory reads, but the
same vSphere Automation API, so it reuses the caller's `/api/session` token
rather than opening and managing a second session. Associations come from one
batched `list-attached-tags-on-objects` POST per bounded object batch, never a
per-object request; tag and category names resolve through a client-scoped
catalog with a 10-minute TTL, so a steady-state refresh of a tagged estate
costs only the association reads while a rename still converges without a
restart. A vCenter without the tagging service, or an account without the tag
read privilege, degrades into a `tags` stage enrichment issue and leaves the
inventory untagged; it never fails the refresh.

The provenance strings stay. `Resource.Tags` is the only keyword set
`resourceSearchMatch.ts`, the `?tags=` resources filter, and saved
report-schedule tag filters read — `collectSearchCandidates` gathers no
`technology`, `type`, or `platformScopes` candidate — so dropping "vmware" or
"vsphere" would silently stop matching searches and saved filters that depend
on them. Real vCenter labels are appended to that set, never substituted for
it.

Because that flat set is deliberately mixed, it is the wrong source for a
per-row Tags cell. Real tags therefore also land on a canonical `VMware.Tags`
facet that carries vCenter's category alongside each name, and
`useWorkloads.ts` maps `WorkloadGuest.tags` from that facet for any resource
carrying VMware metadata — including the empty case, so a vSphere VM nobody
tagged renders an empty cell instead of falling back to the provenance dots.
vCenter tag names are unique only inside their category, so the flat label is
`category:name`: two categories may each hold a "Production".

With the column carrying per-row meaning again, `tags` leaves
VMWARE_WORKLOAD_DEFAULT_HIDDEN_COLUMN_IDS and the `defaultHiddenMigrationIds`
retirement list, and the state-model test that pinned the stopgap now pins its
absence. No un-hide migration ships alongside it: 6b78feba8 is on main but no tag
contains it, so the stopgap never shipped and no install carries the
auto-hidden preference. That holds only while the two stay together — the
migration writes the hide into each user's saved preference on first load,
so an rc cut from main carrying the stopgap without this commit would make
an explicit un-hide path necessary.

Mock fixtures carry uneven tag coverage — several categories on some objects,
one on others, none on the rest — because a uniform fixture set would hide
exactly the defect this data exists to catch.

Verified against a mock estate built from this branch: `/api/resources`
returns provenance plus real labels on the flat set and only real labels on
`vmware.tags`; the Tags column renders 2-4 dots per tagged VM and none for
untagged ones; a dot's tooltip reads `Backup:Nightly`, and clicking it
searches `tags:Backup:Nightly` and narrows 18 VMs to the 3 that carry it.

Contract deltas: performance-and-scalability.md Extension Point 17 replaces
the stopgap paragraph with the two-surface tag contract and the bounded
tag-read budget; unified-resources.md states the keyword-union vs facet split
and that a present-but-empty facet means "no operator tags" rather than a
fallback; storage-recovery.md extends its VMware descriptive-only boundary to
`vmware.tags`, because vCenter tag vocabularies read like protection policy
(`Backup:Nightly`) and a label the operator wrote must never satisfy a
coverage or compliance verdict that recovery-owned evidence should decide.
2026-08-06 20:50:46 +01:00

640 lines
22 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
)
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)}
}