Dedupe alerts PMG queue checks, vmware clones, licensing and proxmox client clones

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.
This commit is contained in:
rcourtman 2026-06-10 09:49:03 +01:00
parent 8439ce6e6b
commit 9f722a442a
7 changed files with 199 additions and 376 deletions

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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.

View file

@ -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