From 9f722a442a5a59fdd54d6f9753afcfd7d0a565aa Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 10 Jun 2026 09:49:03 +0100 Subject: [PATCH] Dedupe alerts PMG queue checks, vmware clones, licensing and proxmox client clones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clears the dupl pairs outside internal/api: - internal/alerts/pmg.go: the total/deferred/hold per-node queue checks share evaluatePMGNodeQueueAlert; the historical short-circuit (a below-threshold clear or invalid spec skips the node's remaining checks) is preserved via the helper's skip-node return. - internal/vmware/provider.go: the cloneInventory* family rides generic cloneSliceWith / cloneShallowSlice helpers instead of fourteen copies of the same nil/make/loop scaffold. - internal/vmware/client.go + client_signals.go: the byte-identical Automation and VI/JSON fetchers delegate to one getSessionScopedJSON. - internal/vmware/fixtures.go: added to the dupl exclude list in .golangci.yml — literal mock fixture catalogs are the same category as the existing internal/mock/ exclusion. - pkg/licensing/license_server_client.go: Activate and ExchangeLegacyLicense share postActivationRequest (idempotent POST + shared activation response decode). - pkg/proxmox/client.go: LXC/VM RRD fetches share getGuestRRDData. - pkg/pulsecli/actions.go is intentionally left for the api-contracts slice. Full test suites pass for internal/alerts, internal/vmware, pkg/licensing, pkg/proxmox. --- .golangci.yml | 4 + internal/alerts/pmg.go | 186 +++++++------------ internal/vmware/client.go | 15 +- internal/vmware/client_signals.go | 28 +-- internal/vmware/provider.go | 243 ++++++++++--------------- pkg/licensing/license_server_client.go | 53 ++---- pkg/proxmox/client.go | 46 +---- 7 files changed, 199 insertions(+), 376 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 79f1144ee..02b15b63e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -19,6 +19,10 @@ issues: path: "_test\\.go$" - linters: [dupl] path: "internal/mock/" + # Platform mock fixture catalogs outside internal/mock/ are the same + # category: explicit literal fixture records that deliberately share shape. + - linters: [dupl] + path: "internal/vmware/fixtures\\.go$" linters-settings: gofmt: simplify: true diff --git a/internal/alerts/pmg.go b/internal/alerts/pmg.go index fbb5267cf..e062b6ad8 100644 --- a/internal/alerts/pmg.go +++ b/internal/alerts/pmg.go @@ -398,6 +398,61 @@ func (m *Manager) checkPMGOldestMessage(pmg models.PMGInstance, defaults PMGThre } } +// evaluatePMGNodeQueueAlert runs one per-node PMG queue threshold check +// (total / deferred / hold share this shape). messageNoun is the operator +// phrasing ("total messages in queue", "deferred messages", "held messages") +// and specLabel feeds the invalid-spec log line. It returns true when the +// caller must skip the node's remaining checks — either the below-threshold +// clear or an invalid spec — preserving the historical per-node +// short-circuit semantics. +func (m *Manager) evaluatePMGNodeQueueAlert(pmg models.PMGInstance, nodeName, metric, specLabel, messageNoun string, observed, scaledWarn, scaledCrit, median int) bool { + if scaledWarn <= 0 && scaledCrit <= 0 { + return false + } + + alertID := fmt.Sprintf("%s-%s-%s", pmg.ID, nodeName, metric) + if (scaledCrit <= 0 || observed < scaledCrit) && (scaledWarn <= 0 || observed < scaledWarn) { + m.clearAlert(buildCanonicalStateID(pmg.ID, alertID)) + return true + } + + // Add outlier indicator to message if applicable + isOutlier := isQueueOutlier(observed, median) + outlierNote := "" + if isOutlier { + outlierNote = ", outlier" + } + + spec, err := buildCanonicalSeverityThresholdSpec(alertID, pmg.ID, pmg.Name, unifiedresources.ResourceTypePMG, metric, float64(scaledWarn), float64(scaledCrit), false) + if err != nil { + log.Warn().Err(err).Str("pmg", pmg.Name).Str("alertID", alertID).Msg("Skipping invalid canonical PMG node " + specLabel + " spec") + return true + } + _, _ = m.evaluateCanonicalStatefulAlert(canonicalStatefulAlertParams{ + Spec: spec, + Evidence: alertspecs.AlertEvidence{ + ObservedAt: time.Now(), + SeverityThreshold: &alertspecs.SeverityThresholdEvidence{ + Metric: metric, + Direction: alertspecs.ThresholdDirectionAbove, + Observed: float64(observed), + }, + }, + AlertID: alertID, + AlertType: metric, + ResourceID: pmg.ID, + ResourceName: pmg.Name, + Node: nodeName, + Instance: pmg.Name, + MessageBuilder: func(result alertspecs.EvaluationResult) (string, float64, float64) { + currentThreshold := thresholdForCanonicalSeverity(result.State.Severity, float64(scaledWarn), float64(scaledCrit)) + return fmt.Sprintf("PMG node %s on %s has %d %s (threshold: %d%s)", nodeName, pmg.Name, observed, messageNoun, int(currentThreshold), outlierNote), float64(observed), currentThreshold + }, + DispatchAsync: true, + }) + return false +} + // checkPMGNodeQueues checks individual PMG node queue health // Uses scaled thresholds (60% warn, 80% crit) and outlier detection func (m *Manager) checkPMGNodeQueues(pmg models.PMGInstance, defaults PMGThresholdConfig) { @@ -439,137 +494,18 @@ func (m *Manager) checkPMGNodeQueues(pmg models.PMGInstance, defaults PMGThresho } // Check total queue - always check thresholds - if scaledQueueWarn > 0 || scaledQueueCrit > 0 { - total := node.QueueStatus.Total - alertID := fmt.Sprintf("%s-%s-queue-total", pmg.ID, node.Name) - if (scaledQueueCrit <= 0 || total < scaledQueueCrit) && (scaledQueueWarn <= 0 || total < scaledQueueWarn) { - m.clearAlert(buildCanonicalStateID(pmg.ID, alertID)) - continue - } - - isOutlier := isQueueOutlier(total, medianTotal) - outlierNote := "" - if isOutlier { - outlierNote = ", outlier" - } - - spec, err := buildCanonicalSeverityThresholdSpec(alertID, pmg.ID, pmg.Name, unifiedresources.ResourceTypePMG, "queue-total", float64(scaledQueueWarn), float64(scaledQueueCrit), false) - if err != nil { - log.Warn().Err(err).Str("pmg", pmg.Name).Str("alertID", alertID).Msg("Skipping invalid canonical PMG node queue spec") - continue - } - _, _ = m.evaluateCanonicalStatefulAlert(canonicalStatefulAlertParams{ - Spec: spec, - Evidence: alertspecs.AlertEvidence{ - ObservedAt: time.Now(), - SeverityThreshold: &alertspecs.SeverityThresholdEvidence{ - Metric: "queue-total", - Direction: alertspecs.ThresholdDirectionAbove, - Observed: float64(total), - }, - }, - AlertID: alertID, - AlertType: "queue-total", - ResourceID: pmg.ID, - ResourceName: pmg.Name, - Node: node.Name, - Instance: pmg.Name, - MessageBuilder: func(result alertspecs.EvaluationResult) (string, float64, float64) { - currentThreshold := thresholdForCanonicalSeverity(result.State.Severity, float64(scaledQueueWarn), float64(scaledQueueCrit)) - return fmt.Sprintf("PMG node %s on %s has %d total messages in queue (threshold: %d%s)", node.Name, pmg.Name, total, int(currentThreshold), outlierNote), float64(total), currentThreshold - }, - DispatchAsync: true, - }) + if m.evaluatePMGNodeQueueAlert(pmg, node.Name, "queue-total", "queue", "total messages in queue", node.QueueStatus.Total, scaledQueueWarn, scaledQueueCrit, medianTotal) { + continue } // Check deferred queue - always check thresholds - if scaledDeferredWarn > 0 || scaledDeferredCrit > 0 { - deferred := node.QueueStatus.Deferred - alertID := fmt.Sprintf("%s-%s-queue-deferred", pmg.ID, node.Name) - if (scaledDeferredCrit <= 0 || deferred < scaledDeferredCrit) && (scaledDeferredWarn <= 0 || deferred < scaledDeferredWarn) { - m.clearAlert(buildCanonicalStateID(pmg.ID, alertID)) - continue - } - - // Add outlier indicator to message if applicable - isOutlier := isQueueOutlier(deferred, medianDeferred) - outlierNote := "" - if isOutlier { - outlierNote = ", outlier" - } - - spec, err := buildCanonicalSeverityThresholdSpec(alertID, pmg.ID, pmg.Name, unifiedresources.ResourceTypePMG, "queue-deferred", float64(scaledDeferredWarn), float64(scaledDeferredCrit), false) - if err != nil { - log.Warn().Err(err).Str("pmg", pmg.Name).Str("alertID", alertID).Msg("Skipping invalid canonical PMG node deferred queue spec") - continue - } - _, _ = m.evaluateCanonicalStatefulAlert(canonicalStatefulAlertParams{ - Spec: spec, - Evidence: alertspecs.AlertEvidence{ - ObservedAt: time.Now(), - SeverityThreshold: &alertspecs.SeverityThresholdEvidence{ - Metric: "queue-deferred", - Direction: alertspecs.ThresholdDirectionAbove, - Observed: float64(deferred), - }, - }, - AlertID: alertID, - AlertType: "queue-deferred", - ResourceID: pmg.ID, - ResourceName: pmg.Name, - Node: node.Name, - Instance: pmg.Name, - MessageBuilder: func(result alertspecs.EvaluationResult) (string, float64, float64) { - currentThreshold := thresholdForCanonicalSeverity(result.State.Severity, float64(scaledDeferredWarn), float64(scaledDeferredCrit)) - return fmt.Sprintf("PMG node %s on %s has %d deferred messages (threshold: %d%s)", node.Name, pmg.Name, deferred, int(currentThreshold), outlierNote), float64(deferred), currentThreshold - }, - DispatchAsync: true, - }) + if m.evaluatePMGNodeQueueAlert(pmg, node.Name, "queue-deferred", "deferred queue", "deferred messages", node.QueueStatus.Deferred, scaledDeferredWarn, scaledDeferredCrit, medianDeferred) { + continue } // Check hold queue - always check thresholds - if scaledHoldWarn > 0 || scaledHoldCrit > 0 { - hold := node.QueueStatus.Hold - alertID := fmt.Sprintf("%s-%s-queue-hold", pmg.ID, node.Name) - if (scaledHoldCrit <= 0 || hold < scaledHoldCrit) && (scaledHoldWarn <= 0 || hold < scaledHoldWarn) { - m.clearAlert(buildCanonicalStateID(pmg.ID, alertID)) - continue - } - - // Add outlier indicator to message if applicable - isOutlier := isQueueOutlier(hold, medianHold) - outlierNote := "" - if isOutlier { - outlierNote = ", outlier" - } - - spec, err := buildCanonicalSeverityThresholdSpec(alertID, pmg.ID, pmg.Name, unifiedresources.ResourceTypePMG, "queue-hold", float64(scaledHoldWarn), float64(scaledHoldCrit), false) - if err != nil { - log.Warn().Err(err).Str("pmg", pmg.Name).Str("alertID", alertID).Msg("Skipping invalid canonical PMG node hold queue spec") - continue - } - _, _ = m.evaluateCanonicalStatefulAlert(canonicalStatefulAlertParams{ - Spec: spec, - Evidence: alertspecs.AlertEvidence{ - ObservedAt: time.Now(), - SeverityThreshold: &alertspecs.SeverityThresholdEvidence{ - Metric: "queue-hold", - Direction: alertspecs.ThresholdDirectionAbove, - Observed: float64(hold), - }, - }, - AlertID: alertID, - AlertType: "queue-hold", - ResourceID: pmg.ID, - ResourceName: pmg.Name, - Node: node.Name, - Instance: pmg.Name, - MessageBuilder: func(result alertspecs.EvaluationResult) (string, float64, float64) { - currentThreshold := thresholdForCanonicalSeverity(result.State.Severity, float64(scaledHoldWarn), float64(scaledHoldCrit)) - return fmt.Sprintf("PMG node %s on %s has %d held messages (threshold: %d%s)", node.Name, pmg.Name, hold, int(currentThreshold), outlierNote), float64(hold), currentThreshold - }, - DispatchAsync: true, - }) + if m.evaluatePMGNodeQueueAlert(pmg, node.Name, "queue-hold", "hold queue", "held messages", node.QueueStatus.Hold, scaledHoldWarn, scaledHoldCrit, medianHold) { + continue } // Check oldest message age per node diff --git a/internal/vmware/client.go b/internal/vmware/client.go index fe13ee3f1..ec58ddd19 100644 --- a/internal/vmware/client.go +++ b/internal/vmware/client.go @@ -327,7 +327,10 @@ func (c *Client) listAutomationResources( return c.getAutomationJSON(ctx, sessionID, path, label, target) } -func (c *Client) getAutomationJSON( +// 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, @@ -360,6 +363,16 @@ func (c *Client) getAutomationJSON( 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 { diff --git a/internal/vmware/client_signals.go b/internal/vmware/client_signals.go index 3c8eb8626..6a8162981 100644 --- a/internal/vmware/client_signals.go +++ b/internal/vmware/client_signals.go @@ -2,10 +2,7 @@ package vmware import ( "context" - "encoding/json" "fmt" - "io" - "net/http" "sort" "strings" "sync" @@ -588,30 +585,7 @@ func (c *Client) resolveAlarmName(ctx context.Context, release, sessionID, alarm } func (c *Client) getVIJSONJSON(ctx context.Context, sessionID, path, 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 + return c.getSessionScopedJSON(ctx, sessionID, path, label, target) } func isVIJSONNotFound(err error) bool { diff --git a/internal/vmware/provider.go b/internal/vmware/provider.go index c13d1b6b4..240f9a512 100644 --- a/internal/vmware/provider.go +++ b/internal/vmware/provider.go @@ -828,171 +828,132 @@ func cloneInventorySnapshot(in *InventorySnapshot) *InventorySnapshot { return &out } -func cloneInventoryHosts(in []InventoryHost) []InventoryHost { +// cloneSliceWith deep-copies a slice, applying fix to each copied element so +// it can re-clone its pointer- and slice-typed fields. +func cloneSliceWith[T any](in []T, fix func(*T)) []T { if in == nil { return nil } - out := make([]InventoryHost, len(in)) + out := make([]T, len(in)) for i := range in { out[i] = in[i] - out[i].ClusterHAEnabled = cloneBoolPointer(in[i].ClusterHAEnabled) - out[i].ClusterDRSEnabled = cloneBoolPointer(in[i].ClusterDRSEnabled) - out[i].DatastoreIDs = cloneStringSlice(in[i].DatastoreIDs) - out[i].DatastoreNames = cloneStringSlice(in[i].DatastoreNames) - out[i].TriggeredAlarms = cloneInventoryAlarms(in[i].TriggeredAlarms) - out[i].RecentTasks = cloneInventoryTasks(in[i].RecentTasks) - out[i].RecentEvents = cloneInventoryEvents(in[i].RecentEvents) - out[i].Metrics = cloneInventoryMetrics(in[i].Metrics) + fix(&out[i]) } return out } +// cloneShallowSlice copies a slice whose elements carry no shared references. +func cloneShallowSlice[T any](in []T) []T { + if in == nil { + return nil + } + out := make([]T, len(in)) + copy(out, in) + return out +} + +func cloneInventoryHosts(in []InventoryHost) []InventoryHost { + return cloneSliceWith(in, func(item *InventoryHost) { + item.ClusterHAEnabled = cloneBoolPointer(item.ClusterHAEnabled) + item.ClusterDRSEnabled = cloneBoolPointer(item.ClusterDRSEnabled) + item.DatastoreIDs = cloneStringSlice(item.DatastoreIDs) + item.DatastoreNames = cloneStringSlice(item.DatastoreNames) + item.TriggeredAlarms = cloneInventoryAlarms(item.TriggeredAlarms) + item.RecentTasks = cloneInventoryTasks(item.RecentTasks) + item.RecentEvents = cloneInventoryEvents(item.RecentEvents) + item.Metrics = cloneInventoryMetrics(item.Metrics) + }) +} + func cloneInventoryVMs(in []InventoryVM) []InventoryVM { - if in == nil { - return nil - } - out := make([]InventoryVM, len(in)) - for i := range in { - out[i] = in[i] - out[i].ClusterHAEnabled = cloneBoolPointer(in[i].ClusterHAEnabled) - out[i].ClusterDRSEnabled = cloneBoolPointer(in[i].ClusterDRSEnabled) - out[i].DatastoreIDs = cloneStringSlice(in[i].DatastoreIDs) - out[i].DatastoreNames = cloneStringSlice(in[i].DatastoreNames) - out[i].GuestIPAddresses = cloneStringSlice(in[i].GuestIPAddresses) - out[i].TriggeredAlarms = cloneInventoryAlarms(in[i].TriggeredAlarms) - out[i].RecentTasks = cloneInventoryTasks(in[i].RecentTasks) - out[i].RecentEvents = cloneInventoryEvents(in[i].RecentEvents) - out[i].SnapshotTree = cloneInventoryVMSnapshots(in[i].SnapshotTree) - out[i].NetworkAdapters = cloneInventoryVMNetworkAdapters(in[i].NetworkAdapters) - out[i].VirtualDisks = cloneInventoryVMVirtualDisks(in[i].VirtualDisks) - out[i].Tools = cloneInventoryVMTools(in[i].Tools) - out[i].Hardware = cloneInventoryVMHardware(in[i].Hardware) - out[i].Metrics = cloneInventoryMetrics(in[i].Metrics) - } - return out + return cloneSliceWith(in, func(item *InventoryVM) { + item.ClusterHAEnabled = cloneBoolPointer(item.ClusterHAEnabled) + item.ClusterDRSEnabled = cloneBoolPointer(item.ClusterDRSEnabled) + item.DatastoreIDs = cloneStringSlice(item.DatastoreIDs) + item.DatastoreNames = cloneStringSlice(item.DatastoreNames) + item.GuestIPAddresses = cloneStringSlice(item.GuestIPAddresses) + item.TriggeredAlarms = cloneInventoryAlarms(item.TriggeredAlarms) + item.RecentTasks = cloneInventoryTasks(item.RecentTasks) + item.RecentEvents = cloneInventoryEvents(item.RecentEvents) + item.SnapshotTree = cloneInventoryVMSnapshots(item.SnapshotTree) + item.NetworkAdapters = cloneInventoryVMNetworkAdapters(item.NetworkAdapters) + item.VirtualDisks = cloneInventoryVMVirtualDisks(item.VirtualDisks) + item.Tools = cloneInventoryVMTools(item.Tools) + item.Hardware = cloneInventoryVMHardware(item.Hardware) + item.Metrics = cloneInventoryMetrics(item.Metrics) + }) } func cloneInventoryDatastores(in []InventoryDatastore) []InventoryDatastore { - if in == nil { - return nil - } - out := make([]InventoryDatastore, len(in)) - for i := range in { - out[i] = in[i] - out[i].HostIDs = cloneStringSlice(in[i].HostIDs) - out[i].HostNames = cloneStringSlice(in[i].HostNames) - out[i].VMIDs = cloneStringSlice(in[i].VMIDs) - out[i].VMNames = cloneStringSlice(in[i].VMNames) - out[i].Accessible = cloneBoolPointer(in[i].Accessible) - out[i].MultipleHostAccess = cloneBoolPointer(in[i].MultipleHostAccess) - out[i].TriggeredAlarms = cloneInventoryAlarms(in[i].TriggeredAlarms) - out[i].RecentTasks = cloneInventoryTasks(in[i].RecentTasks) - out[i].RecentEvents = cloneInventoryEvents(in[i].RecentEvents) - } - return out + return cloneSliceWith(in, func(item *InventoryDatastore) { + item.HostIDs = cloneStringSlice(item.HostIDs) + item.HostNames = cloneStringSlice(item.HostNames) + item.VMIDs = cloneStringSlice(item.VMIDs) + item.VMNames = cloneStringSlice(item.VMNames) + item.Accessible = cloneBoolPointer(item.Accessible) + item.MultipleHostAccess = cloneBoolPointer(item.MultipleHostAccess) + item.TriggeredAlarms = cloneInventoryAlarms(item.TriggeredAlarms) + item.RecentTasks = cloneInventoryTasks(item.RecentTasks) + item.RecentEvents = cloneInventoryEvents(item.RecentEvents) + }) } func cloneInventoryClusters(in []InventoryCluster) []InventoryCluster { - if in == nil { - return nil - } - out := make([]InventoryCluster, len(in)) - for i := range in { - out[i] = in[i] - out[i].HAEnabled = cloneBoolPointer(in[i].HAEnabled) - out[i].DRSEnabled = cloneBoolPointer(in[i].DRSEnabled) - } - return out + return cloneSliceWith(in, func(item *InventoryCluster) { + item.HAEnabled = cloneBoolPointer(item.HAEnabled) + item.DRSEnabled = cloneBoolPointer(item.DRSEnabled) + }) } func cloneInventoryNetworks(in []InventoryNetwork) []InventoryNetwork { - if in == nil { - return nil - } - out := make([]InventoryNetwork, len(in)) - for i := range in { - out[i] = in[i] - out[i].HostIDs = cloneStringSlice(in[i].HostIDs) - out[i].HostNames = cloneStringSlice(in[i].HostNames) - out[i].VMIDs = cloneStringSlice(in[i].VMIDs) - out[i].VMNames = cloneStringSlice(in[i].VMNames) - out[i].TriggeredAlarms = cloneInventoryAlarms(in[i].TriggeredAlarms) - out[i].RecentTasks = cloneInventoryTasks(in[i].RecentTasks) - out[i].RecentEvents = cloneInventoryEvents(in[i].RecentEvents) - } - return out + return cloneSliceWith(in, func(item *InventoryNetwork) { + item.HostIDs = cloneStringSlice(item.HostIDs) + item.HostNames = cloneStringSlice(item.HostNames) + item.VMIDs = cloneStringSlice(item.VMIDs) + item.VMNames = cloneStringSlice(item.VMNames) + item.TriggeredAlarms = cloneInventoryAlarms(item.TriggeredAlarms) + item.RecentTasks = cloneInventoryTasks(item.RecentTasks) + item.RecentEvents = cloneInventoryEvents(item.RecentEvents) + }) } func cloneInventoryAlarms(in []InventoryAlarm) []InventoryAlarm { - if in == nil { - return nil - } - out := make([]InventoryAlarm, len(in)) - copy(out, in) - return out + return cloneShallowSlice(in) } func cloneInventoryTasks(in []InventoryTask) []InventoryTask { - if in == nil { - return nil - } - out := make([]InventoryTask, len(in)) - copy(out, in) - return out + return cloneShallowSlice(in) } func cloneInventoryEvents(in []InventoryEvent) []InventoryEvent { - if in == nil { - return nil - } - out := make([]InventoryEvent, len(in)) - copy(out, in) - return out + return cloneShallowSlice(in) } func cloneInventoryVMSnapshots(in []InventoryVMSnapshot) []InventoryVMSnapshot { - if in == nil { - return nil - } - out := make([]InventoryVMSnapshot, len(in)) - for i := range in { - out[i] = in[i] - out[i].CreatedAt = cloneTimePointer(in[i].CreatedAt) - out[i].Children = cloneInventoryVMSnapshots(in[i].Children) - } - return out + return cloneSliceWith(in, func(item *InventoryVMSnapshot) { + item.CreatedAt = cloneTimePointer(item.CreatedAt) + item.Children = cloneInventoryVMSnapshots(item.Children) + }) } func cloneInventoryVMNetworkAdapters(in []InventoryVMNetworkAdapter) []InventoryVMNetworkAdapter { - if in == nil { - return nil - } - out := make([]InventoryVMNetworkAdapter, len(in)) - for i := range in { - out[i] = in[i] - out[i].PCISlotNumber = cloneInt64Pointer(in[i].PCISlotNumber) - } - return out + return cloneSliceWith(in, func(item *InventoryVMNetworkAdapter) { + item.PCISlotNumber = cloneInt64Pointer(item.PCISlotNumber) + }) } func cloneInventoryVMVirtualDisks(in []InventoryVMVirtualDisk) []InventoryVMVirtualDisk { - if in == nil { - return nil - } - out := make([]InventoryVMVirtualDisk, len(in)) - for i := range in { - out[i] = in[i] - out[i].IDEPrimary = cloneBoolPointer(in[i].IDEPrimary) - out[i].IDEMaster = cloneBoolPointer(in[i].IDEMaster) - out[i].SCSIBus = cloneInt64Pointer(in[i].SCSIBus) - out[i].SCSIUnit = cloneInt64Pointer(in[i].SCSIUnit) - out[i].SATABus = cloneInt64Pointer(in[i].SATABus) - out[i].SATAUnit = cloneInt64Pointer(in[i].SATAUnit) - out[i].NVMEBus = cloneInt64Pointer(in[i].NVMEBus) - out[i].NVMEUnit = cloneInt64Pointer(in[i].NVMEUnit) - out[i].CapacityBytes = cloneInt64Pointer(in[i].CapacityBytes) - } - return out + return cloneSliceWith(in, func(item *InventoryVMVirtualDisk) { + item.IDEPrimary = cloneBoolPointer(item.IDEPrimary) + item.IDEMaster = cloneBoolPointer(item.IDEMaster) + item.SCSIBus = cloneInt64Pointer(item.SCSIBus) + item.SCSIUnit = cloneInt64Pointer(item.SCSIUnit) + item.SATABus = cloneInt64Pointer(item.SATABus) + item.SATAUnit = cloneInt64Pointer(item.SATAUnit) + item.NVMEBus = cloneInt64Pointer(item.NVMEBus) + item.NVMEUnit = cloneInt64Pointer(item.NVMEUnit) + item.CapacityBytes = cloneInt64Pointer(item.CapacityBytes) + }) } func cloneInventoryVMTools(in *InventoryVMTools) *InventoryVMTools { @@ -1030,24 +991,13 @@ func cloneInventoryVMHardware(in *InventoryVMHardware) *InventoryVMHardware { } func cloneInventoryVMBootDevices(in []InventoryVMBootDevice) []InventoryVMBootDevice { - if in == nil { - return nil - } - out := make([]InventoryVMBootDevice, len(in)) - for i := range in { - out[i] = in[i] - out[i].Disks = cloneStringSlice(in[i].Disks) - } - return out + return cloneSliceWith(in, func(item *InventoryVMBootDevice) { + item.Disks = cloneStringSlice(item.Disks) + }) } func cloneInventoryEnrichmentIssues(in []InventoryEnrichmentIssue) []InventoryEnrichmentIssue { - if in == nil { - return nil - } - out := make([]InventoryEnrichmentIssue, len(in)) - copy(out, in) - return out + return cloneShallowSlice(in) } func inventoryEnrichmentIssueSortKey(issue InventoryEnrichmentIssue) string { @@ -1731,12 +1681,7 @@ func cloneBoolPointer(in *bool) *bool { } func cloneStringSlice(in []string) []string { - if in == nil { - return nil - } - out := make([]string, len(in)) - copy(out, in) - return out + return cloneShallowSlice(in) } func firstNonEmptyTrimmed(values ...string) string { diff --git a/pkg/licensing/license_server_client.go b/pkg/licensing/license_server_client.go index 33cd841f7..2e9a04124 100644 --- a/pkg/licensing/license_server_client.go +++ b/pkg/licensing/license_server_client.go @@ -106,27 +106,29 @@ func (c *LicenseServerClient) ready() error { return nil } -// Activate calls the license server to create an installation and receive a relay grant. -func (c *LicenseServerClient) Activate(ctx context.Context, req ActivateInstallationRequest) (*ActivateInstallationResponse, error) { +// postActivationRequest POSTs an idempotent JSON request to the license +// server and decodes the shared activation response shape. label feeds error +// wrapping ("activate", "exchange"). +func (c *LicenseServerClient) postActivationRequest(ctx context.Context, path, label string, req any) (*ActivateInstallationResponse, error) { if err := c.ready(); err != nil { return nil, err } body, err := json.Marshal(req) if err != nil { - return nil, fmt.Errorf("marshal activate request: %w", err) + return nil, fmt.Errorf("marshal %s request: %w", label, err) } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/activate", bytes.NewReader(body)) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body)) if err != nil { - return nil, fmt.Errorf("create activate request: %w", err) + return nil, fmt.Errorf("create %s request: %w", label, err) } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Idempotency-Key", generateIdempotencyKey()) resp, err := c.httpClient.Do(httpReq) if err != nil { - return nil, fmt.Errorf("activate request failed: %w", err) + return nil, fmt.Errorf("%s request failed: %w", label, err) } defer resp.Body.Close() @@ -136,46 +138,21 @@ func (c *LicenseServerClient) Activate(ctx context.Context, req ActivateInstalla var result ActivateInstallationResponse if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil { - return nil, fmt.Errorf("decode activate response: %w", err) + return nil, fmt.Errorf("decode %s response: %w", label, err) } return &result, nil } +// Activate calls the license server to create an installation and receive a relay grant. +func (c *LicenseServerClient) Activate(ctx context.Context, req ActivateInstallationRequest) (*ActivateInstallationResponse, error) { + return c.postActivationRequest(ctx, "/v1/activate", "activate", req) +} + // ExchangeLegacyLicense converts a legacy v5 JWT-style license into a v6 activation. // The response shape matches normal activation so the runtime can persist activation // state and start using grant refresh immediately. func (c *LicenseServerClient) ExchangeLegacyLicense(ctx context.Context, req ExchangeLegacyLicenseRequest) (*ActivateInstallationResponse, error) { - if err := c.ready(); err != nil { - return nil, err - } - - body, err := json.Marshal(req) - if err != nil { - return nil, fmt.Errorf("marshal exchange request: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/licenses/exchange", bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("create exchange request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Idempotency-Key", generateIdempotencyKey()) - - resp, err := c.httpClient.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("exchange request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - return nil, c.parseError(resp) - } - - var result ActivateInstallationResponse - if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil { - return nil, fmt.Errorf("decode exchange response: %w", err) - } - return &result, nil + return c.postActivationRequest(ctx, "/v1/licenses/exchange", "exchange", req) } // RefreshGrant calls the license server to refresh a relay grant. diff --git a/pkg/proxmox/client.go b/pkg/proxmox/client.go index 4263dd6ab..008dcebd1 100644 --- a/pkg/proxmox/client.go +++ b/pkg/proxmox/client.go @@ -889,8 +889,9 @@ func (c *Client) GetNodeRRDData(ctx context.Context, node, timeframe, cf string, return result.Data, nil } -// GetLXCRRDData retrieves RRD metrics for an LXC container. -func (c *Client) GetLXCRRDData(ctx context.Context, node string, vmid int, timeframe, cf string, ds []string) ([]GuestRRDPoint, error) { +// getGuestRRDData retrieves RRD metrics for a guest. guestPath is the PVE API +// guest segment ("lxc", "qemu"). +func (c *Client) getGuestRRDData(ctx context.Context, node, guestPath string, vmid int, timeframe, cf string) ([]GuestRRDPoint, error) { if timeframe == "" { timeframe = "hour" } @@ -904,7 +905,7 @@ func (c *Client) GetLXCRRDData(ctx context.Context, node string, vmid int, timef // Note: the "ds" parameter is not sent because older PVE versions // (including 9.x) reject it as an unknown property. - path := fmt.Sprintf("/nodes/%s/lxc/%d/rrddata", url.PathEscape(node), vmid) + path := fmt.Sprintf("/nodes/%s/%s/%d/rrddata", url.PathEscape(node), guestPath, vmid) if query := params.Encode(); query != "" { path = fmt.Sprintf("%s?%s", path, query) } @@ -926,41 +927,14 @@ func (c *Client) GetLXCRRDData(ctx context.Context, node string, vmid int, timef return result.Data, nil } +// GetLXCRRDData retrieves RRD metrics for an LXC container. +func (c *Client) GetLXCRRDData(ctx context.Context, node string, vmid int, timeframe, cf string, ds []string) ([]GuestRRDPoint, error) { + return c.getGuestRRDData(ctx, node, "lxc", vmid, timeframe, cf) +} + // GetVMRRDData retrieves RRD metrics for a QEMU VM. func (c *Client) GetVMRRDData(ctx context.Context, node string, vmid int, timeframe, cf string, ds []string) ([]GuestRRDPoint, error) { - if timeframe == "" { - timeframe = "hour" - } - if cf == "" { - cf = "AVERAGE" - } - - params := url.Values{} - params.Set("timeframe", timeframe) - params.Set("cf", cf) - // Note: the "ds" parameter is not sent because older PVE versions - // (including 9.x) reject it as an unknown property. - - path := fmt.Sprintf("/nodes/%s/qemu/%d/rrddata", url.PathEscape(node), vmid) - if query := params.Encode(); query != "" { - path = fmt.Sprintf("%s?%s", path, query) - } - - resp, err := c.get(ctx, path) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var result struct { - Data []GuestRRDPoint `json:"data"` - } - - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - return result.Data, nil + return c.getGuestRRDData(ctx, node, "qemu", vmid, timeframe, cf) } // VM represents a Proxmox VE virtual machine