mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
eac9ffcabf
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. Commit6b78feba8default-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:6b78feba8is 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.
640 lines
22 KiB
Go
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)}
|
|
}
|