mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-21 11:15:36 +00:00
81b31e4d3b
Retire runtime/API/UI monitored-system volume enforcement now that infrastructure monitoring is no longer capped. Keep only legacy metadata scrubbing and purchase-start compatibility for old max_monitored_systems references. Rename the remaining preview surface to monitored-system impact and make previews explanatory rather than save-blocking. Update subsystem contracts and RA7 evidence for the caps-retired invariant.
1401 lines
38 KiB
Go
1401 lines
38 KiB
Go
package monitoring
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
internalerrors "github.com/rcourtman/pulse-go-rewrite/internal/monitoring/errors"
|
|
recoverymanager "github.com/rcourtman/pulse-go-rewrite/internal/recovery/manager"
|
|
truenasmapper "github.com/rcourtman/pulse-go-rewrite/internal/recovery/mapper/truenas"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/truenas"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
const defaultTrueNASPollInterval = 60 * time.Second
|
|
|
|
const defaultTrueNASHistoryReadTimeout = 10 * time.Second
|
|
|
|
// TrueNASConnectionPollError captures the last runtime poll error for one
|
|
// configured TrueNAS connection.
|
|
type TrueNASConnectionPollError struct {
|
|
At *time.Time `json:"at,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
Category string `json:"category,omitempty"`
|
|
}
|
|
|
|
// TrueNASConnectionPollStatus summarizes the runtime polling state for one
|
|
// configured TrueNAS connection.
|
|
type TrueNASConnectionPollStatus struct {
|
|
IntervalSeconds int `json:"intervalSeconds"`
|
|
LastAttemptAt *time.Time `json:"lastAttemptAt,omitempty"`
|
|
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
|
|
ConsecutiveFailures int `json:"consecutiveFailures,omitempty"`
|
|
LastError *TrueNASConnectionPollError `json:"lastError,omitempty"`
|
|
}
|
|
|
|
// TrueNASConnectionObservedSummary summarizes the canonical resources the most
|
|
// recent successful poll contributed for one configured TrueNAS connection.
|
|
type TrueNASConnectionObservedSummary struct {
|
|
Host string `json:"host,omitempty"`
|
|
ResourceID string `json:"resourceId,omitempty"`
|
|
CollectedAt *time.Time `json:"collectedAt,omitempty"`
|
|
Systems int `json:"systems"`
|
|
StoragePools int `json:"storagePools"`
|
|
Datasets int `json:"datasets"`
|
|
Apps int `json:"apps"`
|
|
Disks int `json:"disks"`
|
|
RecoveryArtifacts int `json:"recoveryArtifacts"`
|
|
}
|
|
|
|
// TrueNASConnectionSummary merges poll health with the most recent discovered
|
|
// platform contribution for one configured TrueNAS connection.
|
|
type TrueNASConnectionSummary struct {
|
|
Poll *TrueNASConnectionPollStatus `json:"poll,omitempty"`
|
|
Observed *TrueNASConnectionObservedSummary `json:"observed,omitempty"`
|
|
}
|
|
|
|
type trueNASConnectionRuntimeStatus struct {
|
|
lastAttemptAt time.Time
|
|
lastSuccessAt time.Time
|
|
lastError *TrueNASConnectionPollError
|
|
consecutiveFailures int
|
|
nextPollAt time.Time
|
|
observed *TrueNASConnectionObservedSummary
|
|
}
|
|
|
|
// TrueNASPoller manages periodic polling of configured TrueNAS connections.
|
|
type TrueNASPoller struct {
|
|
multiTenant *config.MultiTenantPersistence
|
|
recoveryManager *recoverymanager.Manager
|
|
mu sync.Mutex
|
|
// providersByOrg is keyed by orgID, then connection ID.
|
|
providersByOrg map[string]map[string]*truenas.Provider
|
|
// configsByOrg is keyed by orgID, then connection ID.
|
|
configsByOrg map[string]map[string]config.TrueNASInstance
|
|
// statusByOrg is keyed by orgID, then connection ID.
|
|
statusByOrg map[string]map[string]*trueNASConnectionRuntimeStatus
|
|
// cachedRecordsByOrg is keyed by orgID, then connection ID.
|
|
cachedRecordsByOrg map[string]map[string][]unifiedresources.IngestRecord
|
|
cancel context.CancelFunc
|
|
stopped chan struct{}
|
|
interval time.Duration
|
|
explicitInterval bool
|
|
}
|
|
|
|
type trueNASPollerProviderEntry struct {
|
|
connectionID string
|
|
provider *truenas.Provider
|
|
}
|
|
|
|
// NewTrueNASPoller builds a new TrueNAS poller with the provided poll interval.
|
|
func NewTrueNASPoller(multiTenant *config.MultiTenantPersistence, interval time.Duration, recoveryManager *recoverymanager.Manager) *TrueNASPoller {
|
|
explicitInterval := interval > 0
|
|
if interval <= 0 {
|
|
interval = defaultTrueNASPollInterval
|
|
}
|
|
|
|
stopped := make(chan struct{})
|
|
close(stopped)
|
|
|
|
return &TrueNASPoller{
|
|
multiTenant: multiTenant,
|
|
recoveryManager: recoveryManager,
|
|
providersByOrg: make(map[string]map[string]*truenas.Provider),
|
|
configsByOrg: make(map[string]map[string]config.TrueNASInstance),
|
|
statusByOrg: make(map[string]map[string]*trueNASConnectionRuntimeStatus),
|
|
cachedRecordsByOrg: make(map[string]map[string][]unifiedresources.IngestRecord),
|
|
stopped: stopped,
|
|
interval: interval,
|
|
explicitInterval: explicitInterval,
|
|
}
|
|
}
|
|
|
|
// Start begins periodic TrueNAS polling if the feature flag is enabled.
|
|
func (p *TrueNASPoller) Start(ctx context.Context) {
|
|
if p == nil || !truenas.IsFeatureEnabled() {
|
|
return
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
p.mu.Lock()
|
|
if p.cancel != nil {
|
|
p.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
runCtx, cancel := context.WithCancel(ctx)
|
|
p.cancel = cancel
|
|
p.stopped = make(chan struct{})
|
|
stopped := p.stopped
|
|
p.mu.Unlock()
|
|
|
|
go func() {
|
|
defer close(stopped)
|
|
defer func() {
|
|
p.mu.Lock()
|
|
if p.stopped == stopped {
|
|
p.cancel = nil
|
|
}
|
|
p.mu.Unlock()
|
|
}()
|
|
|
|
p.syncConnections()
|
|
p.pollAll(runCtx)
|
|
for {
|
|
wait := p.nextWaitDuration(time.Now())
|
|
timer := time.NewTimer(wait)
|
|
select {
|
|
case <-runCtx.Done():
|
|
if !timer.Stop() {
|
|
select {
|
|
case <-timer.C:
|
|
default:
|
|
}
|
|
}
|
|
return
|
|
case <-timer.C:
|
|
p.syncConnections()
|
|
p.pollAll(runCtx)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Stop requests poller shutdown and waits up to five seconds for exit.
|
|
func (p *TrueNASPoller) Stop() {
|
|
if p == nil {
|
|
return
|
|
}
|
|
|
|
p.mu.Lock()
|
|
cancel := p.cancel
|
|
stopped := p.stopped
|
|
p.mu.Unlock()
|
|
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
|
|
if stopped == nil {
|
|
return
|
|
}
|
|
|
|
timer := time.NewTimer(5 * time.Second)
|
|
defer timer.Stop()
|
|
|
|
select {
|
|
case <-stopped:
|
|
case <-time.After(5 * time.Second):
|
|
log.Warn().
|
|
Str("component", "truenas_poller").
|
|
Str("action", "stop").
|
|
Dur("timeout", 5*time.Second).
|
|
Msg("TrueNAS poller stop timed out waiting for shutdown")
|
|
}
|
|
}
|
|
|
|
func (p *TrueNASPoller) syncConnections() {
|
|
if p == nil {
|
|
return
|
|
}
|
|
if p.multiTenant == nil {
|
|
log.Warn().
|
|
Str("component", "truenas_poller").
|
|
Str("action", "sync_connections").
|
|
Msg("TrueNAS poller cannot sync connections because multi-tenant persistence is nil")
|
|
return
|
|
}
|
|
|
|
orgs, err := p.multiTenant.ListOrganizations()
|
|
if err != nil {
|
|
log.Warn().
|
|
Str("component", "truenas_poller").
|
|
Str("action", "list_organizations").
|
|
Err(err).
|
|
Msg("TrueNAS poller failed to list organizations")
|
|
return
|
|
}
|
|
|
|
// orgID -> connID -> instance
|
|
configured := make(map[string]map[string]config.TrueNASInstance, len(orgs))
|
|
active := make(map[string]map[string]config.TrueNASInstance, len(orgs))
|
|
|
|
for _, org := range orgs {
|
|
if org == nil {
|
|
continue
|
|
}
|
|
orgID := strings.TrimSpace(org.ID)
|
|
if orgID == "" {
|
|
continue
|
|
}
|
|
persistence, err := p.multiTenant.GetPersistence(orgID)
|
|
if err != nil || persistence == nil {
|
|
log.Warn().
|
|
Str("component", "truenas_poller").
|
|
Str("action", "get_persistence").
|
|
Str("org_id", orgID).
|
|
Err(err).
|
|
Msg("TrueNAS poller failed to open tenant persistence")
|
|
continue
|
|
}
|
|
|
|
instances, err := persistence.LoadTrueNASConfig()
|
|
if err != nil {
|
|
log.Warn().
|
|
Str("component", "truenas_poller").
|
|
Str("action", "load_truenas_config").
|
|
Str("org_id", orgID).
|
|
Err(err).
|
|
Msg("TrueNAS poller failed to load TrueNAS config")
|
|
continue
|
|
}
|
|
|
|
for i := range instances {
|
|
instance := instances[i]
|
|
id := strings.TrimSpace(instance.ID)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
instance.ApplyDefaults()
|
|
if configured[orgID] == nil {
|
|
configured[orgID] = make(map[string]config.TrueNASInstance)
|
|
}
|
|
configured[orgID][id] = instance
|
|
if !instance.Enabled {
|
|
continue
|
|
}
|
|
if active[orgID] == nil {
|
|
active[orgID] = make(map[string]config.TrueNASInstance)
|
|
}
|
|
active[orgID][id] = instance
|
|
}
|
|
}
|
|
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
// Ensure providers exist for active connections.
|
|
for orgID, byConn := range active {
|
|
if len(byConn) == 0 {
|
|
continue
|
|
}
|
|
if p.providersByOrg[orgID] == nil {
|
|
p.providersByOrg[orgID] = make(map[string]*truenas.Provider)
|
|
}
|
|
if p.configsByOrg[orgID] == nil {
|
|
p.configsByOrg[orgID] = make(map[string]config.TrueNASInstance)
|
|
}
|
|
if p.statusByOrg[orgID] == nil {
|
|
p.statusByOrg[orgID] = make(map[string]*trueNASConnectionRuntimeStatus)
|
|
}
|
|
if p.cachedRecordsByOrg[orgID] == nil {
|
|
p.cachedRecordsByOrg[orgID] = make(map[string][]unifiedresources.IngestRecord)
|
|
}
|
|
|
|
for connID, instance := range byConn {
|
|
previousConfig := p.configsByOrg[orgID][connID]
|
|
existingProvider, exists := p.providersByOrg[orgID][connID]
|
|
p.configsByOrg[orgID][connID] = instance
|
|
p.alignConnectionNextPollLocked(orgID, connID, previousConfig, instance)
|
|
if exists && !trueNASProviderConfigChanged(previousConfig, instance) {
|
|
continue
|
|
}
|
|
|
|
client, err := truenas.NewClient(truenas.ClientConfig{
|
|
Host: instance.Host,
|
|
Port: instance.Port,
|
|
APIKey: instance.APIKey,
|
|
Username: instance.Username,
|
|
Password: instance.Password,
|
|
UseHTTPS: instance.UseHTTPS,
|
|
InsecureSkipVerify: instance.InsecureSkipVerify,
|
|
Fingerprint: instance.Fingerprint,
|
|
})
|
|
if err != nil {
|
|
now := time.Now().UTC()
|
|
p.recordConnectionFailureLocked(orgID, connID, instance, err, now)
|
|
log.Warn().
|
|
Str("component", "truenas_poller").
|
|
Str("action", "initialize_client").
|
|
Str("org_id", orgID).
|
|
Str("connection_id", connID).
|
|
Err(err).
|
|
Msg("TrueNAS poller failed to initialize client")
|
|
continue
|
|
}
|
|
|
|
if existingProvider != nil {
|
|
existingProvider.Close()
|
|
}
|
|
p.providersByOrg[orgID][connID] = truenas.NewLiveProvider(&truenas.APIFetcher{Client: client})
|
|
}
|
|
}
|
|
|
|
// Prune providers for removed/disabled connections and removed orgs.
|
|
for orgID, providers := range p.providersByOrg {
|
|
activeByConn := active[orgID]
|
|
for connID, provider := range providers {
|
|
if activeByConn == nil {
|
|
// Org no longer exists or has no active TrueNAS connections.
|
|
if provider != nil {
|
|
provider.Close()
|
|
}
|
|
delete(providers, connID)
|
|
if p.configsByOrg[orgID] != nil {
|
|
delete(p.configsByOrg[orgID], connID)
|
|
}
|
|
if p.cachedRecordsByOrg[orgID] != nil {
|
|
delete(p.cachedRecordsByOrg[orgID], connID)
|
|
}
|
|
continue
|
|
}
|
|
if _, ok := activeByConn[connID]; !ok {
|
|
if provider != nil {
|
|
provider.Close()
|
|
}
|
|
delete(providers, connID)
|
|
if p.configsByOrg[orgID] != nil {
|
|
delete(p.configsByOrg[orgID], connID)
|
|
}
|
|
if p.cachedRecordsByOrg[orgID] != nil {
|
|
delete(p.cachedRecordsByOrg[orgID], connID)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(providers) == 0 {
|
|
delete(p.providersByOrg, orgID)
|
|
delete(p.configsByOrg, orgID)
|
|
delete(p.cachedRecordsByOrg, orgID)
|
|
}
|
|
}
|
|
|
|
// Prune runtime status only when the connection is truly gone from config.
|
|
for orgID, byConn := range p.statusByOrg {
|
|
configuredByConn := configured[orgID]
|
|
for connID := range byConn {
|
|
if configuredByConn == nil {
|
|
delete(byConn, connID)
|
|
continue
|
|
}
|
|
if _, ok := configuredByConn[connID]; !ok {
|
|
delete(byConn, connID)
|
|
}
|
|
}
|
|
if len(byConn) == 0 {
|
|
delete(p.statusByOrg, orgID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *TrueNASPoller) closeAllProviders() {
|
|
if p == nil {
|
|
return
|
|
}
|
|
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
for _, providers := range p.providersByOrg {
|
|
for _, provider := range providers {
|
|
if provider != nil {
|
|
provider.Close()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ConnectionSummaries returns per-connection runtime health and discovered
|
|
// contribution summaries for the supplied TrueNAS settings records.
|
|
func (p *TrueNASPoller) ConnectionSummaries(orgID string, instances []config.TrueNASInstance) map[string]TrueNASConnectionSummary {
|
|
if len(instances) == 0 {
|
|
return nil
|
|
}
|
|
|
|
orgID = normalizeTrueNASOrgID(orgID)
|
|
summaries := make(map[string]TrueNASConnectionSummary, len(instances))
|
|
|
|
for i := range instances {
|
|
instance := instances[i]
|
|
instance.ApplyDefaults()
|
|
connID := strings.TrimSpace(instance.ID)
|
|
if connID == "" {
|
|
continue
|
|
}
|
|
|
|
summary := TrueNASConnectionSummary{
|
|
Poll: &TrueNASConnectionPollStatus{
|
|
IntervalSeconds: instance.EffectivePollIntervalSecs(),
|
|
},
|
|
}
|
|
|
|
if p != nil {
|
|
p.mu.Lock()
|
|
status := p.cloneRuntimeStatusLocked(orgID, connID)
|
|
p.mu.Unlock()
|
|
if status != nil {
|
|
summary.Poll.LastAttemptAt = cloneTimePointer(status.LastAttemptAt)
|
|
summary.Poll.LastSuccessAt = cloneTimePointer(status.LastSuccessAt)
|
|
summary.Poll.ConsecutiveFailures = status.ConsecutiveFailures
|
|
summary.Poll.LastError = cloneTrueNASConnectionPollError(status.LastError)
|
|
summary.Observed = cloneTrueNASObservedSummary(status.Observed)
|
|
}
|
|
}
|
|
|
|
summaries[connID] = summary
|
|
}
|
|
|
|
return summaries
|
|
}
|
|
|
|
func (p *TrueNASPoller) nextWaitDuration(now time.Time) time.Duration {
|
|
if p == nil {
|
|
return defaultTrueNASPollInterval
|
|
}
|
|
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
wait := p.interval
|
|
for orgID, byConn := range p.configsByOrg {
|
|
for connID, instance := range byConn {
|
|
next := p.nextPollAtLocked(orgID, connID, instance)
|
|
if next.IsZero() || !next.After(now) {
|
|
return 0
|
|
}
|
|
until := next.Sub(now)
|
|
if until < wait {
|
|
wait = until
|
|
}
|
|
}
|
|
}
|
|
|
|
if wait < 0 {
|
|
return 0
|
|
}
|
|
return wait
|
|
}
|
|
|
|
func (p *TrueNASPoller) pollAll(ctx context.Context) {
|
|
if p == nil {
|
|
return
|
|
}
|
|
p.mu.Lock()
|
|
type providerEntry struct {
|
|
orgID string
|
|
id string
|
|
config config.TrueNASInstance
|
|
provider *truenas.Provider
|
|
}
|
|
entries := make([]providerEntry, 0)
|
|
now := time.Now().UTC()
|
|
for orgID, providers := range p.providersByOrg {
|
|
for id, provider := range providers {
|
|
instance, ok := p.configsByOrg[orgID][id]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if !p.connectionPollDueLocked(orgID, id, instance, now) {
|
|
continue
|
|
}
|
|
entries = append(entries, providerEntry{
|
|
orgID: orgID,
|
|
id: id,
|
|
config: instance,
|
|
provider: provider,
|
|
})
|
|
}
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
pm := getPollMetrics()
|
|
|
|
for _, entry := range entries {
|
|
if entry.provider == nil {
|
|
continue
|
|
}
|
|
|
|
start := time.Now().UTC()
|
|
err := entry.provider.Refresh(ctx)
|
|
end := time.Now().UTC()
|
|
if err != nil {
|
|
pm.RecordResult(PollResult{
|
|
InstanceName: entry.id,
|
|
InstanceType: "truenas",
|
|
Success: false,
|
|
Error: classifyTrueNASError(err, entry.id),
|
|
StartTime: start,
|
|
EndTime: end,
|
|
})
|
|
p.mu.Lock()
|
|
p.recordConnectionFailureLocked(entry.orgID, entry.id, entry.config, err, end)
|
|
p.mu.Unlock()
|
|
log.Warn().
|
|
Str("component", "truenas_poller").
|
|
Str("action", "refresh_connection").
|
|
Str("connection_id", entry.id).
|
|
Err(err).
|
|
Msg("TrueNAS poller refresh failed")
|
|
continue
|
|
}
|
|
|
|
pm.RecordResult(PollResult{
|
|
InstanceName: entry.id,
|
|
InstanceType: "truenas",
|
|
Success: true,
|
|
StartTime: start,
|
|
EndTime: end,
|
|
})
|
|
|
|
snapshot := entry.provider.Snapshot()
|
|
p.mu.Lock()
|
|
p.recordConnectionSuccessLocked(entry.orgID, entry.id, entry.config, end, snapshot)
|
|
p.mu.Unlock()
|
|
|
|
records := entry.provider.Records()
|
|
if len(records) == 0 {
|
|
p.mu.Lock()
|
|
if p.cachedRecordsByOrg[entry.orgID] == nil {
|
|
p.cachedRecordsByOrg[entry.orgID] = make(map[string][]unifiedresources.IngestRecord)
|
|
}
|
|
p.cachedRecordsByOrg[entry.orgID][entry.id] = nil
|
|
p.mu.Unlock()
|
|
continue
|
|
}
|
|
p.mu.Lock()
|
|
if p.cachedRecordsByOrg[entry.orgID] == nil {
|
|
p.cachedRecordsByOrg[entry.orgID] = make(map[string][]unifiedresources.IngestRecord)
|
|
}
|
|
p.cachedRecordsByOrg[entry.orgID][entry.id] = cloneIngestRecords(records)
|
|
p.mu.Unlock()
|
|
|
|
p.ingestRecoveryPoints(ctx, entry.orgID, entry.id, entry.provider)
|
|
}
|
|
}
|
|
|
|
type trueNASConnectionRuntimeSnapshot struct {
|
|
LastAttemptAt *time.Time
|
|
LastSuccessAt *time.Time
|
|
ConsecutiveFailures int
|
|
LastError *TrueNASConnectionPollError
|
|
Observed *TrueNASConnectionObservedSummary
|
|
}
|
|
|
|
func (p *TrueNASPoller) cloneRuntimeStatusLocked(orgID string, connID string) *trueNASConnectionRuntimeSnapshot {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
byConn := p.statusByOrg[orgID]
|
|
if byConn == nil {
|
|
return nil
|
|
}
|
|
status := byConn[connID]
|
|
if status == nil {
|
|
return nil
|
|
}
|
|
return &trueNASConnectionRuntimeSnapshot{
|
|
LastAttemptAt: timePointerIfSet(status.lastAttemptAt),
|
|
LastSuccessAt: timePointerIfSet(status.lastSuccessAt),
|
|
ConsecutiveFailures: status.consecutiveFailures,
|
|
LastError: cloneTrueNASConnectionPollError(status.lastError),
|
|
Observed: cloneTrueNASObservedSummary(status.observed),
|
|
}
|
|
}
|
|
|
|
func (p *TrueNASPoller) ensureConnectionRuntimeStatusLocked(orgID, connID string) *trueNASConnectionRuntimeStatus {
|
|
if p.statusByOrg[orgID] == nil {
|
|
p.statusByOrg[orgID] = make(map[string]*trueNASConnectionRuntimeStatus)
|
|
}
|
|
if p.statusByOrg[orgID][connID] == nil {
|
|
p.statusByOrg[orgID][connID] = &trueNASConnectionRuntimeStatus{}
|
|
}
|
|
return p.statusByOrg[orgID][connID]
|
|
}
|
|
|
|
func (p *TrueNASPoller) alignConnectionNextPollLocked(
|
|
orgID string,
|
|
connID string,
|
|
previous config.TrueNASInstance,
|
|
next config.TrueNASInstance,
|
|
) {
|
|
status := p.ensureConnectionRuntimeStatusLocked(orgID, connID)
|
|
if status == nil || status.lastAttemptAt.IsZero() {
|
|
return
|
|
}
|
|
previousInterval := p.effectiveRuntimePollInterval(previous)
|
|
nextInterval := p.effectiveRuntimePollInterval(next)
|
|
if previousInterval == nextInterval {
|
|
return
|
|
}
|
|
status.nextPollAt = status.lastAttemptAt.Add(nextInterval)
|
|
}
|
|
|
|
func (p *TrueNASPoller) connectionPollDueLocked(
|
|
orgID string,
|
|
connID string,
|
|
instance config.TrueNASInstance,
|
|
now time.Time,
|
|
) bool {
|
|
next := p.nextPollAtLocked(orgID, connID, instance)
|
|
return next.IsZero() || !next.After(now)
|
|
}
|
|
|
|
func (p *TrueNASPoller) nextPollAtLocked(orgID string, connID string, instance config.TrueNASInstance) time.Time {
|
|
status := p.ensureConnectionRuntimeStatusLocked(orgID, connID)
|
|
if status == nil {
|
|
return time.Time{}
|
|
}
|
|
if status.nextPollAt.IsZero() && !status.lastAttemptAt.IsZero() {
|
|
status.nextPollAt = status.lastAttemptAt.Add(p.effectiveRuntimePollInterval(instance))
|
|
}
|
|
return status.nextPollAt
|
|
}
|
|
|
|
func (p *TrueNASPoller) effectiveRuntimePollInterval(instance config.TrueNASInstance) time.Duration {
|
|
base := time.Duration(instance.EffectivePollIntervalSecs()) * time.Second
|
|
if p != nil && p.explicitInterval && p.interval > 0 && p.interval < base {
|
|
return p.interval
|
|
}
|
|
return base
|
|
}
|
|
|
|
func (p *TrueNASPoller) recordConnectionSuccessLocked(
|
|
orgID string,
|
|
connID string,
|
|
instance config.TrueNASInstance,
|
|
at time.Time,
|
|
snapshot *truenas.FixtureSnapshot,
|
|
) {
|
|
status := p.ensureConnectionRuntimeStatusLocked(orgID, connID)
|
|
status.lastAttemptAt = at
|
|
status.lastSuccessAt = at
|
|
status.lastError = nil
|
|
status.consecutiveFailures = 0
|
|
status.nextPollAt = at.Add(p.effectiveRuntimePollInterval(instance))
|
|
if snapshot != nil {
|
|
status.observed = buildTrueNASObservedSummary(snapshot)
|
|
}
|
|
}
|
|
|
|
func (p *TrueNASPoller) recordConnectionFailureLocked(
|
|
orgID string,
|
|
connID string,
|
|
instance config.TrueNASInstance,
|
|
err error,
|
|
at time.Time,
|
|
) {
|
|
status := p.ensureConnectionRuntimeStatusLocked(orgID, connID)
|
|
status.lastAttemptAt = at
|
|
status.consecutiveFailures++
|
|
status.nextPollAt = at.Add(p.effectiveRuntimePollInterval(instance))
|
|
|
|
category := ""
|
|
if monitorErr := classifyTrueNASError(err, connID); monitorErr != nil {
|
|
category = string(monitorErr.Type)
|
|
}
|
|
status.lastError = &TrueNASConnectionPollError{
|
|
At: timePointerIfSet(at),
|
|
Message: strings.TrimSpace(err.Error()),
|
|
Category: category,
|
|
}
|
|
}
|
|
|
|
// RecordConnectionTestSuccess updates one saved TrueNAS connection summary after
|
|
// a manual row-level test without clearing the last observed contribution
|
|
// summary.
|
|
func (p *TrueNASPoller) RecordConnectionTestSuccess(
|
|
orgID string,
|
|
connID string,
|
|
instance config.TrueNASInstance,
|
|
at time.Time,
|
|
) {
|
|
if p == nil {
|
|
return
|
|
}
|
|
connID = strings.TrimSpace(connID)
|
|
if connID == "" {
|
|
return
|
|
}
|
|
orgID = normalizeTrueNASOrgID(orgID)
|
|
instance.ApplyDefaults()
|
|
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.recordConnectionSuccessLocked(orgID, connID, instance, at, nil)
|
|
}
|
|
|
|
// RecordConnectionTestFailure updates one saved TrueNAS connection summary after
|
|
// a manual row-level test failure while preserving any previously observed
|
|
// resource contribution summary.
|
|
func (p *TrueNASPoller) RecordConnectionTestFailure(
|
|
orgID string,
|
|
connID string,
|
|
instance config.TrueNASInstance,
|
|
err error,
|
|
at time.Time,
|
|
) {
|
|
if p == nil || err == nil {
|
|
return
|
|
}
|
|
connID = strings.TrimSpace(connID)
|
|
if connID == "" {
|
|
return
|
|
}
|
|
orgID = normalizeTrueNASOrgID(orgID)
|
|
instance.ApplyDefaults()
|
|
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.recordConnectionFailureLocked(orgID, connID, instance, err, at)
|
|
}
|
|
|
|
func buildTrueNASObservedSummary(snapshot *truenas.FixtureSnapshot) *TrueNASConnectionObservedSummary {
|
|
if snapshot == nil {
|
|
return nil
|
|
}
|
|
|
|
host := strings.TrimSpace(snapshot.System.Hostname)
|
|
resourceID := host
|
|
collectedAt := snapshot.CollectedAt
|
|
if collectedAt.IsZero() {
|
|
collectedAt = snapshot.System.CollectedAt
|
|
}
|
|
|
|
summary := &TrueNASConnectionObservedSummary{
|
|
Host: host,
|
|
ResourceID: resourceID,
|
|
Systems: 0,
|
|
StoragePools: len(snapshot.Pools),
|
|
Datasets: len(snapshot.Datasets),
|
|
Apps: len(snapshot.Apps),
|
|
Disks: len(snapshot.Disks),
|
|
RecoveryArtifacts: len(snapshot.ZFSSnapshots) + len(snapshot.ReplicationTasks),
|
|
}
|
|
if host != "" || resourceID != "" {
|
|
summary.Systems = 1
|
|
}
|
|
summary.CollectedAt = timePointerIfSet(collectedAt)
|
|
return summary
|
|
}
|
|
|
|
func normalizeTrueNASOrgID(orgID string) string {
|
|
orgID = strings.TrimSpace(orgID)
|
|
if orgID == "" {
|
|
return "default"
|
|
}
|
|
return orgID
|
|
}
|
|
|
|
func timePointerIfSet(value time.Time) *time.Time {
|
|
if value.IsZero() {
|
|
return nil
|
|
}
|
|
copied := value
|
|
return &copied
|
|
}
|
|
|
|
func cloneTimePointer(value *time.Time) *time.Time {
|
|
if value == nil || value.IsZero() {
|
|
return nil
|
|
}
|
|
copied := *value
|
|
return &copied
|
|
}
|
|
|
|
func cloneTrueNASConnectionPollError(value *TrueNASConnectionPollError) *TrueNASConnectionPollError {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
return &TrueNASConnectionPollError{
|
|
At: cloneTimePointer(value.At),
|
|
Message: strings.TrimSpace(value.Message),
|
|
Category: strings.TrimSpace(value.Category),
|
|
}
|
|
}
|
|
|
|
func cloneTrueNASObservedSummary(value *TrueNASConnectionObservedSummary) *TrueNASConnectionObservedSummary {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
return &TrueNASConnectionObservedSummary{
|
|
Host: strings.TrimSpace(value.Host),
|
|
ResourceID: strings.TrimSpace(value.ResourceID),
|
|
CollectedAt: cloneTimePointer(value.CollectedAt),
|
|
Systems: value.Systems,
|
|
StoragePools: value.StoragePools,
|
|
Datasets: value.Datasets,
|
|
Apps: value.Apps,
|
|
Disks: value.Disks,
|
|
RecoveryArtifacts: value.RecoveryArtifacts,
|
|
}
|
|
}
|
|
|
|
func (p *TrueNASPoller) ingestRecoveryPoints(ctx context.Context, orgID string, connectionID string, provider *truenas.Provider) {
|
|
if p == nil || p.recoveryManager == nil || provider == nil {
|
|
return
|
|
}
|
|
|
|
store, err := p.recoveryManager.StoreForOrg(orgID)
|
|
if err != nil {
|
|
log.Warn().
|
|
Str("component", "truenas_poller").
|
|
Str("action", "recovery_store").
|
|
Err(err).
|
|
Msg("TrueNAS poller failed to open recovery store")
|
|
return
|
|
}
|
|
|
|
snapshot := provider.Snapshot()
|
|
points := truenasmapper.FromTrueNASSnapshot(connectionID, snapshot)
|
|
if len(points) == 0 {
|
|
return
|
|
}
|
|
|
|
// Best-effort cap in case a system has an enormous snapshot history.
|
|
const maxPointsPerPoll = 2000
|
|
if len(points) > maxPointsPerPoll {
|
|
points = points[:maxPointsPerPoll]
|
|
}
|
|
|
|
if err := store.UpsertPoints(ctx, points); err != nil {
|
|
log.Warn().
|
|
Str("component", "truenas_poller").
|
|
Str("action", "ingest_recovery_points").
|
|
Str("connection_id", strings.TrimSpace(connectionID)).
|
|
Err(err).
|
|
Msg("TrueNAS poller failed to ingest recovery points")
|
|
}
|
|
}
|
|
|
|
// GetCurrentRecords returns the latest known TrueNAS records across active connections.
|
|
func (p *TrueNASPoller) GetCurrentRecords() []unifiedresources.IngestRecord {
|
|
return p.GetCurrentRecordsForOrg("default")
|
|
}
|
|
|
|
// SupplementalRecords adapts TrueNAS records to the monitor supplemental
|
|
// provider contract.
|
|
func (p *TrueNASPoller) SupplementalRecords(_ *Monitor, orgID string) []unifiedresources.IngestRecord {
|
|
return p.GetCurrentRecordsForOrg(orgID)
|
|
}
|
|
|
|
// PhysicalDiskTemperatureHistory exposes native TrueNAS disk temperature
|
|
// history through the canonical monitoring chart boundary.
|
|
func (p *TrueNASPoller) PhysicalDiskTemperatureHistory(_ *Monitor, orgID string, duration time.Duration) map[string][]MetricPoint {
|
|
if p == nil || !truenas.IsFeatureEnabled() {
|
|
return nil
|
|
}
|
|
|
|
orgID = strings.TrimSpace(orgID)
|
|
if orgID == "" {
|
|
orgID = "default"
|
|
}
|
|
|
|
entries := p.providerEntriesForOrg(orgID)
|
|
if len(entries) == 0 {
|
|
return nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), defaultTrueNASHistoryReadTimeout)
|
|
defer cancel()
|
|
|
|
history := make(map[string][]MetricPoint)
|
|
for _, entry := range entries {
|
|
nativeHistory, err := entry.provider.PhysicalDiskTemperatureHistory(ctx, duration)
|
|
if err != nil {
|
|
log.Warn().
|
|
Str("component", "truenas_poller").
|
|
Str("action", "disk_temperature_history").
|
|
Str("org_id", orgID).
|
|
Str("connection_id", strings.TrimSpace(entry.connectionID)).
|
|
Err(err).
|
|
Msg("TrueNAS poller failed to read native disk temperature history")
|
|
continue
|
|
}
|
|
for resourceID, points := range nativeHistory {
|
|
if strings.TrimSpace(resourceID) == "" || len(points) == 0 {
|
|
continue
|
|
}
|
|
converted := make([]MetricPoint, len(points))
|
|
for i, point := range points {
|
|
converted[i] = MetricPoint{
|
|
Timestamp: point.Timestamp,
|
|
Value: point.Value,
|
|
}
|
|
}
|
|
history[resourceID] = converted
|
|
}
|
|
}
|
|
if len(history) == 0 {
|
|
return nil
|
|
}
|
|
return history
|
|
}
|
|
|
|
// GuestMetricHistory exposes native TrueNAS host history through the canonical
|
|
// guest-chart boundary when local Pulse history is shallow.
|
|
func (p *TrueNASPoller) GuestMetricHistory(_ *Monitor, orgID string, resourceType string, duration time.Duration) map[string]map[string][]MetricPoint {
|
|
if p == nil || !truenas.IsFeatureEnabled() || strings.TrimSpace(resourceType) != "agent" {
|
|
return nil
|
|
}
|
|
|
|
orgID = strings.TrimSpace(orgID)
|
|
if orgID == "" {
|
|
orgID = "default"
|
|
}
|
|
|
|
entries := p.providerEntriesForOrg(orgID)
|
|
if len(entries) == 0 {
|
|
return nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), defaultTrueNASHistoryReadTimeout)
|
|
defer cancel()
|
|
|
|
history := make(map[string]map[string][]MetricPoint)
|
|
for _, entry := range entries {
|
|
resourceID, nativeHistory, err := entry.provider.SystemMetricHistory(ctx, duration)
|
|
if err != nil {
|
|
log.Warn().
|
|
Str("component", "truenas_poller").
|
|
Str("action", "guest_metric_history").
|
|
Str("resource_type", resourceType).
|
|
Str("org_id", orgID).
|
|
Str("connection_id", strings.TrimSpace(entry.connectionID)).
|
|
Err(err).
|
|
Msg("TrueNAS poller failed to read native guest history")
|
|
continue
|
|
}
|
|
resourceID = strings.TrimSpace(resourceID)
|
|
if resourceID == "" || len(nativeHistory) == 0 {
|
|
continue
|
|
}
|
|
|
|
converted := make(map[string][]MetricPoint, len(nativeHistory))
|
|
for metricType, points := range nativeHistory {
|
|
if strings.TrimSpace(metricType) == "" || len(points) == 0 {
|
|
continue
|
|
}
|
|
series := make([]MetricPoint, len(points))
|
|
for i, point := range points {
|
|
series[i] = MetricPoint{
|
|
Timestamp: point.Timestamp,
|
|
Value: point.Value,
|
|
}
|
|
}
|
|
converted[metricType] = series
|
|
}
|
|
if len(converted) == 0 {
|
|
continue
|
|
}
|
|
history[resourceID] = converted
|
|
}
|
|
if len(history) == 0 {
|
|
return nil
|
|
}
|
|
return history
|
|
}
|
|
|
|
// SnapshotOwnedSources declares source-native ingest ownership for legacy
|
|
// snapshot suppression (default/global call path).
|
|
func (p *TrueNASPoller) SnapshotOwnedSources() []unifiedresources.DataSource {
|
|
return []unifiedresources.DataSource{unifiedresources.SourceTrueNAS}
|
|
}
|
|
|
|
// SnapshotOwnedSourcesForOrg declares source-native ingest ownership for a
|
|
// specific tenant.
|
|
func (p *TrueNASPoller) SnapshotOwnedSourcesForOrg(string) []unifiedresources.DataSource {
|
|
return []unifiedresources.DataSource{unifiedresources.SourceTrueNAS}
|
|
}
|
|
|
|
// SupplementalInventoryReadyAt reports when the current org-scoped TrueNAS
|
|
// inventory has reached a settled initial baseline that has to be reflected in
|
|
// the canonical monitor store before support surfaces consume monitored-system
|
|
// counts.
|
|
func (p *TrueNASPoller) SupplementalInventoryReadyAt(_ *Monitor, orgID string) (time.Time, bool) {
|
|
if p == nil {
|
|
return time.Time{}, true
|
|
}
|
|
|
|
orgID = normalizeTrueNASOrgID(orgID)
|
|
configs := p.activeConnectionConfigsForOrg(orgID)
|
|
if len(configs) == 0 {
|
|
return time.Time{}, true
|
|
}
|
|
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
var watermark time.Time
|
|
statusByConn := p.statusByOrg[orgID]
|
|
for connID := range configs {
|
|
status := statusByConn[connID]
|
|
if status == nil || status.lastAttemptAt.IsZero() {
|
|
return time.Time{}, false
|
|
}
|
|
if status.lastAttemptAt.After(watermark) {
|
|
watermark = status.lastAttemptAt
|
|
}
|
|
}
|
|
|
|
return watermark, true
|
|
}
|
|
|
|
// GetCurrentRecordsForOrg returns the latest known TrueNAS records for the specified org.
|
|
func (p *TrueNASPoller) GetCurrentRecordsForOrg(orgID string) []unifiedresources.IngestRecord {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
|
|
orgID = strings.TrimSpace(orgID)
|
|
if orgID == "" {
|
|
orgID = "default"
|
|
}
|
|
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
recordsByConn := p.cachedRecordsByOrg[orgID]
|
|
if len(recordsByConn) == 0 {
|
|
return nil
|
|
}
|
|
|
|
connectionIDs := make([]string, 0, len(recordsByConn))
|
|
for id := range recordsByConn {
|
|
connectionIDs = append(connectionIDs, id)
|
|
}
|
|
sort.Strings(connectionIDs)
|
|
|
|
total := 0
|
|
for _, id := range connectionIDs {
|
|
total += len(recordsByConn[id])
|
|
}
|
|
if total == 0 {
|
|
return nil
|
|
}
|
|
|
|
records := make([]unifiedresources.IngestRecord, 0, total)
|
|
for _, id := range connectionIDs {
|
|
records = append(records, cloneIngestRecords(recordsByConn[id])...)
|
|
}
|
|
return records
|
|
}
|
|
|
|
func (p *TrueNASPoller) activeConnectionConfigsForOrg(orgID string) map[string]config.TrueNASInstance {
|
|
orgID = normalizeTrueNASOrgID(orgID)
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
|
|
p.mu.Lock()
|
|
cached := cloneTrueNASConnectionConfigMap(p.configsByOrg[orgID])
|
|
p.mu.Unlock()
|
|
if len(cached) > 0 || p.multiTenant == nil {
|
|
return cached
|
|
}
|
|
|
|
persistence, err := p.multiTenant.GetPersistence(orgID)
|
|
if err != nil || persistence == nil {
|
|
return cached
|
|
}
|
|
instances, err := persistence.LoadTrueNASConfig()
|
|
if err != nil {
|
|
return cached
|
|
}
|
|
|
|
active := make(map[string]config.TrueNASInstance)
|
|
for i := range instances {
|
|
instance := instances[i]
|
|
instance.ApplyDefaults()
|
|
if !instance.Enabled {
|
|
continue
|
|
}
|
|
connID := strings.TrimSpace(instance.ID)
|
|
if connID == "" {
|
|
continue
|
|
}
|
|
active[connID] = instance
|
|
}
|
|
return active
|
|
}
|
|
|
|
func cloneTrueNASConnectionConfigMap(
|
|
in map[string]config.TrueNASInstance,
|
|
) map[string]config.TrueNASInstance {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]config.TrueNASInstance, len(in))
|
|
for connID, instance := range in {
|
|
out[connID] = instance
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ControlApp executes a canonical TrueNAS app action for one tenant-scoped
|
|
// API-backed app-container resource and refreshes the cached records afterward.
|
|
func (p *TrueNASPoller) ControlApp(ctx context.Context, orgID, host, appID, action string) (*truenas.App, error) {
|
|
if p == nil {
|
|
return nil, fmt.Errorf("truenas poller is nil")
|
|
}
|
|
if !truenas.IsFeatureEnabled() {
|
|
return nil, fmt.Errorf("truenas integration is disabled")
|
|
}
|
|
|
|
orgID = strings.TrimSpace(orgID)
|
|
if orgID == "" {
|
|
orgID = "default"
|
|
}
|
|
host = strings.TrimSpace(host)
|
|
appID = strings.TrimSpace(appID)
|
|
action = strings.ToLower(strings.TrimSpace(action))
|
|
if appID == "" {
|
|
return nil, fmt.Errorf("truenas app id is required")
|
|
}
|
|
|
|
entry, currentApp, err := p.findProviderEntryForApp(orgID, host, appID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var nextSnapshot *truenas.FixtureSnapshot
|
|
switch action {
|
|
case "start", "stop":
|
|
nextSnapshot, err = entry.provider.ControlApp(ctx, trueNASAppCanonicalID(*currentApp), action)
|
|
case "restart":
|
|
if trueNASAppRunning(currentApp) {
|
|
if _, err = entry.provider.ControlApp(ctx, trueNASAppCanonicalID(*currentApp), "stop"); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
nextSnapshot, err = entry.provider.ControlApp(ctx, trueNASAppCanonicalID(*currentApp), "start")
|
|
default:
|
|
return nil, fmt.Errorf("unsupported truenas app action %q", action)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
records := entry.provider.Records()
|
|
p.mu.Lock()
|
|
if p.cachedRecordsByOrg[orgID] == nil {
|
|
p.cachedRecordsByOrg[orgID] = make(map[string][]unifiedresources.IngestRecord)
|
|
}
|
|
p.cachedRecordsByOrg[orgID][entry.connectionID] = cloneIngestRecords(records)
|
|
p.mu.Unlock()
|
|
p.ingestRecoveryPoints(ctx, orgID, entry.connectionID, entry.provider)
|
|
|
|
if updatedApp, ok := findTrueNASAppSnapshot(nextSnapshot, host, appID); ok {
|
|
appCopy := *updatedApp
|
|
return &appCopy, nil
|
|
}
|
|
appCopy := *currentApp
|
|
return &appCopy, nil
|
|
}
|
|
|
|
// ReadAppLogs executes a canonical bounded log read for one tenant-scoped
|
|
// TrueNAS-managed app-container resource.
|
|
func (p *TrueNASPoller) ReadAppLogs(ctx context.Context, orgID, host, appID, containerRef string, tailLines int) (*truenas.AppLogResult, error) {
|
|
if p == nil {
|
|
return nil, fmt.Errorf("truenas poller is nil")
|
|
}
|
|
if !truenas.IsFeatureEnabled() {
|
|
return nil, fmt.Errorf("truenas integration is disabled")
|
|
}
|
|
|
|
orgID = strings.TrimSpace(orgID)
|
|
if orgID == "" {
|
|
orgID = "default"
|
|
}
|
|
host = strings.TrimSpace(host)
|
|
appID = strings.TrimSpace(appID)
|
|
if appID == "" {
|
|
return nil, fmt.Errorf("truenas app id is required")
|
|
}
|
|
|
|
entry, _, err := p.findProviderEntryForApp(orgID, host, appID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return entry.provider.ReadAppLogs(ctx, appID, containerRef, tailLines)
|
|
}
|
|
|
|
// GetAppConfig reads the current TrueNAS app configuration/runtime shape for
|
|
// one tenant-scoped API-backed app-container resource.
|
|
func (p *TrueNASPoller) GetAppConfig(ctx context.Context, orgID, host, appID string) (*truenas.AppConfigResult, error) {
|
|
if p == nil {
|
|
return nil, fmt.Errorf("truenas poller is nil")
|
|
}
|
|
if !truenas.IsFeatureEnabled() {
|
|
return nil, fmt.Errorf("truenas integration is disabled")
|
|
}
|
|
|
|
orgID = strings.TrimSpace(orgID)
|
|
if orgID == "" {
|
|
orgID = "default"
|
|
}
|
|
host = strings.TrimSpace(host)
|
|
appID = strings.TrimSpace(appID)
|
|
if appID == "" {
|
|
return nil, fmt.Errorf("truenas app id is required")
|
|
}
|
|
|
|
entry, currentApp, err := p.findProviderEntryForApp(orgID, host, appID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result, err := entry.provider.GetAppConfig(ctx, trueNASAppCanonicalID(*currentApp))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if result == nil {
|
|
return nil, fmt.Errorf("truenas app %q configuration is unavailable", appID)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func cloneIngestRecords(records []unifiedresources.IngestRecord) []unifiedresources.IngestRecord {
|
|
if len(records) == 0 {
|
|
return nil
|
|
}
|
|
cloned := make([]unifiedresources.IngestRecord, len(records))
|
|
copy(cloned, records)
|
|
return cloned
|
|
}
|
|
|
|
func (p *TrueNASPoller) providerEntriesForOrg(orgID string) []trueNASPollerProviderEntry {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
entries := make([]trueNASPollerProviderEntry, 0, len(p.providersByOrg[orgID]))
|
|
for connectionID, provider := range p.providersByOrg[orgID] {
|
|
if provider == nil {
|
|
continue
|
|
}
|
|
entries = append(entries, trueNASPollerProviderEntry{
|
|
connectionID: connectionID,
|
|
provider: provider,
|
|
})
|
|
}
|
|
return entries
|
|
}
|
|
|
|
func (p *TrueNASPoller) findProviderEntryForApp(orgID, host, appID string) (trueNASPollerProviderEntry, *truenas.App, error) {
|
|
for _, entry := range p.providerEntriesForOrg(orgID) {
|
|
currentSnapshot := entry.provider.Snapshot()
|
|
currentApp, ok := findTrueNASAppSnapshot(currentSnapshot, host, appID)
|
|
if ok {
|
|
return entry, currentApp, nil
|
|
}
|
|
}
|
|
return trueNASPollerProviderEntry{}, nil, fmt.Errorf("truenas app %q was not found for org %q", appID, orgID)
|
|
}
|
|
|
|
func findTrueNASAppSnapshot(snapshot *truenas.FixtureSnapshot, host, appID string) (*truenas.App, bool) {
|
|
if snapshot == nil {
|
|
return nil, false
|
|
}
|
|
if host != "" && !strings.EqualFold(strings.TrimSpace(snapshot.System.Hostname), host) {
|
|
return nil, false
|
|
}
|
|
for i := range snapshot.Apps {
|
|
app := &snapshot.Apps[i]
|
|
if strings.EqualFold(trueNASAppCanonicalID(*app), appID) || strings.EqualFold(strings.TrimSpace(app.Name), appID) {
|
|
return app, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func trueNASAppCanonicalID(app truenas.App) string {
|
|
if id := strings.TrimSpace(app.ID); id != "" {
|
|
return id
|
|
}
|
|
return strings.TrimSpace(app.Name)
|
|
}
|
|
|
|
func trueNASAppRunning(app *truenas.App) bool {
|
|
if app == nil {
|
|
return false
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(app.State), "RUNNING") {
|
|
return true
|
|
}
|
|
for _, container := range app.Containers {
|
|
if strings.EqualFold(strings.TrimSpace(container.State), "running") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func trueNASProviderConfigChanged(previous, next config.TrueNASInstance) bool {
|
|
return strings.TrimSpace(previous.Host) != strings.TrimSpace(next.Host) ||
|
|
previous.Port != next.Port ||
|
|
strings.TrimSpace(previous.APIKey) != strings.TrimSpace(next.APIKey) ||
|
|
strings.TrimSpace(previous.Username) != strings.TrimSpace(next.Username) ||
|
|
strings.TrimSpace(previous.Password) != strings.TrimSpace(next.Password) ||
|
|
previous.UseHTTPS != next.UseHTTPS ||
|
|
previous.InsecureSkipVerify != next.InsecureSkipVerify ||
|
|
strings.TrimSpace(previous.Fingerprint) != strings.TrimSpace(next.Fingerprint)
|
|
}
|
|
|
|
// classifyTrueNASError wraps a TrueNAS API error in MonitorError for metrics classification.
|
|
func classifyTrueNASError(err error, connectionID string) *internalerrors.MonitorError {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
errType := internalerrors.ErrorTypeAPI
|
|
retryable := true
|
|
|
|
var apiErr *truenas.APIError
|
|
if errors.As(err, &apiErr) {
|
|
switch {
|
|
case apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden:
|
|
errType = internalerrors.ErrorTypeAuth
|
|
retryable = false
|
|
case apiErr.StatusCode == http.StatusRequestTimeout || apiErr.StatusCode == http.StatusGatewayTimeout:
|
|
errType = internalerrors.ErrorTypeTimeout
|
|
default:
|
|
errType = internalerrors.ErrorTypeAPI
|
|
}
|
|
} else {
|
|
// Transport-level errors: timeout takes precedence over generic connection failures.
|
|
var urlErr *url.Error
|
|
if (errors.As(err, &urlErr) && urlErr.Timeout()) || errors.Is(err, context.DeadlineExceeded) {
|
|
errType = internalerrors.ErrorTypeTimeout
|
|
} else {
|
|
var netOpErr *net.OpError
|
|
if errors.As(err, &netOpErr) {
|
|
errType = internalerrors.ErrorTypeConnection
|
|
}
|
|
}
|
|
}
|
|
|
|
return &internalerrors.MonitorError{
|
|
Type: errType,
|
|
Op: "truenas_poll",
|
|
Instance: connectionID,
|
|
Err: err,
|
|
Timestamp: time.Now(),
|
|
Retryable: retryable,
|
|
}
|
|
}
|