mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
990 lines
29 KiB
Go
990 lines
29 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/vmware"
|
|
)
|
|
|
|
const vmwareConnectionsPathPrefix = "/api/vmware/connections/"
|
|
|
|
// VMwareHandlers manages VMware vCenter connection CRUD and connectivity checks.
|
|
type VMwareHandlers struct {
|
|
getPersistence func(ctx context.Context) *config.ConfigPersistence
|
|
getMonitor func(ctx context.Context) *monitoring.Monitor
|
|
getPoller func(ctx context.Context) *monitoring.VMwarePoller
|
|
newClient func(vmware.ClientConfig) (vmwareClient, error)
|
|
previewRecords func(ctx context.Context, instance config.VMwareVCenterInstance) ([]unifiedresources.IngestRecord, error)
|
|
|
|
statusMu sync.RWMutex
|
|
statuses map[string]vmwareConnectionRuntimeStatus
|
|
}
|
|
|
|
type vmwareClient interface {
|
|
TestConnection(ctx context.Context) (*vmware.InventorySummary, error)
|
|
Close()
|
|
}
|
|
|
|
type vmwareConnectionResponse struct {
|
|
config.VMwareVCenterInstance
|
|
Poll *monitoring.VMwareConnectionPollStatus `json:"poll,omitempty"`
|
|
Observed *monitoring.VMwareConnectionObservedSummary `json:"observed,omitempty"`
|
|
}
|
|
|
|
type vmwareConnectionRuntimeStatus struct {
|
|
Poll *monitoring.VMwareConnectionPollStatus
|
|
Observed *monitoring.VMwareConnectionObservedSummary
|
|
}
|
|
|
|
// HandleAdd stores a new VMware vCenter connection.
|
|
func (h *VMwareHandlers) HandleAdd(w http.ResponseWriter, r *http.Request) {
|
|
if !h.featureEnabled(w) {
|
|
return
|
|
}
|
|
if mock.IsMockEnabled() {
|
|
writeErrorResponse(w, http.StatusForbidden, "mock_mode_enabled", "Cannot modify connections in mock mode", nil)
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
|
|
defer r.Body.Close()
|
|
|
|
instance, ok := decodeVMwareInstanceRequest(w, r, config.NewVMwareVCenterInstance())
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
instance.ID = strings.TrimSpace(instance.ID)
|
|
if instance.ID == "" {
|
|
instance.ID = config.NewVMwareVCenterInstance().ID
|
|
}
|
|
normalizeVMwareInstance(&instance)
|
|
|
|
if err := instance.Validate(); err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
persistence := h.persistenceForRequest(w, r.Context())
|
|
if persistence == nil {
|
|
return
|
|
}
|
|
if h.enforceMonitoredSystemLimit(w, r, instance) {
|
|
return
|
|
}
|
|
|
|
instances, err := persistence.LoadVMwareConfig()
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
instances = append(instances, instance)
|
|
if err := persistence.SaveVMwareConfig(instances); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "vmware_save_failed", "Failed to save VMware configuration", map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, instance.Redacted())
|
|
}
|
|
|
|
// HandleList returns all configured VMware connections with sensitive fields redacted.
|
|
func (h *VMwareHandlers) HandleList(w http.ResponseWriter, r *http.Request) {
|
|
if !h.featureEnabled(w) {
|
|
return
|
|
}
|
|
if mock.IsMockEnabled() {
|
|
writeJSON(w, http.StatusOK, mockVMwareConnectionResponses())
|
|
return
|
|
}
|
|
|
|
persistence := h.persistenceForRequest(w, r.Context())
|
|
if persistence == nil {
|
|
return
|
|
}
|
|
|
|
instances, err := persistence.LoadVMwareConfig()
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
orgID := resolveTenantOrgID(r)
|
|
var summaries map[string]monitoring.VMwareConnectionSummary
|
|
if h != nil && h.getPoller != nil {
|
|
if poller := h.getPoller(r.Context()); poller != nil {
|
|
summaries = poller.ConnectionSummaries(orgID, instances)
|
|
}
|
|
}
|
|
|
|
responses := make([]vmwareConnectionResponse, 0, len(instances))
|
|
for i := range instances {
|
|
item := instances[i]
|
|
item.ApplyDefaults()
|
|
response := vmwareConnectionResponse{
|
|
VMwareVCenterInstance: item.Redacted(),
|
|
}
|
|
connID := strings.TrimSpace(item.ID)
|
|
if summary, ok := summaries[connID]; ok {
|
|
response.Poll = summary.Poll
|
|
response.Observed = summary.Observed
|
|
} else {
|
|
status := h.runtimeStatus(connID)
|
|
response.Poll = status.Poll
|
|
response.Observed = status.Observed
|
|
}
|
|
responses = append(responses, response)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, responses)
|
|
}
|
|
|
|
// HandleDelete removes a configured VMware vCenter connection by ID.
|
|
func (h *VMwareHandlers) HandleDelete(w http.ResponseWriter, r *http.Request) {
|
|
if !h.featureEnabled(w) {
|
|
return
|
|
}
|
|
if mock.IsMockEnabled() {
|
|
writeErrorResponse(w, http.StatusForbidden, "mock_mode_enabled", "Cannot modify connections in mock mode", nil)
|
|
return
|
|
}
|
|
|
|
connectionID, ok := vmwareConnectionIDFromPath(r.URL.Path)
|
|
if !ok {
|
|
writeErrorResponse(w, http.StatusBadRequest, "missing_connection_id", "Connection ID is required", nil)
|
|
return
|
|
}
|
|
|
|
persistence := h.persistenceForRequest(w, r.Context())
|
|
if persistence == nil {
|
|
return
|
|
}
|
|
|
|
instances, err := persistence.LoadVMwareConfig()
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
index := -1
|
|
for i := range instances {
|
|
if strings.TrimSpace(instances[i].ID) == connectionID {
|
|
index = i
|
|
break
|
|
}
|
|
}
|
|
if index < 0 {
|
|
writeErrorResponse(w, http.StatusNotFound, "vmware_not_found", "Connection not found", nil)
|
|
return
|
|
}
|
|
|
|
instances = append(instances[:index], instances[index+1:]...)
|
|
if err := persistence.SaveVMwareConfig(instances); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "vmware_save_failed", "Failed to save VMware configuration", map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
h.clearRuntimeStatus(connectionID)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"success": true,
|
|
"id": connectionID,
|
|
})
|
|
}
|
|
|
|
// HandleUpdate replaces a configured VMware vCenter connection by ID while
|
|
// preserving unchanged masked secrets from the stored record.
|
|
func (h *VMwareHandlers) HandleUpdate(w http.ResponseWriter, r *http.Request) {
|
|
if !h.featureEnabled(w) {
|
|
return
|
|
}
|
|
if mock.IsMockEnabled() {
|
|
writeErrorResponse(w, http.StatusForbidden, "mock_mode_enabled", "Cannot modify connections in mock mode", nil)
|
|
return
|
|
}
|
|
|
|
connectionID, ok := vmwareConnectionIDFromPath(r.URL.Path)
|
|
if !ok {
|
|
writeErrorResponse(w, http.StatusBadRequest, "missing_connection_id", "Connection ID is required", nil)
|
|
return
|
|
}
|
|
|
|
persistence := h.persistenceForRequest(w, r.Context())
|
|
if persistence == nil {
|
|
return
|
|
}
|
|
|
|
instances, err := persistence.LoadVMwareConfig()
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
index := -1
|
|
for i := range instances {
|
|
if strings.TrimSpace(instances[i].ID) == connectionID {
|
|
index = i
|
|
break
|
|
}
|
|
}
|
|
if index < 0 {
|
|
writeErrorResponse(w, http.StatusNotFound, "vmware_not_found", "Connection not found", nil)
|
|
return
|
|
}
|
|
|
|
instance, ok := decodeVMwareInstanceRequest(w, r, instances[index])
|
|
if !ok {
|
|
return
|
|
}
|
|
instance.ID = connectionID
|
|
normalizeVMwareInstance(&instance)
|
|
instance.PreserveMaskedSecrets(instances[index])
|
|
if err := instance.Validate(); err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
|
|
return
|
|
}
|
|
if h.enforceMonitoredSystemLimitReplacement(w, r, instances[index], instance) {
|
|
return
|
|
}
|
|
|
|
instances[index] = instance
|
|
if err := persistence.SaveVMwareConfig(instances); err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "vmware_save_failed", "Failed to save VMware configuration", map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, instance.Redacted())
|
|
}
|
|
|
|
// HandlePreviewConnection projects the monitored-system impact of a proposed
|
|
// VMware vCenter connection without persisting it.
|
|
func (h *VMwareHandlers) HandlePreviewConnection(w http.ResponseWriter, r *http.Request) {
|
|
if !h.featureEnabled(w) {
|
|
return
|
|
}
|
|
|
|
instance, ok := decodeVMwareInstanceRequest(w, r, config.NewVMwareVCenterInstance())
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
normalizeVMwareInstance(&instance)
|
|
if err := instance.Validate(); err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
monitor := h.monitorForRequest(r.Context())
|
|
usage := monitoredSystemUsage(monitor)
|
|
if !usage.available {
|
|
writeMonitoredSystemUsageUnavailable(w, usage.unavailableReason)
|
|
return
|
|
}
|
|
|
|
candidate := vmwareMonitoredSystemCandidate(instance)
|
|
if !candidate.CountsTowardMonitoredSystems() {
|
|
preview := unifiedresources.PreviewMonitoredSystemCandidate(usage.readState, candidate)
|
|
writeJSON(w, http.StatusOK, monitoredSystemLedgerPreviewResponse(r.Context(), false, preview).NormalizeCollections())
|
|
return
|
|
}
|
|
|
|
records, invalidConfig, err := h.previewMonitoredSystemRecords(r.Context(), instance)
|
|
if err != nil {
|
|
h.writeConnectionFailure(w, invalidConfig, err)
|
|
return
|
|
}
|
|
|
|
preview := unifiedresources.PreviewMonitoredSystemRecords(usage.readState, map[unifiedresources.DataSource][]unifiedresources.IngestRecord{
|
|
unifiedresources.SourceVMware: records,
|
|
})
|
|
if len(preview.ProjectedSystems) == 0 {
|
|
writeErrorResponse(w, http.StatusBadRequest, "validation_error", "VMware connection did not resolve to a canonical monitored system preview", nil)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, monitoredSystemLedgerPreviewResponse(r.Context(), false, preview).NormalizeCollections())
|
|
}
|
|
|
|
// HandlePreviewSavedConnection projects the monitored-system impact of editing
|
|
// one saved VMware connection using the same secret-preservation path as
|
|
// update and saved test flows.
|
|
func (h *VMwareHandlers) HandlePreviewSavedConnection(w http.ResponseWriter, r *http.Request) {
|
|
if !h.featureEnabled(w) {
|
|
return
|
|
}
|
|
|
|
connectionID, ok := vmwareConnectionIDFromPreviewPath(r.URL.Path)
|
|
if !ok {
|
|
writeErrorResponse(w, http.StatusBadRequest, "missing_connection_id", "Connection ID is required", nil)
|
|
return
|
|
}
|
|
|
|
persistence := h.persistenceForRequest(w, r.Context())
|
|
if persistence == nil {
|
|
return
|
|
}
|
|
|
|
instances, err := persistence.LoadVMwareConfig()
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
for i := range instances {
|
|
current := instances[i]
|
|
if strings.TrimSpace(current.ID) != connectionID {
|
|
continue
|
|
}
|
|
|
|
payload, hasPayload, ok := decodeOptionalVMwareInstanceRequest(w, r, current)
|
|
if !ok {
|
|
return
|
|
}
|
|
next := current
|
|
if hasPayload {
|
|
payload.ID = connectionID
|
|
normalizeVMwareInstance(&payload)
|
|
payload.PreserveMaskedSecrets(current)
|
|
if err := payload.Validate(); err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
|
|
return
|
|
}
|
|
next = payload
|
|
}
|
|
|
|
monitor := h.monitorForRequest(r.Context())
|
|
usage := monitoredSystemUsage(monitor)
|
|
if !usage.available {
|
|
writeMonitoredSystemUsageUnavailable(w, usage.unavailableReason)
|
|
return
|
|
}
|
|
|
|
candidate := vmwareMonitoredSystemCandidate(next)
|
|
replacement := unifiedresources.MonitoredSystemReplacement{
|
|
Source: unifiedresources.SourceVMware,
|
|
Selector: unifiedresources.MonitoredSystemReplacementSelector{
|
|
ResourceID: connectionID,
|
|
},
|
|
}
|
|
if !candidate.CountsTowardMonitoredSystems() {
|
|
preview := unifiedresources.PreviewMonitoredSystemCandidateReplacement(
|
|
usage.readState,
|
|
replacement,
|
|
candidate,
|
|
)
|
|
writeJSON(w, http.StatusOK, monitoredSystemLedgerPreviewResponse(r.Context(), true, preview).NormalizeCollections())
|
|
return
|
|
}
|
|
|
|
records, invalidConfig, err := h.previewMonitoredSystemRecords(r.Context(), next)
|
|
if err != nil {
|
|
h.writeConnectionFailure(w, invalidConfig, err)
|
|
return
|
|
}
|
|
|
|
preview := unifiedresources.PreviewMonitoredSystemRecordsReplacement(
|
|
usage.readState,
|
|
replacement,
|
|
map[unifiedresources.DataSource][]unifiedresources.IngestRecord{
|
|
unifiedresources.SourceVMware: records,
|
|
},
|
|
)
|
|
if len(preview.ProjectedSystems) == 0 {
|
|
writeErrorResponse(w, http.StatusBadRequest, "validation_error", "VMware connection did not resolve to a canonical monitored system preview", nil)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, monitoredSystemLedgerPreviewResponse(r.Context(), true, preview).NormalizeCollections())
|
|
return
|
|
}
|
|
|
|
writeErrorResponse(w, http.StatusNotFound, "vmware_not_found", "Connection not found", nil)
|
|
}
|
|
|
|
// HandleTestConnection validates connectivity for a proposed VMware vCenter connection.
|
|
func (h *VMwareHandlers) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
|
|
if !h.featureEnabled(w) {
|
|
return
|
|
}
|
|
|
|
instance, ok := decodeVMwareInstanceRequest(w, r, config.NewVMwareVCenterInstance())
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
normalizeVMwareInstance(&instance)
|
|
if err := instance.Validate(); err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
|
|
return
|
|
}
|
|
|
|
h.writeConnectionTestResult(w, r, instance)
|
|
}
|
|
|
|
// HandleTestSavedConnection validates connectivity for one saved VMware
|
|
// connection using the server-owned stored secret material instead of relying
|
|
// on frontend redaction placeholders.
|
|
func (h *VMwareHandlers) HandleTestSavedConnection(w http.ResponseWriter, r *http.Request) {
|
|
if !h.featureEnabled(w) {
|
|
return
|
|
}
|
|
|
|
connectionID, ok := vmwareConnectionIDFromTestPath(r.URL.Path)
|
|
if !ok {
|
|
writeErrorResponse(w, http.StatusBadRequest, "missing_connection_id", "Connection ID is required", nil)
|
|
return
|
|
}
|
|
|
|
persistence := h.persistenceForRequest(w, r.Context())
|
|
if persistence == nil {
|
|
return
|
|
}
|
|
|
|
instances, err := persistence.LoadVMwareConfig()
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
for i := range instances {
|
|
instance := instances[i]
|
|
if strings.TrimSpace(instance.ID) != connectionID {
|
|
continue
|
|
}
|
|
normalizeVMwareInstance(&instance)
|
|
payload, hasPayload, ok := decodeOptionalVMwareInstanceRequest(w, r, instance)
|
|
if !ok {
|
|
return
|
|
}
|
|
if hasPayload {
|
|
payload.ID = connectionID
|
|
normalizeVMwareInstance(&payload)
|
|
payload.PreserveMaskedSecrets(instance)
|
|
if err := payload.Validate(); err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
|
|
return
|
|
}
|
|
instance = payload
|
|
}
|
|
|
|
summary, invalidConfig, err := h.testConnectionInstance(r, instance)
|
|
if err != nil {
|
|
if !hasPayload {
|
|
at := time.Now().UTC()
|
|
if poller := h.pollerForRequest(r.Context()); poller != nil {
|
|
poller.RecordConnectionTestFailure(resolveTenantOrgID(r), connectionID, err, at)
|
|
} else {
|
|
h.recordTestFailure(connectionID, err, at)
|
|
}
|
|
}
|
|
h.writeConnectionFailure(w, invalidConfig, err)
|
|
return
|
|
}
|
|
if !hasPayload {
|
|
at := time.Now().UTC()
|
|
if poller := h.pollerForRequest(r.Context()); poller != nil {
|
|
poller.RecordConnectionTestSuccess(resolveTenantOrgID(r), connectionID, summary, at)
|
|
} else {
|
|
h.recordTestSuccess(connectionID, summary, at)
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"success": true})
|
|
return
|
|
}
|
|
|
|
writeErrorResponse(w, http.StatusNotFound, "vmware_not_found", "Connection not found", nil)
|
|
}
|
|
|
|
func (h *VMwareHandlers) writeConnectionTestResult(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
instance config.VMwareVCenterInstance,
|
|
) {
|
|
_, invalidConfig, err := h.testConnectionInstance(r, instance)
|
|
if err != nil {
|
|
h.writeConnectionFailure(w, invalidConfig, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"success": true})
|
|
}
|
|
|
|
func (h *VMwareHandlers) writeConnectionFailure(w http.ResponseWriter, invalidConfig bool, err error) {
|
|
if invalidConfig {
|
|
writeErrorResponse(w, http.StatusBadRequest, "vmware_invalid_config", "Invalid VMware vCenter connection configuration", connectionFailureDetails(err))
|
|
return
|
|
}
|
|
writeErrorResponse(w, http.StatusBadRequest, "vmware_connection_failed", "Failed to connect to VMware vCenter", connectionFailureDetails(err))
|
|
}
|
|
|
|
func connectionFailureDetails(err error) map[string]string {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
details := map[string]string{"error": err.Error()}
|
|
if category := connectionErrorCategory(err); category != "" {
|
|
details["category"] = category
|
|
}
|
|
return details
|
|
}
|
|
|
|
func connectionErrorCategory(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
if connectionErr, ok := err.(*vmware.ConnectionError); ok {
|
|
return strings.TrimSpace(connectionErr.Category)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (h *VMwareHandlers) testConnectionInstance(
|
|
r *http.Request,
|
|
instance config.VMwareVCenterInstance,
|
|
) (*vmware.InventorySummary, bool, error) {
|
|
newClient := h.newClient
|
|
if newClient == nil {
|
|
newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) { return vmware.NewClient(cfg) }
|
|
}
|
|
|
|
client, err := newClient(vmware.ClientConfig{
|
|
Host: instance.Host,
|
|
Port: instance.Port,
|
|
Username: instance.Username,
|
|
Password: instance.Password,
|
|
InsecureSkipVerify: instance.InsecureSkipVerify,
|
|
Timeout: 10 * time.Second,
|
|
})
|
|
if err != nil {
|
|
return nil, true, err
|
|
}
|
|
defer client.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
summary, err := client.TestConnection(ctx)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return summary, false, nil
|
|
}
|
|
|
|
func normalizeVMwareInstance(instance *config.VMwareVCenterInstance) {
|
|
if instance == nil {
|
|
return
|
|
}
|
|
instance.Name = strings.TrimSpace(instance.Name)
|
|
instance.Host = strings.TrimSpace(instance.Host)
|
|
instance.Username = strings.TrimSpace(instance.Username)
|
|
instance.Password = strings.TrimSpace(instance.Password)
|
|
instance.ApplyDefaults()
|
|
}
|
|
|
|
func decodeVMwareInstanceRequest(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
base config.VMwareVCenterInstance,
|
|
) (config.VMwareVCenterInstance, bool) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
|
|
defer r.Body.Close()
|
|
|
|
instance := base
|
|
if err := json.NewDecoder(r.Body).Decode(&instance); err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()})
|
|
return config.VMwareVCenterInstance{}, false
|
|
}
|
|
|
|
return instance, true
|
|
}
|
|
|
|
func decodeOptionalVMwareInstanceRequest(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
base config.VMwareVCenterInstance,
|
|
) (config.VMwareVCenterInstance, bool, bool) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
|
|
defer r.Body.Close()
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()})
|
|
return config.VMwareVCenterInstance{}, false, false
|
|
}
|
|
if len(bytes.TrimSpace(body)) == 0 {
|
|
return base, false, true
|
|
}
|
|
|
|
instance := base
|
|
if err := json.Unmarshal(body, &instance); err != nil {
|
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()})
|
|
return config.VMwareVCenterInstance{}, false, false
|
|
}
|
|
|
|
return instance, true, true
|
|
}
|
|
|
|
func (h *VMwareHandlers) featureEnabled(w http.ResponseWriter) bool {
|
|
if vmware.IsFeatureEnabled() {
|
|
return true
|
|
}
|
|
writeErrorResponse(w, http.StatusNotFound, "vmware_disabled", "VMware integration has been explicitly disabled", nil)
|
|
return false
|
|
}
|
|
|
|
func (h *VMwareHandlers) persistenceForRequest(w http.ResponseWriter, ctx context.Context) *config.ConfigPersistence {
|
|
if h == nil || h.getPersistence == nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "vmware_unavailable", "VMware service unavailable", nil)
|
|
return nil
|
|
}
|
|
persistence := h.getPersistence(ctx)
|
|
if persistence == nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "vmware_unavailable", "VMware service unavailable", nil)
|
|
return nil
|
|
}
|
|
return persistence
|
|
}
|
|
|
|
func (h *VMwareHandlers) pollerForRequest(ctx context.Context) *monitoring.VMwarePoller {
|
|
if h == nil || h.getPoller == nil {
|
|
return nil
|
|
}
|
|
return h.getPoller(ctx)
|
|
}
|
|
|
|
func (h *VMwareHandlers) monitorForRequest(ctx context.Context) *monitoring.Monitor {
|
|
if h == nil || h.getMonitor == nil {
|
|
return nil
|
|
}
|
|
return h.getMonitor(ctx)
|
|
}
|
|
|
|
func (h *VMwareHandlers) enforceMonitoredSystemLimit(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
instance config.VMwareVCenterInstance,
|
|
) bool {
|
|
candidate := vmwareMonitoredSystemCandidate(instance)
|
|
if !candidate.CountsTowardMonitoredSystems() {
|
|
return false
|
|
}
|
|
|
|
limit := maxMonitoredSystemsLimitForContext(r.Context())
|
|
if limit <= 0 {
|
|
return false
|
|
}
|
|
|
|
monitor := h.monitorForRequest(r.Context())
|
|
usage := monitoredSystemUsage(monitor)
|
|
if !usage.available {
|
|
writeMonitoredSystemUsageUnavailable(w, usage.unavailableReason)
|
|
return true
|
|
}
|
|
|
|
records, invalidConfig, err := h.previewMonitoredSystemRecords(r.Context(), instance)
|
|
if err != nil {
|
|
h.writeConnectionFailure(w, invalidConfig, err)
|
|
return true
|
|
}
|
|
|
|
decision := monitoredSystemLimitDecisionForRecordsFromUsage(r.Context(), limit, usage, map[unifiedresources.DataSource][]unifiedresources.IngestRecord{
|
|
unifiedresources.SourceVMware: records,
|
|
})
|
|
if !decision.usageAvailable {
|
|
writeMonitoredSystemUsageUnavailable(w, decision.usageUnavailableReason)
|
|
return true
|
|
}
|
|
if !decision.exceeded {
|
|
return false
|
|
}
|
|
|
|
emitLimitBlockedEvent(r.Context(), decision.current, decision.limit)
|
|
writeMaxMonitoredSystemsLimitExceeded(w, decision)
|
|
return true
|
|
}
|
|
|
|
func (h *VMwareHandlers) enforceMonitoredSystemLimitReplacement(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
current config.VMwareVCenterInstance,
|
|
next config.VMwareVCenterInstance,
|
|
) bool {
|
|
candidate := vmwareMonitoredSystemCandidate(next)
|
|
if !candidate.CountsTowardMonitoredSystems() {
|
|
return false
|
|
}
|
|
|
|
limit := maxMonitoredSystemsLimitForContext(r.Context())
|
|
if limit <= 0 {
|
|
return false
|
|
}
|
|
|
|
monitor := h.monitorForRequest(r.Context())
|
|
usage := monitoredSystemUsage(monitor)
|
|
if !usage.available {
|
|
writeMonitoredSystemUsageUnavailable(w, usage.unavailableReason)
|
|
return true
|
|
}
|
|
|
|
records, invalidConfig, err := h.previewMonitoredSystemRecords(r.Context(), next)
|
|
if err != nil {
|
|
h.writeConnectionFailure(w, invalidConfig, err)
|
|
return true
|
|
}
|
|
|
|
replacementID := strings.TrimSpace(current.ID)
|
|
decision := monitoredSystemLimitDecisionForRecordsReplacementFromUsage(r.Context(), limit, usage, unifiedresources.MonitoredSystemReplacement{
|
|
Source: unifiedresources.SourceVMware,
|
|
Selector: unifiedresources.MonitoredSystemReplacementSelector{
|
|
ResourceID: replacementID,
|
|
},
|
|
}, map[unifiedresources.DataSource][]unifiedresources.IngestRecord{
|
|
unifiedresources.SourceVMware: records,
|
|
})
|
|
if !decision.usageAvailable {
|
|
writeMonitoredSystemUsageUnavailable(w, decision.usageUnavailableReason)
|
|
return true
|
|
}
|
|
if !decision.exceeded {
|
|
return false
|
|
}
|
|
|
|
emitLimitBlockedEvent(r.Context(), decision.current, decision.limit)
|
|
writeMaxMonitoredSystemsLimitExceeded(w, decision)
|
|
return true
|
|
}
|
|
|
|
func (h *VMwareHandlers) previewMonitoredSystemRecords(
|
|
ctx context.Context,
|
|
instance config.VMwareVCenterInstance,
|
|
) ([]unifiedresources.IngestRecord, bool, error) {
|
|
if h != nil && h.previewRecords != nil {
|
|
records, err := h.previewRecords(ctx, instance)
|
|
return records, false, err
|
|
}
|
|
|
|
client, err := vmware.NewClient(vmware.ClientConfig{
|
|
Host: instance.Host,
|
|
Port: instance.Port,
|
|
Username: instance.Username,
|
|
Password: instance.Password,
|
|
InsecureSkipVerify: instance.InsecureSkipVerify,
|
|
Timeout: 20 * time.Second,
|
|
})
|
|
if err != nil {
|
|
return nil, true, err
|
|
}
|
|
|
|
provider := vmware.NewAPIProvider(vmware.ProviderMetadata{
|
|
ConnectionID: strings.TrimSpace(instance.ID),
|
|
ConnectionName: strings.TrimSpace(instance.Name),
|
|
VCenterHost: strings.TrimSpace(instance.Host),
|
|
}, client)
|
|
defer provider.Close()
|
|
|
|
refreshCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
|
defer cancel()
|
|
|
|
if err := provider.Refresh(refreshCtx); err != nil {
|
|
return nil, false, err
|
|
}
|
|
return provider.Records(), false, nil
|
|
}
|
|
|
|
func vmwareMonitoredSystemCandidate(
|
|
instance config.VMwareVCenterInstance,
|
|
) unifiedresources.MonitoredSystemCandidate {
|
|
return unifiedresources.MonitoredSystemCandidate{
|
|
Source: unifiedresources.SourceVMware,
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Name: strings.TrimSpace(instance.Name),
|
|
Hostname: pulseTokenHostCandidate(instance.Host),
|
|
HostURL: strings.TrimSpace(instance.Host),
|
|
ResourceID: strings.TrimSpace(instance.ID),
|
|
State: monitoredSystemCandidateStateFromEnabled(instance.Enabled),
|
|
}
|
|
}
|
|
|
|
func (h *VMwareHandlers) runtimeStatus(connectionID string) vmwareConnectionRuntimeStatus {
|
|
if h == nil {
|
|
return vmwareConnectionRuntimeStatus{}
|
|
}
|
|
h.statusMu.RLock()
|
|
defer h.statusMu.RUnlock()
|
|
if h.statuses == nil {
|
|
return vmwareConnectionRuntimeStatus{}
|
|
}
|
|
status, ok := h.statuses[connectionID]
|
|
if !ok {
|
|
return vmwareConnectionRuntimeStatus{}
|
|
}
|
|
return cloneVMwareRuntimeStatus(status)
|
|
}
|
|
|
|
func (h *VMwareHandlers) recordTestSuccess(connectionID string, summary *vmware.InventorySummary, at time.Time) {
|
|
if h == nil {
|
|
return
|
|
}
|
|
h.statusMu.Lock()
|
|
defer h.statusMu.Unlock()
|
|
if h.statuses == nil {
|
|
h.statuses = make(map[string]vmwareConnectionRuntimeStatus)
|
|
}
|
|
current := h.statuses[connectionID]
|
|
current.Poll = &monitoring.VMwareConnectionPollStatus{
|
|
IntervalSeconds: defaultVMwarePollIntervalSeconds(),
|
|
LastAttemptAt: timePointer(at),
|
|
LastSuccessAt: timePointer(at),
|
|
}
|
|
if summary != nil {
|
|
current.Observed = &monitoring.VMwareConnectionObservedSummary{
|
|
CollectedAt: timePointer(at),
|
|
Hosts: summary.Hosts,
|
|
VMs: summary.VMs,
|
|
Datastores: summary.Datastores,
|
|
VIRelease: strings.TrimSpace(summary.VIRelease),
|
|
}
|
|
}
|
|
h.statuses[connectionID] = current
|
|
}
|
|
|
|
func (h *VMwareHandlers) recordTestFailure(connectionID string, err error, at time.Time) {
|
|
if h == nil {
|
|
return
|
|
}
|
|
h.statusMu.Lock()
|
|
defer h.statusMu.Unlock()
|
|
if h.statuses == nil {
|
|
h.statuses = make(map[string]vmwareConnectionRuntimeStatus)
|
|
}
|
|
current := h.statuses[connectionID]
|
|
poll := current.Poll
|
|
if poll == nil {
|
|
poll = &monitoring.VMwareConnectionPollStatus{
|
|
IntervalSeconds: defaultVMwarePollIntervalSeconds(),
|
|
}
|
|
}
|
|
poll.LastAttemptAt = timePointer(at)
|
|
poll.LastError = &monitoring.VMwareConnectionPollError{
|
|
At: timePointer(at),
|
|
Message: err.Error(),
|
|
Category: connectionErrorCategory(err),
|
|
}
|
|
current.Poll = poll
|
|
h.statuses[connectionID] = current
|
|
}
|
|
|
|
func (h *VMwareHandlers) clearRuntimeStatus(connectionID string) {
|
|
if h == nil {
|
|
return
|
|
}
|
|
h.statusMu.Lock()
|
|
defer h.statusMu.Unlock()
|
|
if h.statuses == nil {
|
|
return
|
|
}
|
|
delete(h.statuses, connectionID)
|
|
}
|
|
|
|
func cloneVMwareRuntimeStatus(status vmwareConnectionRuntimeStatus) vmwareConnectionRuntimeStatus {
|
|
cloned := vmwareConnectionRuntimeStatus{}
|
|
if status.Poll != nil {
|
|
poll := *status.Poll
|
|
if poll.LastAttemptAt != nil {
|
|
poll.LastAttemptAt = timePointer(*poll.LastAttemptAt)
|
|
}
|
|
if poll.LastSuccessAt != nil {
|
|
poll.LastSuccessAt = timePointer(*poll.LastSuccessAt)
|
|
}
|
|
if poll.LastError != nil {
|
|
errCopy := *poll.LastError
|
|
if errCopy.At != nil {
|
|
errCopy.At = timePointer(*errCopy.At)
|
|
}
|
|
poll.LastError = &errCopy
|
|
}
|
|
cloned.Poll = &poll
|
|
}
|
|
if status.Observed != nil {
|
|
observed := *status.Observed
|
|
if observed.CollectedAt != nil {
|
|
observed.CollectedAt = timePointer(*observed.CollectedAt)
|
|
}
|
|
cloned.Observed = &observed
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func defaultVMwarePollIntervalSeconds() int {
|
|
return int((60 * time.Second) / time.Second)
|
|
}
|
|
|
|
func timePointer(value time.Time) *time.Time {
|
|
v := value
|
|
return &v
|
|
}
|
|
|
|
func vmwareConnectionIDFromPath(path string) (string, bool) {
|
|
if !strings.HasPrefix(path, vmwareConnectionsPathPrefix) {
|
|
return "", false
|
|
}
|
|
|
|
raw := strings.Trim(strings.TrimPrefix(path, vmwareConnectionsPathPrefix), "/")
|
|
if raw == "" || strings.Contains(raw, "/") {
|
|
return "", false
|
|
}
|
|
|
|
connectionID, err := url.PathUnescape(raw)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
connectionID = strings.TrimSpace(connectionID)
|
|
if connectionID == "" || strings.Contains(connectionID, "/") {
|
|
return "", false
|
|
}
|
|
return connectionID, true
|
|
}
|
|
|
|
func vmwareConnectionIDFromTestPath(path string) (string, bool) {
|
|
return vmwareConnectionIDFromActionPath(path, "/test")
|
|
}
|
|
|
|
func vmwareConnectionIDFromPreviewPath(path string) (string, bool) {
|
|
return vmwareConnectionIDFromActionPath(path, "/preview")
|
|
}
|
|
|
|
func vmwareConnectionIDFromActionPath(path string, suffix string) (string, bool) {
|
|
if !strings.HasPrefix(path, vmwareConnectionsPathPrefix) {
|
|
return "", false
|
|
}
|
|
|
|
raw := strings.Trim(strings.TrimPrefix(path, vmwareConnectionsPathPrefix), "/")
|
|
if !strings.HasSuffix(raw, suffix) {
|
|
return "", false
|
|
}
|
|
raw = strings.TrimSuffix(raw, suffix)
|
|
if raw == "" || strings.Contains(raw, "/") {
|
|
return "", false
|
|
}
|
|
|
|
connectionID, err := url.PathUnescape(raw)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
connectionID = strings.TrimSpace(connectionID)
|
|
if connectionID == "" || strings.Contains(connectionID, "/") {
|
|
return "", false
|
|
}
|
|
return connectionID, true
|
|
}
|