Files
T
rcourtman 5d4f51c027 Key TrueNAS systems by configured connection, not reported hostname
Two TrueNAS systems that report the same hostname collapsed into one
flapping resource (#1573, #1575): systemSourceID keyed the system by the
snapshot-reported hostname, every child pool/dataset/app/VM/share/disk
was scoped under it, and the client minted the system's machine key from
the DMI serial with a hostname fallback, so serial-less systems sharing
a hostname (and DR clones sharing a serial) also fully merged in the
identity matcher.

The system source ID now scopes to the connection ID the poller passes
through NewLiveProviderForConnection; the hostname arm survives only for
fixture snapshots, which carry no connection. The ingest identity drops
the machine key entirely (DR clones share DMI serials, and vendor
placeholder serials collide across unrelated machines), the client no
longer falls back to the hostname for MachineID, and ingest skips
identity-pin completion for SourceTrueNAS so a stale pre-fix pin or a
same-named agent host's pin cannot lend the system a machine key and
re-merge what connection scoping keeps apart. Agent.AgentID and the
native metric history keys follow the source ID minus its system:
prefix, so BuildMetricsTarget keeps resolving one series.

Rows minted under the retired hostname-keyed derivation re-key once via
record-declared succession: records name their old canonical IDs in
IngestRecord.SupersededCanonicalIDs and IngestRecords applies the
existing ApplyCanonicalIDSuccessions semantics (operator state and
action audits re-key, the superseded pin drops, never while the old ID
still belongs to a live resource, journal rows are never rewritten).
Alert identities and persisted metric series under old child IDs are
not re-keyed: active alerts re-arm under the new IDs and TrueNAS host
charts are backed by native read-through history.
2026-07-14 11:51:55 +01:00

133 lines
5.3 KiB
Go

package unifiedresources
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
)
func normalizeSourceID(sourceID string) string {
return strings.TrimSpace(sourceID)
}
// SourceSpecificID returns the deterministic ID used for non-host resources when the registry
// does not have a canonical identity to key off of.
//
// This matches the ResourceRegistry's internal ID derivation:
// stable := fmt.Sprintf("%s:%s", source, sourceID)
// id := fmt.Sprintf("%s-%s", resourceType, hex(sha256(stable)[:8]))
func SourceSpecificID(resourceType ResourceType, source DataSource, sourceID string) string {
resourceType = CanonicalResourceType(resourceType)
stable := fmt.Sprintf("%s:%s", source, normalizeSourceID(sourceID))
hash := sha256.Sum256([]byte(stable))
return fmt.Sprintf("%s-%s", resourceType, hex.EncodeToString(hash[:8]))
}
// MachineIdentityCanonicalID returns the canonical ID the registry mints for
// a resource keyed by its machine identity, the strongest arm of the
// chooseNewID ladder. Providers use it to name superseded canonical IDs
// (IngestRecord.SupersededCanonicalIDs) when a derivation correction retires
// a machine key, so operator-owned rows follow the resource to its new ID.
func MachineIdentityCanonicalID(resourceType ResourceType, machineID string) string {
return buildHashID(resourceType, "machine:"+strings.TrimSpace(machineID))
}
// CanonicalResourceID returns the canonical v6 resource identifier.
func CanonicalResourceID(id string) string {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
return ""
}
return trimmed
}
// ResourceIdentityPin is the durable identity→canonical-ID record persisted in
// the resource store (resource_identities table). It exists because canonical
// ID derivation keys off the strongest identity field known at mint time, and
// merged-source hosts (PVE node + pulse-agent) expose different identity
// subsets depending on which records are present when the registry rebuilds:
// the agent record knows the machine ID, the Proxmox node record only knows
// cluster+hostname. Pins let a rebuild that only sees the weak subset recover
// the strong keys, so the same physical host derives the same canonical ID in
// every boot, in every registry instance.
type ResourceIdentityPin struct {
CanonicalID string
ResourceType ResourceType
MachineID string
DMIUUID string
ClusterName string
// Hostname is the normalized full hostname (NormalizeFullHostname).
// Dotted hostnames are preserved so distinct machines that share a short
// name (cloud.rnd-lax1 vs cloud.gce-or1) keep distinct pins. Rows written
// before the fix for #1559 hold the collapsed short name; they heal in
// place on the host's next pin persist.
Hostname string
}
func (p ResourceIdentityPin) normalized() ResourceIdentityPin {
p.CanonicalID = CanonicalResourceID(p.CanonicalID)
p.ResourceType = CanonicalResourceType(p.ResourceType)
p.MachineID = strings.TrimSpace(p.MachineID)
p.DMIUUID = strings.TrimSpace(p.DMIUUID)
p.ClusterName = strings.TrimSpace(p.ClusterName)
p.Hostname = NormalizeFullHostname(p.Hostname)
return p
}
func (p ResourceIdentityPin) hasStrongKey() bool {
return p.MachineID != "" || p.DMIUUID != "" || (p.ClusterName != "" && p.Hostname != "")
}
// EraIDs returns every canonical ID this pin's identity keys derive under the
// historical chooseNewID ladder (machine > dmi > cluster+hostname > hostname).
// Change-journal rows written in boots that only knew a weaker key sit under
// the weaker key's hash; expanding a read to the full era set merges those
// journal eras without rewriting history.
func (p ResourceIdentityPin) EraIDs() []string {
p = p.normalized()
ids := make([]string, 0, 7)
if p.CanonicalID != "" {
ids = append(ids, p.CanonicalID)
}
if p.MachineID != "" {
ids = append(ids, buildHashID(p.ResourceType, "machine:"+p.MachineID))
}
if p.DMIUUID != "" {
ids = append(ids, buildHashID(p.ResourceType, "dmi:"+p.DMIUUID))
}
if p.Hostname != "" {
// The historical chooseNewID ladder hashed the short hostname; the
// pin now preserves the full dotted name, so derive eras for both so
// journal rows written under short-hostname IDs stay readable.
for _, hostname := range uniqueTrimmed(p.Hostname, NormalizeHostname(p.Hostname)) {
if p.ClusterName != "" {
ids = append(ids, buildHashID(p.ResourceType, fmt.Sprintf("cluster:%s:%s", p.ClusterName, hostname)))
}
ids = append(ids, buildHashID(p.ResourceType, "hostname:"+hostname))
}
}
return uniqueTrimmed(ids...)
}
// identityPinForResource derives the persistable pin for a canonical resource.
// Only host resources with at least one strong identity key are pinned; a
// hostname-only host has a single possible derivation and needs no pin.
func identityPinForResource(resource *Resource) (ResourceIdentityPin, bool) {
if resource == nil || CanonicalResourceType(resource.Type) != ResourceTypeAgent {
return ResourceIdentityPin{}, false
}
pin := ResourceIdentityPin{
CanonicalID: resource.ID,
ResourceType: ResourceTypeAgent,
MachineID: resource.Identity.MachineID,
DMIUUID: resource.Identity.DMIUUID,
ClusterName: resource.Identity.ClusterName,
Hostname: firstIdentityHostname(resource.Identity),
}.normalized()
if pin.CanonicalID == "" || !pin.hasStrongKey() {
return ResourceIdentityPin{}, false
}
return pin, true
}