From 5f3cfb2f3e0a357dd8bcac233b4d213a78b2016c Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Wed, 15 Jul 2026 19:38:44 -0400 Subject: [PATCH 1/3] fix(intelligence): no-clobber merge for discovery + OS intelligence A degraded collection (SSH connects but individual probes fail or are sudo-denied) used to overwrite previously-good data with empty values, and both current-state stores keep no history. Fix: absence-of-observation is now distinct from observed-empty, keyed on whether the probe actually ran. - Discovery: each probe records its fact CATEGORY in SystemFacts.Observed; persist() reads the prior host_system_info row in-tx and carries forward the prior value for any unobserved category (observed values, even 0/false, write normally). Correctly handles cases a naive COALESCE can't (a genuinely full disk, apparmor=false). Spec system-host-discovery v1.5.0 (C-08, AC-24). - OS intelligence: RunCycle merges prior into the snapshot for unobserved categories BEFORE both Diff and persist, so a failed probe neither emits a false change event nor blanks the stored snapshot. Observed set is transient (json:"-"). Spec system-os-intelligence v1.2.0 (C-03, AC-18). - Verified the intelligence scheduler's recordSuccess only touches next_intelligence_at and never clobbers the snapshot. --- internal/intelligence/collector/collector.go | 74 ++++++++++- internal/intelligence/collector/merge_test.go | 68 ++++++++++ internal/intelligence/collector/types.go | 28 ++++ internal/intelligence/discovery/discovery.go | 122 +++++++++++++++++- .../discovery/discovery_db_test.go | 74 +++++++++++ specs/system/host-discovery.spec.yaml | 8 +- specs/system/os-intelligence.spec.yaml | 8 +- 7 files changed, 375 insertions(+), 7 deletions(-) create mode 100644 internal/intelligence/collector/merge_test.go diff --git a/internal/intelligence/collector/collector.go b/internal/intelligence/collector/collector.go index eff67ca3..c85753cc 100644 --- a/internal/intelligence/collector/collector.go +++ b/internal/intelligence/collector/collector.go @@ -212,6 +212,12 @@ func (s *Service) RunCycle(ctx context.Context, hostID uuid.UUID) ([]Event, erro return nil, err } + // No-clobber (spec C-03, v1.2.0): carry forward the prior value for any + // category this cycle did NOT observe, BEFORE diffing and persisting, so a + // failed or denied probe neither emits a false change event nor blanks the + // stored snapshot. + snapshot = mergeUnobserved(snapshot, prior) + events := Diff(prior, snapshot) if err := s.persist(ctx, hostID, snapshot, events); err != nil { return nil, err @@ -293,9 +299,10 @@ func (s *Service) runCycleWithTransport(ctx context.Context, hf hostFacts) (Snap } } - snap := Snapshot{CollectedAt: time.Now().UTC()} + snap := Snapshot{CollectedAt: time.Now().UTC(), Observed: map[SnapCategory]bool{}} if out, code, err := sess.Run(ctx, "cat /etc/passwd"); err == nil && code == 0 { + snap.Observed[SnapUsers] = true // Spec v1.1.0 C-09: sudo -n first; sudo -S -k with cred.Password // on fallback if policy + credential allow. shadow, scode, used, observed, serr := owssh.RunSudo(ctx, sess, hf.Cred, policy, sudoPrefer, "cat /etc/shadow") @@ -314,11 +321,13 @@ func (s *Service) runCycleWithTransport(ctx context.Context, hf hostFacts) (Snap if out, code, err := sess.Run(ctx, "getent group"); err == nil && code == 0 { snap.Groups = parseGroupOutput(out) + snap.Observed[SnapGroups] = true } if out, code, err := sess.Run(ctx, "ss -tln"); err == nil && code == 0 { ports, _ := ParseListeningPorts(out) snap.ListeningPorts = ports + snap.Observed[SnapPorts] = true } // Network interfaces: `ip -j addr` gives addresses + state + MAC + @@ -342,12 +351,14 @@ func (s *Service) runCycleWithTransport(ctx context.Context, hf hostFacts) (Snap stats = ParseSysfsNetStats(sout) } snap.NetworkInterfaces = MergeNetworkInterfaces(ifaces, stats) + snap.Observed[SnapInterfaces] = true } } if out, code, err := sess.Run(ctx, "ip -j route show 2>/dev/null"); err == nil && code == 0 { if routes, perr := ParseIPRouteJSON(out); perr == nil { snap.Routes = routes + snap.Observed[SnapRoutes] = true } } @@ -386,6 +397,9 @@ func (s *Service) runCycleWithTransport(ctx context.Context, hf hostFacts) (Snap fi ` if out, code, err := sess.Run(ctx, fwRuleCmd); err == nil && code == 0 { + // The probe ran: this is an observation (the value is a real count, or + // -1 for "no engine"). Only a probe that could not run carries forward. + snap.Observed[SnapFirewall] = true if n, ok := parseFirewallRuleCount(out); ok { nn := n snap.FirewallRuleCount = &nn @@ -395,14 +409,17 @@ func (s *Service) runCycleWithTransport(ctx context.Context, hf hostFacts) (Snap if out, code, err := sess.Run(ctx, "rpm -qa --queryformat='%{NAME} %{VERSION}-%{RELEASE}\\n' 2>/dev/null || dpkg -l 2>/dev/null"); err == nil && code == 0 { pkgs, _ := ParseInstalledPackages(out) snap.Packages = pkgs + snap.Observed[SnapPackages] = true } if out, code, err := sess.Run(ctx, "systemctl list-units --type=service --all --no-legend --plain"); err == nil && code == 0 { snap.Services = parseSystemctlUnits(out) + snap.Observed[SnapServices] = true } if out, code, err := sess.Run(ctx, "uname -r"); err == nil && code == 0 { snap.KernelRelease = strings.TrimSpace(string(out)) + snap.Observed[SnapKernel] = true } // Reboot marker — present on Debian-family; some RHEL setups expose @@ -413,10 +430,12 @@ func (s *Service) runCycleWithTransport(ctx context.Context, hf hostFacts) (Snap if out, code, err := sess.Run(ctx, "cat /proc/uptime"); err == nil && code == 0 { snap.UptimeSeconds = parseUptime(out) + snap.Observed[SnapUptime] = true } if out, code, err := sess.Run(ctx, "cat /proc/mounts"); err == nil && code == 0 { snap.Mountpoints = parseProcMounts(out) + snap.Observed[SnapMounts] = true } // Config hashes — small fixed set. sha256 keeps the JSONB short. @@ -445,6 +464,12 @@ func (s *Service) runCycleWithTransport(ctx context.Context, hf hostFacts) (Snap } } } + // Config hashes are partial by nature (sudo-gated /etc/shadow may drop); + // treat the category as observed only when at least one file hashed, so a + // fully-denied run carries forward the prior hashes rather than blanking. + if len(snap.ConfigHashes) > 0 { + snap.Observed[SnapConfig] = true + } // TODO(v1.2): the firewall-rule probe embeds three `sudo -n` calls // inside one shell heredoc. Wrapping them through ssh.RunSudo @@ -464,6 +489,53 @@ func (s *Service) runCycleWithTransport(ctx context.Context, hf hostFacts) (Snap return snap, sudoFallbackCount, nil } +// mergeUnobserved carries forward prior values for every category the current +// cycle did not observe, so a failed or denied probe never overwrites good data +// with an empty result. An observed category keeps this cycle's value even when +// genuinely empty (a real observation). A nil Observed map treats all +// categories as unobserved — the safe default: preserve everything rather than +// blank. RebootRequired and CollectedAt always come from this cycle. +func mergeUnobserved(snap, prior Snapshot) Snapshot { + obs := snap.Observed + if !obs[SnapUsers] { + snap.Users = prior.Users + } + if !obs[SnapGroups] { + snap.Groups = prior.Groups + } + if !obs[SnapPorts] { + snap.ListeningPorts = prior.ListeningPorts + } + if !obs[SnapInterfaces] { + snap.NetworkInterfaces = prior.NetworkInterfaces + } + if !obs[SnapRoutes] { + snap.Routes = prior.Routes + } + if !obs[SnapFirewall] { + snap.FirewallRuleCount = prior.FirewallRuleCount + } + if !obs[SnapPackages] { + snap.Packages = prior.Packages + } + if !obs[SnapServices] { + snap.Services = prior.Services + } + if !obs[SnapKernel] { + snap.KernelRelease = prior.KernelRelease + } + if !obs[SnapUptime] { + snap.UptimeSeconds = prior.UptimeSeconds + } + if !obs[SnapMounts] { + snap.Mountpoints = prior.Mountpoints + } + if !obs[SnapConfig] { + snap.ConfigHashes = prior.ConfigHashes + } + return snap +} + // loadPriorSnapshot reads the prior cycle's snapshot from // host_intelligence_state. Missing row → empty Snapshot. func (s *Service) loadPriorSnapshot(ctx context.Context, hostID uuid.UUID) (Snapshot, error) { diff --git a/internal/intelligence/collector/merge_test.go b/internal/intelligence/collector/merge_test.go new file mode 100644 index 00000000..c00a8ca6 --- /dev/null +++ b/internal/intelligence/collector/merge_test.go @@ -0,0 +1,68 @@ +// @spec system-os-intelligence +// +// AC traceability (this file): +// +// AC-18 TestMergeUnobserved_NoClobberAndNoFalseEvents + +package collector + +import "testing" + +// @ac AC-18 +// AC-18: an unobserved category carries forward its prior value (no clobber, +// no false change event); an observed category keeps this cycle's value even +// when genuinely empty; a nil Observed map carries forward everything. +func TestMergeUnobserved_NoClobberAndNoFalseEvents(t *testing.T) { + t.Run("system-os-intelligence/AC-18", func(t *testing.T) { + prior := Snapshot{ + Packages: map[string]string{"openssh": "9.0"}, + Services: map[string]string{"sshd": "active"}, + Users: map[string]UserSnapshot{"root": {}}, + } + + // This cycle observed ONLY packages; services/users probes failed (nil). + cycle := Snapshot{ + Packages: map[string]string{"openssh": "9.6"}, + Observed: map[SnapCategory]bool{SnapPackages: true}, + } + merged := mergeUnobserved(cycle, prior) + + // Unobserved categories carried forward, not blanked. + if merged.Services["sshd"] != "active" { + t.Errorf("services not carried forward: %+v", merged.Services) + } + if _, ok := merged.Users["root"]; !ok { + t.Errorf("users not carried forward: %+v", merged.Users) + } + // Observed category kept this cycle's value. + if merged.Packages["openssh"] != "9.6" { + t.Errorf("packages = %+v, want observed 9.6", merged.Packages) + } + + // Diff against prior sees only the real package change, no false + // service/user "removed" events from the failed probes. + events := Diff(prior, merged) + if len(events) != 1 || events[0].Code != "system.package.updated" { + t.Fatalf("events = %+v, want exactly one system.package.updated", events) + } + + // Observed-but-empty overwrites (a real observation of "no services"). + cycle2 := Snapshot{ + Services: map[string]string{}, + Observed: map[SnapCategory]bool{SnapServices: true}, + } + merged2 := mergeUnobserved(cycle2, prior) + if len(merged2.Services) != 0 { + t.Errorf("observed-empty services should overwrite, got %+v", merged2.Services) + } + if merged2.Packages["openssh"] != "9.0" { + t.Errorf("packages (unobserved) not carried forward in cycle2: %+v", merged2.Packages) + } + + // Nil Observed → carry forward everything (safe default). + merged3 := mergeUnobserved(Snapshot{}, prior) + if merged3.Packages["openssh"] != "9.0" || merged3.Services["sshd"] != "active" { + t.Errorf("nil Observed should carry forward all: %+v", merged3) + } + }) +} diff --git a/internal/intelligence/collector/types.go b/internal/intelligence/collector/types.go index 260faf36..82831a82 100644 --- a/internal/intelligence/collector/types.go +++ b/internal/intelligence/collector/types.go @@ -73,8 +73,36 @@ type Snapshot struct { // CollectedAt is when the snapshot was captured. Set by the // service, not the parsers. CollectedAt time.Time `json:"collected_at,omitempty"` + + // Observed records which snapshot CATEGORIES this cycle actually + // collected (the probe ran and returned usable output). It is transient + // (`json:"-"`, never stored): before Diff + persist, RunCycle carries + // forward the prior stored value for any category NOT observed, so a + // failed or denied probe never blanks previously-good data (spec C-03, + // v1.2.0). An observed category keeps this cycle's value even when + // genuinely empty — that is a real observation. + Observed map[SnapCategory]bool `json:"-"` } +// SnapCategory groups Snapshot fields by the probe that collects them, so the +// no-clobber merge can carry forward an unobserved category's prior value. +type SnapCategory string + +const ( + SnapUsers SnapCategory = "users" + SnapGroups SnapCategory = "groups" + SnapPorts SnapCategory = "listening_ports" + SnapInterfaces SnapCategory = "network_interfaces" + SnapRoutes SnapCategory = "routes" + SnapFirewall SnapCategory = "firewall_rule_count" + SnapPackages SnapCategory = "packages" + SnapServices SnapCategory = "services" + SnapKernel SnapCategory = "kernel_release" + SnapUptime SnapCategory = "uptime" + SnapMounts SnapCategory = "mountpoints" + SnapConfig SnapCategory = "config_hashes" +) + // ListeningPort is one entry from `ss -tln`. type ListeningPort struct { Protocol string `json:"protocol"` // tcp | udp diff --git a/internal/intelligence/discovery/discovery.go b/internal/intelligence/discovery/discovery.go index 1426c7dc..0120aa30 100644 --- a/internal/intelligence/discovery/discovery.go +++ b/internal/intelligence/discovery/discovery.go @@ -114,8 +114,34 @@ type SystemFacts struct { FirewallStatus string CollectedAt time.Time + + // Observed records which fact CATEGORIES this run actually collected (the + // probe ran and returned usable output). persist() carries forward the + // prior stored value for any category NOT observed, so a failed or denied + // probe never blanks previously-good data (spec C-08, v1.5.0). An observed + // category keeps the run's values even when genuinely empty/zero — that is + // a real observation, not a missing one. + Observed map[FactCategory]bool } +// FactCategory groups host_system_info columns by the probe that collects them, +// so persist() can merge at category granularity: an unobserved category +// retains its prior stored value rather than being overwritten with an empty +// read. +type FactCategory string + +const ( + CatOSRelease FactCategory = "os_release" + CatUname FactCategory = "uname" + CatMemory FactCategory = "memory" + CatDisk FactCategory = "disk" + CatHostname FactCategory = "hostname" + CatFQDN FactCategory = "fqdn" + CatSELinux FactCategory = "selinux" + CatAppArmor FactCategory = "apparmor" + CatFirewall FactCategory = "firewall" +) + // AuditEmitFunc is the audit emission seam. Production wires it to // audit.Emit; tests use a recorder that counts emissions. type AuditEmitFunc func(ctx context.Context, code audit.Code, ev audit.Event) @@ -299,7 +325,7 @@ func (s *Service) discoverWithTransport(ctx context.Context, hf hostFacts) (Syst } defer sess.Close() - facts := SystemFacts{CollectedAt: time.Now().UTC()} + facts := SystemFacts{CollectedAt: time.Now().UTC(), Observed: map[FactCategory]bool{}} // World-readable probes — sudo not required. if out, code, err := sess.Run(ctx, "cat /etc/os-release"); err == nil && code == 0 { @@ -312,6 +338,7 @@ func (s *Service) discoverWithTransport(ctx context.Context, hf hostFacts) (Syst facts.OSPrettyName = osf.OSPrettyName facts.PlatformIdentifier = osf.PlatformIdentifier facts.OSFamily = deriveOSFamily(osf.OSID, osf.OSIDLike) + facts.Observed[CatOSRelease] = true } if out, code, err := sess.Run(ctx, "uname -srvm"); err == nil && code == 0 { @@ -320,6 +347,7 @@ func (s *Service) discoverWithTransport(ctx context.Context, hf hostFacts) (Syst facts.KernelRelease = uf.KernelRelease facts.KernelVersion = uf.KernelVersion facts.Architecture = uf.Architecture + facts.Observed[CatUname] = true } if out, code, err := sess.Run(ctx, "cat /proc/meminfo"); err == nil && code == 0 { @@ -327,6 +355,7 @@ func (s *Service) discoverWithTransport(ctx context.Context, hf hostFacts) (Syst facts.MemTotalMB = mi.MemTotalMB facts.MemAvailableMB = mi.MemAvailableMB facts.SwapTotalMB = mi.SwapTotalMB + facts.Observed[CatMemory] = true } if out, code, err := sess.Run(ctx, "df -BG /"); err == nil && code == 0 { @@ -334,21 +363,27 @@ func (s *Service) discoverWithTransport(ctx context.Context, hf hostFacts) (Syst facts.DiskTotalGB = total facts.DiskUsedGB = used facts.DiskFreeGB = free + facts.Observed[CatDisk] = true } if out, code, err := sess.Run(ctx, "hostname"); err == nil && code == 0 { facts.Hostname = strings.TrimSpace(string(out)) + facts.Observed[CatHostname] = true } if out, code, err := sess.Run(ctx, "hostname -f"); err == nil && code == 0 { facts.FQDN = strings.TrimSpace(string(out)) + facts.Observed[CatFQDN] = true } if out, code, err := sess.Run(ctx, "getenforce"); err == nil && code == 0 { facts.SELinuxStatus = strings.TrimSpace(string(out)) + facts.Observed[CatSELinux] = true } if _, code, err := sess.Run(ctx, "aa-status --enabled"); err == nil { - // aa-status --enabled exits 0 when enabled, 1 when not. + // aa-status --enabled exits 0 when enabled, 1 when not. Either exit is + // a genuine observation of the AppArmor state. facts.AppArmorEnabled = code == 0 + facts.Observed[CatAppArmor] = true } // Firewall introspection — needs sudo on most distros. Per spec C-03 @@ -377,6 +412,7 @@ func (s *Service) discoverWithTransport(ctx context.Context, hf hostFacts) (Syst if ok { facts.FirewallService = svc facts.FirewallStatus = status + facts.Observed[CatFirewall] = true } if s.profiles != nil && learnedSudo != connprofile.SudoUnknown && learnedSudo != knownSudo { _ = s.profiles.RecordSudoMode(ctx, hf.HostID, learnedSudo) @@ -399,6 +435,16 @@ func (s *Service) persist(ctx context.Context, hostID uuid.UUID, f SystemFacts) } defer func() { _ = tx.Rollback(ctx) }() + // No-clobber merge (spec C-08, v1.5.0): carry forward the prior stored value + // for any category this run did NOT observe, so a failed or denied probe + // never blanks previously-good data. Read + write inside the same tx so the + // merge is atomic. First discovery (no prior row) skips the merge. + if prior, ok, perr := readPriorFacts(ctx, tx, hostID); perr != nil { + return fmt.Errorf("discovery: read prior facts: %w", perr) + } else if ok { + mergeUnobserved(&f, prior) + } + const upsertSystemInfo = ` INSERT INTO host_system_info ( host_id, @@ -493,6 +539,78 @@ func (s *Service) persist(ctx context.Context, hostID uuid.UUID, f SystemFacts) return nil } +// readPriorFacts loads the current host_system_info row (if any) so persist can +// carry forward categories a run did not observe. ok=false means no row yet +// (first discovery), in which case there is nothing to preserve. +func readPriorFacts(ctx context.Context, tx pgx.Tx, hostID uuid.UUID) (SystemFacts, bool, error) { + var f SystemFacts + err := tx.QueryRow(ctx, ` + SELECT COALESCE(os_name, ''), COALESCE(os_version, ''), COALESCE(os_version_full, ''), + COALESCE(os_id, ''), COALESCE(os_id_like, ''), COALESCE(os_pretty_name, ''), + COALESCE(platform_identifier, ''), COALESCE(os_family, ''), + COALESCE(kernel_name, ''), COALESCE(kernel_release, ''), COALESCE(kernel_version, ''), + COALESCE(architecture, ''), + COALESCE(mem_total_mb, 0), COALESCE(mem_available_mb, 0), COALESCE(swap_total_mb, 0), + COALESCE(disk_total_gb, 0), COALESCE(disk_used_gb, 0), COALESCE(disk_free_gb, 0), + COALESCE(hostname, ''), COALESCE(fqdn, ''), + COALESCE(selinux_status, ''), COALESCE(apparmor_enabled, false), + COALESCE(firewall_service, ''), COALESCE(firewall_status, '') + FROM host_system_info WHERE host_id = $1`, hostID).Scan( + &f.OSName, &f.OSVersion, &f.OSVersionFull, &f.OSID, &f.OSIDLike, &f.OSPrettyName, + &f.PlatformIdentifier, &f.OSFamily, + &f.KernelName, &f.KernelRelease, &f.KernelVersion, &f.Architecture, + &f.MemTotalMB, &f.MemAvailableMB, &f.SwapTotalMB, + &f.DiskTotalGB, &f.DiskUsedGB, &f.DiskFreeGB, + &f.Hostname, &f.FQDN, + &f.SELinuxStatus, &f.AppArmorEnabled, + &f.FirewallService, &f.FirewallStatus, + ) + if errors.Is(err, pgx.ErrNoRows) { + return SystemFacts{}, false, nil + } + if err != nil { + return SystemFacts{}, false, err + } + return f, true, nil +} + +// mergeUnobserved carries forward prior values for every category the current +// run did not observe, so persist never overwrites good data with a failed or +// denied probe's empty result. A nil Observed map treats all categories as +// unobserved (the safe default: preserve everything rather than blank). +func mergeUnobserved(f *SystemFacts, prior SystemFacts) { + if !f.Observed[CatOSRelease] { + f.OSName, f.OSVersion, f.OSVersionFull = prior.OSName, prior.OSVersion, prior.OSVersionFull + f.OSID, f.OSIDLike, f.OSPrettyName = prior.OSID, prior.OSIDLike, prior.OSPrettyName + f.PlatformIdentifier, f.OSFamily = prior.PlatformIdentifier, prior.OSFamily + } + if !f.Observed[CatUname] { + f.KernelName, f.KernelRelease = prior.KernelName, prior.KernelRelease + f.KernelVersion, f.Architecture = prior.KernelVersion, prior.Architecture + } + if !f.Observed[CatMemory] { + f.MemTotalMB, f.MemAvailableMB, f.SwapTotalMB = prior.MemTotalMB, prior.MemAvailableMB, prior.SwapTotalMB + } + if !f.Observed[CatDisk] { + f.DiskTotalGB, f.DiskUsedGB, f.DiskFreeGB = prior.DiskTotalGB, prior.DiskUsedGB, prior.DiskFreeGB + } + if !f.Observed[CatHostname] { + f.Hostname = prior.Hostname + } + if !f.Observed[CatFQDN] { + f.FQDN = prior.FQDN + } + if !f.Observed[CatSELinux] { + f.SELinuxStatus = prior.SELinuxStatus + } + if !f.Observed[CatAppArmor] { + f.AppArmorEnabled = prior.AppArmorEnabled + } + if !f.Observed[CatFirewall] { + f.FirewallService, f.FirewallStatus = prior.FirewallService, prior.FirewallStatus + } +} + // publishBusEvent emits HostDiscovered on the eventbus. Best-effort — // bus errors are not surfaced (matches HeartbeatPulse semantics). func (s *Service) publishBusEvent(hostID uuid.UUID, f SystemFacts) { diff --git a/internal/intelligence/discovery/discovery_db_test.go b/internal/intelligence/discovery/discovery_db_test.go index 8cb66bb6..e0ddcf07 100644 --- a/internal/intelligence/discovery/discovery_db_test.go +++ b/internal/intelligence/discovery/discovery_db_test.go @@ -3,6 +3,7 @@ // AC traceability (this file): // // AC-08 TestDiscover_HappyPath_PersistsAndPublishes +// AC-24 TestDiscover_NoClobberOnPartialCollection package discovery @@ -132,3 +133,76 @@ func TestDiscover_HappyPath_PersistsAndPublishes(t *testing.T) { } }) } + +// @ac AC-24 +// AC-24: a partial-collection run does not blank previously-good categories. +func TestDiscover_NoClobberOnPartialCollection(t *testing.T) { + t.Run("system-host-discovery/AC-24", func(t *testing.T) { + pool, hostID, _ := freshDBHost(t) + ctx := context.Background() + svc := &Service{pool: pool} + allObserved := map[FactCategory]bool{ + CatOSRelease: true, CatUname: true, CatMemory: true, CatDisk: true, + CatHostname: true, CatFQDN: true, CatSELinux: true, CatAppArmor: true, CatFirewall: true, + } + + // First run: fully observed fingerprint. + full := SystemFacts{ + OSName: "Rocky Linux", OSVersion: "9.4", OSID: "rocky", OSFamily: "rhel", + KernelRelease: "5.14.0-570", Architecture: "x86_64", + MemTotalMB: 8000, DiskTotalGB: 100, DiskUsedGB: 60, DiskFreeGB: 40, + Hostname: "web01", SELinuxStatus: "Enforcing", AppArmorEnabled: false, + FirewallService: "firewalld", FirewallStatus: "active", + CollectedAt: time.Now().UTC(), Observed: allObserved, + } + if err := svc.persist(ctx, hostID, full); err != nil { + t.Fatalf("first persist: %v", err) + } + + // Second run: only os_release + uname observed; firewall/disk/selinux + // probes failed (fields empty, categories absent from Observed). + partial := SystemFacts{ + OSName: "Rocky Linux", OSVersion: "9.5", OSID: "rocky", OSFamily: "rhel", + KernelRelease: "5.14.0-580", Architecture: "x86_64", + CollectedAt: time.Now().UTC(), + Observed: map[FactCategory]bool{CatOSRelease: true, CatUname: true}, + } + if err := svc.persist(ctx, hostID, partial); err != nil { + t.Fatalf("second persist: %v", err) + } + + var osVer, kernel, fwSvc, selinux string + var diskFree int + if err := pool.QueryRow(ctx, ` + SELECT os_version, kernel_release, COALESCE(firewall_service, ''), + COALESCE(selinux_status, ''), COALESCE(disk_free_gb, 0) + FROM host_system_info WHERE host_id = $1`, hostID). + Scan(&osVer, &kernel, &fwSvc, &selinux, &diskFree); err != nil { + t.Fatalf("read host_system_info: %v", err) + } + // Observed categories updated. + if osVer != "9.5" { + t.Errorf("os_version = %q, want 9.5 (observed, updated)", osVer) + } + if kernel != "5.14.0-580" { + t.Errorf("kernel_release = %q, want 5.14.0-580 (observed, updated)", kernel) + } + // Unobserved categories retained prior values, NOT blanked. + if fwSvc != "firewalld" { + t.Errorf("firewall_service = %q, want firewalld retained (unobserved)", fwSvc) + } + if selinux != "Enforcing" { + t.Errorf("selinux_status = %q, want Enforcing retained (unobserved)", selinux) + } + if diskFree != 40 { + t.Errorf("disk_free_gb = %d, want 40 retained (unobserved)", diskFree) + } + + // hosts.os_* also updated from the merged (observed) facts. + var hOsVer string + _ = pool.QueryRow(ctx, `SELECT COALESCE(os_version, '') FROM hosts WHERE id = $1`, hostID).Scan(&hOsVer) + if hOsVer != "9.5" { + t.Errorf("hosts.os_version = %q, want 9.5", hOsVer) + } + }) +} diff --git a/specs/system/host-discovery.spec.yaml b/specs/system/host-discovery.spec.yaml index bc67aa41..c4c3d71a 100644 --- a/specs/system/host-discovery.spec.yaml +++ b/specs/system/host-discovery.spec.yaml @@ -1,7 +1,7 @@ spec: id: system-host-discovery title: Host OS fingerprint discovery via SSH - version: "1.4.0" + version: "1.5.0" status: approved tier: 1 @@ -115,7 +115,7 @@ spec: type: security enforcement: error - id: C-08 - description: 'host_system_info table MUST upsert keyed by host_id (UNIQUE constraint). Subsequent Discover runs UPDATE the existing row + bump collected_at + updated_at. The table NEVER appends — it represents the CURRENT fingerprint, not a history' + description: 'host_system_info table MUST upsert keyed by host_id (UNIQUE constraint). Subsequent Discover runs UPDATE the existing row + bump collected_at + updated_at. The table NEVER appends — it represents the CURRENT fingerprint, not a history. v1.5.0 no-clobber: a run MUST NOT overwrite a previously-good category with a failed/denied probe''s empty read. A run records which fact CATEGORIES it OBSERVED (the probe ran and returned usable output: os_release, uname, memory, disk, hostname, fqdn, selinux, apparmor, firewall); persist carries forward the prior stored value for any category NOT observed, and writes the run''s value (even a genuine empty/zero, which is a real observation) for an observed category. The read-and-merge happens inside the persist transaction. The denormalized hosts.os_* columns (C-09) follow the same rule via the merged facts. A first discovery (no prior row) has nothing to carry forward' type: technical enforcement: error - id: C-09 @@ -204,3 +204,7 @@ spec: description: 'v1.4.0 — emitAuditSuccess attributes the trigger: called with a context carrying a bound operator identity (auth.SetIdentity), the emitted audit.HostDiscoveryCompleted event has actor_type="user" and actor_id equal to that identity''s id; called with an anonymous/background context (no bound identity, i.e. the scheduled sweep), it has actor_type="system". The event is never emitted with an empty actor_type.' priority: high references_constraints: [C-07] + - id: AC-24 + description: 'v1.5.0 no-clobber merge: given a host whose host_system_info is fully populated, a second Discover run that observes only some categories (e.g. os_release + uname) and whose other probes failed/were-denied (firewall, disk, selinux fields empty, those categories absent from Observed) leaves the unobserved categories'' columns at their prior values (firewall_service, disk_free_gb, selinux_status retained) while the observed categories update (os_version, kernel_release change). A first Discover with no prior row persists only what it observed (unobserved categories stay empty). An observed category with a genuine empty/zero value writes that value rather than carrying forward.' + priority: high + references_constraints: [C-08] diff --git a/specs/system/os-intelligence.spec.yaml b/specs/system/os-intelligence.spec.yaml index f56e2bbc..3bda1607 100644 --- a/specs/system/os-intelligence.spec.yaml +++ b/specs/system/os-intelligence.spec.yaml @@ -1,7 +1,7 @@ spec: id: system-os-intelligence title: Host OS Intelligence — recurring snapshot + diff-driven event log - version: "1.1.0" + version: "1.2.0" status: approved tier: 2 @@ -89,7 +89,7 @@ spec: type: technical enforcement: error - id: C-03 - description: 'host_intelligence_state UPSERT keyed by host_id (UNIQUE). The table represents the LAST snapshot, never a history. Subsequent cycles UPDATE the existing row' + description: 'host_intelligence_state UPSERT keyed by host_id (UNIQUE). The table represents the LAST snapshot, never a history. Subsequent cycles UPDATE the existing row. v1.2.0 no-clobber: a cycle records which snapshot CATEGORIES it OBSERVED (the probe ran and returned usable output: users, groups, listening_ports, network_interfaces, routes, firewall_rule_count, packages, services, kernel_release, uptime, mountpoints, config_hashes). BEFORE the diff and the UPSERT, the cycle carries forward the prior stored value for any category NOT observed, so a failed/denied probe neither emits a false change event nor blanks previously-good data; an observed category keeps this cycle''s value even when genuinely empty. The Observed set is transient (never serialized into the stored snapshot). This extends the C-07 partial-success discipline from the change log to the stored state' type: technical enforcement: error - id: C-04 @@ -182,3 +182,7 @@ spec: description: 'v1.1.0 — the diff engine emits the reserved account.password.expired code once when a user''s password crosses PasswordExpiresAt between two cycles (prior snapshot collected before expiry, current after), deduped by the append-only UNIQUE key. It does NOT fire when both cycles are pre-expiry, both post-expiry, or when there is no policy. Expiry is time-based (the shadow fields are unchanged; wall-clock crosses), and the warn-window "expiring" notification is owned by system-account-policy.' priority: high references_constraints: [C-05] + - id: AC-18 + description: 'v1.2.0 no-clobber merge (mergeUnobserved): given a prior stored snapshot with packages + services + users, a cycle that observes only packages (Observed = {packages}) and whose services/users probes failed keeps the prior services + users in the stored snapshot and emits NO service/user change events, while an observed packages change is stored and diffed normally. An observed category with a genuinely empty value overwrites (not carried forward). A nil Observed map carries forward every category (safe default).' + priority: high + references_constraints: [C-03] From 0e61fa592e1144fb2a2b091ade72b3ed83b1bf2d Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Wed, 15 Jul 2026 19:47:09 -0400 Subject: [PATCH 2/3] feat(intelligence): persist per-category collection freshness Phase 1b of the last-known-good model: the no-clobber merge keeps a failed probe from blanking good data; this records WHEN each carried-forward value was last actually seen, so a consumer can tell fresh data from stale. - Migration 0052 adds category_freshness JSONB to host_system_info and host_intelligence_state: one key per fact category -> {observed_at, attempt_at, status}. status ok = observed this run; stale = not observed but has a prior observation (prior observed_at kept, attempt advances). - Discovery persist and the collector state write compute and store it from the Observed set + the prior freshness (computeFreshness / computeSnapFreshness). - Specs: system-host-discovery v1.6.0 (C-13, AC-25), system-os-intelligence v1.3.0 (C-09, AC-19). DB + unit tests. The API surfacing + freshness-aware UI (render a stale value as 'unknown, last good X ago') is the remaining Phase 1b sub-step. --- .../0052_intelligence_freshness.sql | 31 +++++++++ internal/intelligence/collector/collector.go | 28 ++++++-- internal/intelligence/collector/merge_test.go | 37 ++++++++++- internal/intelligence/collector/types.go | 34 ++++++++++ internal/intelligence/discovery/discovery.go | 66 ++++++++++++++++--- .../discovery/discovery_db_test.go | 54 +++++++++++++++ specs/system/host-discovery.spec.yaml | 10 ++- specs/system/os-intelligence.spec.yaml | 10 ++- 8 files changed, 252 insertions(+), 18 deletions(-) create mode 100644 internal/db/migrations/0052_intelligence_freshness.sql diff --git a/internal/db/migrations/0052_intelligence_freshness.sql b/internal/db/migrations/0052_intelligence_freshness.sql new file mode 100644 index 00000000..b6d4a59c --- /dev/null +++ b/internal/db/migrations/0052_intelligence_freshness.sql @@ -0,0 +1,31 @@ +-- 0052_intelligence_freshness.sql +-- +-- Per-category collection freshness for discovery + OS intelligence, Phase 1b of +-- the last-known-good model. The no-clobber merge (0051-era code, spec +-- system-host-discovery v1.5.0 / system-os-intelligence v1.2.0) already keeps a +-- failed probe from blanking good data. This adds the metadata a consumer needs +-- to tell FRESH data from CARRIED-FORWARD data: for each fact category, when it +-- was last observed, when it was last attempted, and the last attempt's status. +-- +-- Shape (JSONB, one key per fact category): +-- { "": { "observed_at": , "attempt_at": , "status": "ok" | "stale" } } +-- * status "ok" — observed this run; observed_at == attempt_at +-- * status "stale" — not observed this run; observed_at is the last good +-- time, attempt_at is this run (the failed attempt) +-- A category never observed has no key. NULL column = pre-feature row. +-- +-- Discovery categories: os_release, uname, memory, disk, hostname, fqdn, +-- selinux, apparmor, firewall. +-- Intelligence categories: users, groups, listening_ports, network_interfaces, +-- routes, firewall_rule_count, packages, services, kernel_release, uptime, +-- mountpoints, config_hashes. +-- +-- Spec: system-host-discovery, system-os-intelligence. + +-- +goose Up +ALTER TABLE host_system_info ADD COLUMN category_freshness JSONB; +ALTER TABLE host_intelligence_state ADD COLUMN category_freshness JSONB; + +-- +goose Down +ALTER TABLE host_intelligence_state DROP COLUMN IF EXISTS category_freshness; +ALTER TABLE host_system_info DROP COLUMN IF EXISTS category_freshness; diff --git a/internal/intelligence/collector/collector.go b/internal/intelligence/collector/collector.go index c85753cc..fcfab863 100644 --- a/internal/intelligence/collector/collector.go +++ b/internal/intelligence/collector/collector.go @@ -576,15 +576,31 @@ func (s *Service) persist(ctx context.Context, hostID uuid.UUID, snap Snapshot, if err != nil { return fmt.Errorf("collector: encode snapshot: %w", err) } + // Per-category freshness (spec v1.3.0): stamp when each category was last + // observed vs merely attempted, so a consumer can tell fresh from + // carried-forward data. + var priorFreshRaw []byte + if err := tx.QueryRow(ctx, + `SELECT category_freshness FROM host_intelligence_state WHERE host_id = $1`, hostID, + ).Scan(&priorFreshRaw); err != nil && !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("collector: read prior freshness: %w", err) + } + var priorFresh map[string]snapFreshnessEntry + if len(priorFreshRaw) > 0 { + _ = json.Unmarshal(priorFreshRaw, &priorFresh) + } + freshJSON, _ := json.Marshal(computeSnapFreshness(snap.Observed, priorFresh, snap.CollectedAt)) + // Spec C-03: UPSERT keyed by host_id. if _, err := tx.Exec(ctx, ` - INSERT INTO host_intelligence_state (host_id, snapshot, collected_at, created_at, updated_at) - VALUES ($1, $2, $3, now(), now()) + INSERT INTO host_intelligence_state (host_id, snapshot, collected_at, category_freshness, created_at, updated_at) + VALUES ($1, $2, $3, $4, now(), now()) ON CONFLICT (host_id) DO UPDATE SET - snapshot = EXCLUDED.snapshot, - collected_at = EXCLUDED.collected_at, - updated_at = now()`, - hostID, raw, snap.CollectedAt, + snapshot = EXCLUDED.snapshot, + collected_at = EXCLUDED.collected_at, + category_freshness = EXCLUDED.category_freshness, + updated_at = now()`, + hostID, raw, snap.CollectedAt, freshJSON, ); err != nil { return fmt.Errorf("collector: upsert state: %w", err) } diff --git a/internal/intelligence/collector/merge_test.go b/internal/intelligence/collector/merge_test.go index c00a8ca6..8ede0bce 100644 --- a/internal/intelligence/collector/merge_test.go +++ b/internal/intelligence/collector/merge_test.go @@ -3,10 +3,14 @@ // AC traceability (this file): // // AC-18 TestMergeUnobserved_NoClobberAndNoFalseEvents +// AC-19 TestComputeSnapFreshness package collector -import "testing" +import ( + "testing" + "time" +) // @ac AC-18 // AC-18: an unobserved category carries forward its prior value (no clobber, @@ -66,3 +70,34 @@ func TestMergeUnobserved_NoClobberAndNoFalseEvents(t *testing.T) { } }) } + +// @ac AC-19 +// AC-19: computeSnapFreshness stamps observed→ok (observed_at=now), +// unobserved-with-prior→stale (prior observed_at kept, attempt advances), and +// omits never-observed categories. +func TestComputeSnapFreshness(t *testing.T) { + t.Run("system-os-intelligence/AC-19", func(t *testing.T) { + now := time.Now().UTC() + earlier := now.Add(-time.Hour) + prior := map[string]snapFreshnessEntry{ + "packages": {ObservedAt: earlier, AttemptAt: earlier, Status: "ok"}, + "services": {ObservedAt: earlier, AttemptAt: earlier, Status: "ok"}, + } + + out := computeSnapFreshness(map[SnapCategory]bool{SnapPackages: true}, prior, now) + + if e := out["packages"]; e.Status != "ok" || !e.ObservedAt.Equal(now) { + t.Errorf("packages = %+v, want ok observed_at=now", e) + } + if e := out["services"]; e.Status != "stale" || !e.ObservedAt.Equal(earlier) || !e.AttemptAt.Equal(now) { + t.Errorf("services = %+v, want stale keeping earlier observed_at, attempt=now", e) + } + if _, ok := out["users"]; ok { + t.Errorf("users should be absent (never observed, no prior)") + } + + if out2 := computeSnapFreshness(map[SnapCategory]bool{}, nil, now); len(out2) != 0 { + t.Errorf("nil prior + nothing observed should be empty, got %+v", out2) + } + }) +} diff --git a/internal/intelligence/collector/types.go b/internal/intelligence/collector/types.go index 82831a82..828453ec 100644 --- a/internal/intelligence/collector/types.go +++ b/internal/intelligence/collector/types.go @@ -103,6 +103,40 @@ const ( SnapConfig SnapCategory = "config_hashes" ) +// allSnapCategories is the fixed set persist stamps freshness for. +var allSnapCategories = []SnapCategory{ + SnapUsers, SnapGroups, SnapPorts, SnapInterfaces, SnapRoutes, SnapFirewall, + SnapPackages, SnapServices, SnapKernel, SnapUptime, SnapMounts, SnapConfig, +} + +// snapFreshnessEntry is one category's collection freshness, stored in +// host_intelligence_state.category_freshness (migration 0052). +type snapFreshnessEntry struct { + ObservedAt time.Time `json:"observed_at"` + AttemptAt time.Time `json:"attempt_at"` + Status string `json:"status"` // ok | stale +} + +// computeSnapFreshness stamps per-category freshness: an observed category is +// "ok" (observed_at = now); an unobserved category with a prior observation is +// "stale" (prior observed_at kept, attempt_at = now, so a consumer can show +// "last good X ago"); a category never observed has no entry. +func computeSnapFreshness(observed map[SnapCategory]bool, prior map[string]snapFreshnessEntry, now time.Time) map[string]snapFreshnessEntry { + out := make(map[string]snapFreshnessEntry, len(allSnapCategories)) + for _, cat := range allSnapCategories { + key := string(cat) + switch { + case observed[cat]: + out[key] = snapFreshnessEntry{ObservedAt: now, AttemptAt: now, Status: "ok"} + case prior != nil: + if p, ok := prior[key]; ok { + out[key] = snapFreshnessEntry{ObservedAt: p.ObservedAt, AttemptAt: now, Status: "stale"} + } + } + } + return out +} + // ListeningPort is one entry from `ss -tln`. type ListeningPort struct { Protocol string `json:"protocol"` // tcp | udp diff --git a/internal/intelligence/discovery/discovery.go b/internal/intelligence/discovery/discovery.go index 0120aa30..7a66e0db 100644 --- a/internal/intelligence/discovery/discovery.go +++ b/internal/intelligence/discovery/discovery.go @@ -2,6 +2,7 @@ package discovery import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -142,6 +143,41 @@ const ( CatFirewall FactCategory = "firewall" ) +// allFactCategories is the fixed set persist stamps freshness for. +var allFactCategories = []FactCategory{ + CatOSRelease, CatUname, CatMemory, CatDisk, CatHostname, + CatFQDN, CatSELinux, CatAppArmor, CatFirewall, +} + +// freshnessEntry is one category's collection freshness, stored in the +// host_system_info.category_freshness JSONB (migration 0052). +type freshnessEntry struct { + ObservedAt time.Time `json:"observed_at"` + AttemptAt time.Time `json:"attempt_at"` + Status string `json:"status"` // ok | stale +} + +// computeFreshness builds the per-category freshness map from this run's +// observed set and the prior stored freshness. An observed category is "ok" +// (observed_at = now); an unobserved category with a prior observation is +// "stale" (prior observed_at kept, attempt_at = now, so a consumer can show +// "last good X ago"); a category never observed has no entry. +func computeFreshness(observed map[FactCategory]bool, prior map[string]freshnessEntry, now time.Time) map[string]freshnessEntry { + out := make(map[string]freshnessEntry, len(allFactCategories)) + for _, cat := range allFactCategories { + key := string(cat) + switch { + case observed[cat]: + out[key] = freshnessEntry{ObservedAt: now, AttemptAt: now, Status: "ok"} + case prior != nil: + if p, ok := prior[key]; ok { + out[key] = freshnessEntry{ObservedAt: p.ObservedAt, AttemptAt: now, Status: "stale"} + } + } + } + return out +} + // AuditEmitFunc is the audit emission seam. Production wires it to // audit.Emit; tests use a recorder that counts emissions. type AuditEmitFunc func(ctx context.Context, code audit.Code, ev audit.Event) @@ -439,11 +475,19 @@ func (s *Service) persist(ctx context.Context, hostID uuid.UUID, f SystemFacts) // for any category this run did NOT observe, so a failed or denied probe // never blanks previously-good data. Read + write inside the same tx so the // merge is atomic. First discovery (no prior row) skips the merge. - if prior, ok, perr := readPriorFacts(ctx, tx, hostID); perr != nil { + var priorFresh map[string]freshnessEntry + if prior, freshRaw, ok, perr := readPriorFacts(ctx, tx, hostID); perr != nil { return fmt.Errorf("discovery: read prior facts: %w", perr) } else if ok { mergeUnobserved(&f, prior) + if len(freshRaw) > 0 { + _ = json.Unmarshal(freshRaw, &priorFresh) + } } + // Per-category freshness (spec v1.6.0): stamp when each category was last + // observed vs merely attempted, so a consumer can tell fresh from + // carried-forward data. + freshJSON, _ := json.Marshal(computeFreshness(f.Observed, priorFresh, f.CollectedAt)) const upsertSystemInfo = ` INSERT INTO host_system_info ( @@ -456,7 +500,7 @@ func (s *Service) persist(ctx context.Context, hostID uuid.UUID, f SystemFacts) hostname, fqdn, selinux_status, apparmor_enabled, firewall_service, firewall_status, - collected_at, created_at, updated_at + collected_at, category_freshness, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, @@ -467,7 +511,7 @@ func (s *Service) persist(ctx context.Context, hostID uuid.UUID, f SystemFacts) $20, $21, $22, $23, $24, $25, - $26, now(), now() + $26, $27, now(), now() ) ON CONFLICT (host_id) DO UPDATE SET os_name = EXCLUDED.os_name, @@ -495,6 +539,7 @@ func (s *Service) persist(ctx context.Context, hostID uuid.UUID, f SystemFacts) firewall_service = EXCLUDED.firewall_service, firewall_status = EXCLUDED.firewall_status, collected_at = EXCLUDED.collected_at, + category_freshness = EXCLUDED.category_freshness, updated_at = now()` if _, err := tx.Exec(ctx, upsertSystemInfo, @@ -508,7 +553,7 @@ func (s *Service) persist(ctx context.Context, hostID uuid.UUID, f SystemFacts) nilIfEmpty(f.Hostname), nilIfEmpty(f.FQDN), nilIfEmpty(f.SELinuxStatus), f.AppArmorEnabled, nilIfEmpty(f.FirewallService), nilIfEmpty(f.FirewallStatus), - f.CollectedAt, + f.CollectedAt, freshJSON, ); err != nil { return fmt.Errorf("discovery: upsert host_system_info: %w", err) } @@ -542,8 +587,9 @@ func (s *Service) persist(ctx context.Context, hostID uuid.UUID, f SystemFacts) // readPriorFacts loads the current host_system_info row (if any) so persist can // carry forward categories a run did not observe. ok=false means no row yet // (first discovery), in which case there is nothing to preserve. -func readPriorFacts(ctx context.Context, tx pgx.Tx, hostID uuid.UUID) (SystemFacts, bool, error) { +func readPriorFacts(ctx context.Context, tx pgx.Tx, hostID uuid.UUID) (SystemFacts, []byte, bool, error) { var f SystemFacts + var freshRaw []byte err := tx.QueryRow(ctx, ` SELECT COALESCE(os_name, ''), COALESCE(os_version, ''), COALESCE(os_version_full, ''), COALESCE(os_id, ''), COALESCE(os_id_like, ''), COALESCE(os_pretty_name, ''), @@ -554,7 +600,8 @@ func readPriorFacts(ctx context.Context, tx pgx.Tx, hostID uuid.UUID) (SystemFac COALESCE(disk_total_gb, 0), COALESCE(disk_used_gb, 0), COALESCE(disk_free_gb, 0), COALESCE(hostname, ''), COALESCE(fqdn, ''), COALESCE(selinux_status, ''), COALESCE(apparmor_enabled, false), - COALESCE(firewall_service, ''), COALESCE(firewall_status, '') + COALESCE(firewall_service, ''), COALESCE(firewall_status, ''), + category_freshness FROM host_system_info WHERE host_id = $1`, hostID).Scan( &f.OSName, &f.OSVersion, &f.OSVersionFull, &f.OSID, &f.OSIDLike, &f.OSPrettyName, &f.PlatformIdentifier, &f.OSFamily, @@ -564,14 +611,15 @@ func readPriorFacts(ctx context.Context, tx pgx.Tx, hostID uuid.UUID) (SystemFac &f.Hostname, &f.FQDN, &f.SELinuxStatus, &f.AppArmorEnabled, &f.FirewallService, &f.FirewallStatus, + &freshRaw, ) if errors.Is(err, pgx.ErrNoRows) { - return SystemFacts{}, false, nil + return SystemFacts{}, nil, false, nil } if err != nil { - return SystemFacts{}, false, err + return SystemFacts{}, nil, false, err } - return f, true, nil + return f, freshRaw, true, nil } // mergeUnobserved carries forward prior values for every category the current diff --git a/internal/intelligence/discovery/discovery_db_test.go b/internal/intelligence/discovery/discovery_db_test.go index e0ddcf07..e9496a19 100644 --- a/internal/intelligence/discovery/discovery_db_test.go +++ b/internal/intelligence/discovery/discovery_db_test.go @@ -4,6 +4,7 @@ // // AC-08 TestDiscover_HappyPath_PersistsAndPublishes // AC-24 TestDiscover_NoClobberOnPartialCollection +// AC-25 TestDiscover_CategoryFreshness package discovery @@ -206,3 +207,56 @@ func TestDiscover_NoClobberOnPartialCollection(t *testing.T) { } }) } + +// @ac AC-25 +// AC-25: persist stamps per-category freshness — observed categories are "ok" +// (observed_at advances), unobserved categories with a prior observation flip +// to "stale" keeping their earlier observed_at. +func TestDiscover_CategoryFreshness(t *testing.T) { + t.Run("system-host-discovery/AC-25", func(t *testing.T) { + pool, hostID, _ := freshDBHost(t) + ctx := context.Background() + svc := &Service{pool: pool} + allObserved := map[FactCategory]bool{ + CatOSRelease: true, CatUname: true, CatMemory: true, CatDisk: true, + CatHostname: true, CatFQDN: true, CatSELinux: true, CatAppArmor: true, CatFirewall: true, + } + + run1 := time.Now().Add(-time.Hour).UTC().Truncate(time.Second) + if err := svc.persist(ctx, hostID, SystemFacts{ + OSVersion: "9.4", KernelRelease: "5.14.0-570", FirewallService: "firewalld", + CollectedAt: run1, Observed: allObserved, + }); err != nil { + t.Fatalf("run1 persist: %v", err) + } + + run2 := time.Now().UTC().Truncate(time.Second) + if err := svc.persist(ctx, hostID, SystemFacts{ + OSVersion: "9.5", KernelRelease: "5.14.0-580", + CollectedAt: run2, Observed: map[FactCategory]bool{CatOSRelease: true, CatUname: true}, + }); err != nil { + t.Fatalf("run2 persist: %v", err) + } + + var osStatus, fwStatus string + var fwEarlier bool + if err := pool.QueryRow(ctx, ` + SELECT category_freshness->'os_release'->>'status', + category_freshness->'firewall'->>'status', + (category_freshness->'firewall'->>'observed_at')::timestamptz + < (category_freshness->'os_release'->>'observed_at')::timestamptz + FROM host_system_info WHERE host_id = $1`, hostID). + Scan(&osStatus, &fwStatus, &fwEarlier); err != nil { + t.Fatalf("read freshness: %v", err) + } + if osStatus != "ok" { + t.Errorf("os_release status = %q, want ok (observed run2)", osStatus) + } + if fwStatus != "stale" { + t.Errorf("firewall status = %q, want stale (unobserved run2)", fwStatus) + } + if !fwEarlier { + t.Errorf("firewall observed_at should be earlier than os_release (kept run1 time)") + } + }) +} diff --git a/specs/system/host-discovery.spec.yaml b/specs/system/host-discovery.spec.yaml index c4c3d71a..250b732a 100644 --- a/specs/system/host-discovery.spec.yaml +++ b/specs/system/host-discovery.spec.yaml @@ -1,7 +1,7 @@ spec: id: system-host-discovery title: Host OS fingerprint discovery via SSH - version: "1.5.0" + version: "1.6.0" status: approved tier: 1 @@ -134,6 +134,10 @@ spec: description: 'v1.3.0 — The value persisted into hosts.os_family AND host_system_info.os_family MUST be the distro ID (lower-cased) from /etc/os-release''s ID field whenever that field is non-empty. Collapsing the distro ID into a family rollup via ID_LIKE (e.g. Ubuntu → "debian" because ID_LIKE=debian) is forbidden because it strips information the front-end''s osDisplayLabel mapping (frontend-host-list-os AC-01..AC-03) needs to distinguish Ubuntu from Debian, Rocky from RHEL, etc. The rollup fallback applies ONLY when ID is empty — Discovery then walks ID_LIKE for the first recognized family. When neither yields a recognized value the persisted family is the literal "other".' type: technical enforcement: error + - id: C-13 + description: 'v1.6.0 — persist stamps host_system_info.category_freshness (JSONB, migration 0052): one key per fact category mapping to {observed_at, attempt_at, status}. An OBSERVED category this run is status "ok" with observed_at = attempt_at = collected_at. An UNOBSERVED category that has a prior observation is status "stale" with the prior observed_at kept and attempt_at = this run (so a consumer can render "last good X ago"). A category never observed has no key. This is the read-side companion to the C-08 no-clobber merge: it records WHEN each carried-forward value was actually last seen.' + type: technical + enforcement: error acceptance_criteria: - id: AC-01 @@ -208,3 +212,7 @@ spec: description: 'v1.5.0 no-clobber merge: given a host whose host_system_info is fully populated, a second Discover run that observes only some categories (e.g. os_release + uname) and whose other probes failed/were-denied (firewall, disk, selinux fields empty, those categories absent from Observed) leaves the unobserved categories'' columns at their prior values (firewall_service, disk_free_gb, selinux_status retained) while the observed categories update (os_version, kernel_release change). A first Discover with no prior row persists only what it observed (unobserved categories stay empty). An observed category with a genuine empty/zero value writes that value rather than carrying forward.' priority: high references_constraints: [C-08] + - id: AC-25 + description: 'v1.6.0 freshness: after a Discover run, host_system_info.category_freshness has status "ok" for every observed category with observed_at = the row''s collected_at; after a SECOND run that observes only some categories, the observed ones stay "ok" with a bumped observed_at while the unobserved ones flip to "stale" keeping their earlier observed_at (attempt_at advances). A first run stamps only observed categories (no key for never-observed ones).' + priority: high + references_constraints: [C-13] diff --git a/specs/system/os-intelligence.spec.yaml b/specs/system/os-intelligence.spec.yaml index 3bda1607..316b5f00 100644 --- a/specs/system/os-intelligence.spec.yaml +++ b/specs/system/os-intelligence.spec.yaml @@ -1,7 +1,7 @@ spec: id: system-os-intelligence title: Host OS Intelligence — recurring snapshot + diff-driven event log - version: "1.2.0" + version: "1.3.0" status: approved tier: 2 @@ -112,6 +112,10 @@ spec: description: 'RunCycle is invoked per-host. Concurrency control is the caller''s responsibility (the scheduler in PR 1.3 will use pg_advisory_xact_lock the same way the scan worker does). The service itself MUST NOT serialize globally' type: technical enforcement: error + - id: C-09 + description: 'v1.3.0 — persist stamps host_intelligence_state.category_freshness (JSONB, migration 0052): one key per snapshot category mapping to {observed_at, attempt_at, status}. computeSnapFreshness sets an OBSERVED category to status "ok" (observed_at = attempt_at = collected_at); an UNOBSERVED category with a prior observation to status "stale" (prior observed_at kept, attempt_at = this run); a never-observed category has no key. This is the read-side companion to the C-03 no-clobber merge — it records WHEN each carried-forward category was actually last seen.' + type: technical + enforcement: error acceptance_criteria: - id: AC-01 @@ -186,3 +190,7 @@ spec: description: 'v1.2.0 no-clobber merge (mergeUnobserved): given a prior stored snapshot with packages + services + users, a cycle that observes only packages (Observed = {packages}) and whose services/users probes failed keeps the prior services + users in the stored snapshot and emits NO service/user change events, while an observed packages change is stored and diffed normally. An observed category with a genuinely empty value overwrites (not carried forward). A nil Observed map carries forward every category (safe default).' priority: high references_constraints: [C-03] + - id: AC-19 + description: 'v1.3.0 freshness (computeSnapFreshness): an observed category maps to status "ok" with observed_at = attempt_at = the passed now; an unobserved category present in the prior freshness maps to status "stale" with the prior observed_at kept and attempt_at = now; an unobserved category with no prior entry is absent from the output; a nil prior with an unobserved category yields no entry.' + priority: high + references_constraints: [C-09] From c83a47780d94a5ffcaae63957caeb8527e72ade6 Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Wed, 15 Jul 2026 21:46:45 -0400 Subject: [PATCH 3/3] feat(intelligence): surface per-category freshness on host read APIs + System card Expose the category_freshness map (migration 0052) on the two host read endpoints and render staleness in the host detail System card so an operator can tell a freshly-observed value from a carried-forward one. Backend: - GET /api/v1/hosts/{id}/system-info now selects category_freshness and exposes it as CategoryFreshness (omitted when NULL, pre-0052 rows). - GET /api/v1/intelligence/state/{host_id} does the same for the intelligence snapshot's per-category freshness. - Shared FreshnessEntry {status, observed_at, attempt_at} + CategoryFreshness map added to api/openapi.yaml; server.gen.go + schema.d.ts regenerated. Frontend: - CardSystem renders a muted "Last verified " caveat beneath a value whose category is stale (Distribution/FQDN/Firewall/Disk/Memory); ok/absent freshness renders nothing. Specs: api-host-system-info v1.1.0 (C-05/AC-06), api-os-intelligence v1.1.0 (C-06/AC-11), frontend-host-detail-system-card v1.1.0 (C-07/AC-08). All three at 100% annotation coverage; specter check + check --test clean. --- api/openapi.yaml | 33 +++++++ frontend/src/api/schema.d.ts | 27 ++++++ frontend/src/pages/host-detail/CardSystem.tsx | 62 +++++++++++- .../pages/host-detail-system-card.test.tsx | 59 ++++++++++- internal/server/api/server.gen.go | 77 +++++++++++---- internal/server/api_host_system_info_test.go | 97 +++++++++++++++++++ internal/server/api_intelligence_test.go | 66 +++++++++++++ internal/server/host_system_info_handler.go | 16 ++- internal/server/intelligence_handlers.go | 18 +++- specs/api/host-system-info.spec.yaml | 10 +- specs/api/os-intelligence.spec.yaml | 10 +- .../host-detail-system-card.spec.yaml | 10 +- 12 files changed, 454 insertions(+), 31 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 71a7c5b5..1c90bda6 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -6463,6 +6463,11 @@ components: additionalProperties: true description: Free-form host_intelligence_state.snapshot — mirrors collector.Snapshot collected_at: {type: string, format: date-time} + # Per-category collection freshness (system-os-intelligence v1.3.0 C-09). + # nullable: rows written before migration 0052 have no freshness map. + category_freshness: + nullable: true + allOf: [{$ref: '#/components/schemas/CategoryFreshness'}] Alert: type: object @@ -6534,6 +6539,29 @@ components: reason: {type: string, maxLength: 256} until: {type: string, format: date-time, nullable: true} + FreshnessEntry: + type: object + required: [status, observed_at, attempt_at] + description: | + Per-category collection freshness. status=ok when the category was + observed on the most recent run; status=stale when the run did not + observe it and the last-known-good value was carried forward + (observed_at then points at the last successful observation). + Spec system-host-discovery / system-os-intelligence. + properties: + status: {type: string, enum: [ok, stale]} + observed_at: {type: string, format: date-time} + attempt_at: {type: string, format: date-time} + CategoryFreshness: + type: object + additionalProperties: {$ref: '#/components/schemas/FreshnessEntry'} + description: | + Map of fact category -> freshness. Absent categories were never + observed. Discovery keys: os_release, uname, memory, disk, hostname, + fqdn, selinux, apparmor, firewall. Intelligence keys: users, groups, + ports, interfaces, routes, firewall, packages, services, kernel, + uptime, mounts, config. + HostSystemInfo: type: object required: [host_id, collected_at] @@ -6574,6 +6602,11 @@ components: firewall_service: {type: string, nullable: true, description: 'firewalld | ufw | nftables | iptables | empty'} firewall_status: {type: string, nullable: true} collected_at: {type: string, format: date-time} + # Per-category collection freshness (system-host-discovery v1.6.0 C-13). + # nullable: rows written before migration 0052 have no freshness map. + category_freshness: + nullable: true + allOf: [{$ref: '#/components/schemas/CategoryFreshness'}] FleetRuleFailure: type: object diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 6335ce19..8529c66d 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -4294,6 +4294,7 @@ export interface components { }; /** Format: date-time */ collected_at: string; + category_freshness?: components["schemas"]["CategoryFreshness"] | null; }; Alert: { /** Format: uuid */ @@ -4364,6 +4365,31 @@ export interface components { /** Format: date-time */ until?: string | null; }; + /** + * @description Per-category collection freshness. status=ok when the category was + * observed on the most recent run; status=stale when the run did not + * observe it and the last-known-good value was carried forward + * (observed_at then points at the last successful observation). + * Spec system-host-discovery / system-os-intelligence. + */ + FreshnessEntry: { + /** @enum {string} */ + status: "ok" | "stale"; + /** Format: date-time */ + observed_at: string; + /** Format: date-time */ + attempt_at: string; + }; + /** + * @description Map of fact category -> freshness. Absent categories were never + * observed. Discovery keys: os_release, uname, memory, disk, hostname, + * fqdn, selinux, apparmor, firewall. Intelligence keys: users, groups, + * ports, interfaces, routes, firewall, packages, services, kernel, + * uptime, mounts, config. + */ + CategoryFreshness: { + [key: string]: components["schemas"]["FreshnessEntry"]; + }; /** * @description Result of a Discovery run. Mirrors the host_system_info table * column-for-column. Spec system-host-discovery. @@ -4400,6 +4426,7 @@ export interface components { firewall_status?: string | null; /** Format: date-time */ collected_at: string; + category_freshness?: components["schemas"]["CategoryFreshness"] | null; }; FleetRuleFailure: { rule_id: string; diff --git a/frontend/src/pages/host-detail/CardSystem.tsx b/frontend/src/pages/host-detail/CardSystem.tsx index 644e8336..ac8ea53e 100644 --- a/frontend/src/pages/host-detail/CardSystem.tsx +++ b/frontend/src/pages/host-detail/CardSystem.tsx @@ -37,6 +37,16 @@ export interface CardSystemHost { os_version?: string | null; } +// Per-category collection freshness. Mirrors the API CategoryFreshness map +// (system-host-discovery v1.6.0). status=stale means the value shown was +// carried forward from an earlier successful run — the most recent Discovery +// did not re-observe this category (SSH degraded, sudo denied, probe failed). +export interface CategoryFreshness { + status: 'ok' | 'stale'; + observed_at: string; + attempt_at: string; +} + // Subset of HostSystemInfo we render. Mirrors the API schema column // names; null fields tolerate partial-collection rows (sudo unavailable, // older snapshots). @@ -50,6 +60,7 @@ export interface CardSystemInfo { firewall_service?: string | null; firewall_status?: string | null; collected_at?: string; + category_freshness?: Record | null; } interface CardSystemProps { @@ -135,6 +146,7 @@ export function CardSystem({ host, intelligenceSnapshot, systemInfo }: CardSyste {systemInfo?.architecture && (
{systemInfo.architecture}
)} + , ], [ @@ -145,9 +157,10 @@ export function CardSystem({ host, intelligenceSnapshot, systemInfo }: CardSyste ], [ 'FQDN', - - {fqdnDisplay} - , +
+ {fqdnDisplay} + +
, ], [ 'Uptime', @@ -205,6 +218,7 @@ export function CardSystem({ host, intelligenceSnapshot, systemInfo }: CardSyste status={systemInfo?.firewall_status ?? null} service={systemInfo?.firewall_service ?? null} /> + , ], ]} @@ -280,6 +294,7 @@ function HardwareSection({ systemInfo }: { systemInfo: CardSystemInfo }) { ) : ( )} + , ], [ @@ -289,6 +304,7 @@ function HardwareSection({ systemInfo }: { systemInfo: CardSystemInfo }) { {memGb !== null ? `${memGb} GB` : '—'}
Live utilization not collected
+ , ], ]} @@ -350,6 +366,46 @@ const subValueStyle: React.CSSProperties = { marginTop: 2, }; +// StaleNote — the honesty marker for "The Eye": when a rendered value was +// carried forward (the last Discovery did not re-observe this category), we +// tell the operator what they're looking at is last-known-good, not a fresh +// reading, and when it was last actually verified. An ok/absent category +// renders nothing (a fresh reading needs no caveat). +// +// `category` is a host_system_info freshness key: os_release, uname, memory, +// disk, hostname, fqdn, selinux, apparmor, firewall. +export function StaleNote({ + freshness, + category, +}: { + freshness: Record | null | undefined; + category: string; +}) { + const entry = freshness?.[category]; + if (!entry || entry.status !== 'stale') return null; + return ( +
+ Last verified {formatTimeAgo(entry.observed_at)} +
+ ); +} + +// formatTimeAgo — compact "Xm/Xh/Xd ago" from an ISO timestamp, for the +// stale-value caveat. Self-contained so CardSystem stays testable. +export function formatTimeAgo(iso: string): string { + const then = new Date(iso).getTime(); + if (!Number.isFinite(then)) return 'earlier'; + const mins = Math.max(0, Math.round((Date.now() - then) / 60000)); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.round(hours / 24)}d ago`; +} + function NotDiscoveredState({ canWrite, pending, diff --git a/frontend/tests/pages/host-detail-system-card.test.tsx b/frontend/tests/pages/host-detail-system-card.test.tsx index 2c81a670..f53972b3 100644 --- a/frontend/tests/pages/host-detail-system-card.test.tsx +++ b/frontend/tests/pages/host-detail-system-card.test.tsx @@ -14,7 +14,12 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { render, screen } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { CardSystem, type CardSystemHost } from '@/pages/host-detail/CardSystem'; +import { + CardSystem, + formatTimeAgo, + type CardSystemHost, + type CardSystemInfo, +} from '@/pages/host-detail/CardSystem'; import { useAuthStore } from '@/store/useAuthStore'; const PAGE_SRC = readFileSync( @@ -119,6 +124,58 @@ describe('frontend-host-detail-system-card — behavior', () => { }); }); +describe('frontend-host-detail-system-card — freshness', () => { + // @ac AC-08 + test('frontend-host-detail-system-card/AC-08 — stale category shows Last verified caveat; ok/absent shows none', () => { + withWriter(); + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 3600 * 1000).toISOString(); + const now = new Date().toISOString(); + + // Stale firewall → caveat present. + const staleInfo: CardSystemInfo = { + firewall_status: 'active', + firewall_service: 'firewalld', + category_freshness: { + firewall: { status: 'stale', observed_at: twoDaysAgo, attempt_at: now }, + }, + }; + const { unmount } = renderWith( + , + ); + expect(screen.getByText(/Last verified/i)).toBeInTheDocument(); + unmount(); + + // ok firewall → no caveat. + const freshInfo: CardSystemInfo = { + firewall_status: 'active', + firewall_service: 'firewalld', + category_freshness: { + firewall: { status: 'ok', observed_at: now, attempt_at: now }, + }, + }; + renderWith( + , + ); + expect(screen.queryByText(/Last verified/i)).toBeNull(); + }); + + // @ac AC-08 + test('frontend-host-detail-system-card/AC-08 — formatTimeAgo buckets to coarse m/h/d ago', () => { + expect(formatTimeAgo(new Date(Date.now() - 2 * 24 * 3600 * 1000).toISOString())).toBe('2d ago'); + expect(formatTimeAgo(new Date(Date.now() - 5 * 3600 * 1000).toISOString())).toBe('5h ago'); + expect(formatTimeAgo(new Date(Date.now() - 10 * 60 * 1000).toISOString())).toBe('10m ago'); + expect(formatTimeAgo(new Date().toISOString())).toBe('just now'); + }); +}); + describe('frontend-host-detail-system-card — structural', () => { // @ac AC-05 test('frontend-host-detail-system-card/AC-05 — Re-run click mints fresh idempotency key via crypto.randomUUID', () => { diff --git a/internal/server/api/server.gen.go b/internal/server/api/server.gen.go index 26620617..02c977bd 100644 --- a/internal/server/api/server.gen.go +++ b/internal/server/api/server.gen.go @@ -431,6 +431,24 @@ func (e FleetTransactionStatus) Valid() bool { } } +// Defines values for FreshnessEntryStatus. +const ( + FreshnessEntryStatusOk FreshnessEntryStatus = "ok" + FreshnessEntryStatusStale FreshnessEntryStatus = "stale" +) + +// Valid indicates whether the value is a known member of the FreshnessEntryStatus enum. +func (e FreshnessEntryStatus) Valid() bool { + switch e { + case FreshnessEntryStatusOk: + return true + case FreshnessEntryStatusStale: + return true + default: + return false + } +} + // Defines values for GenerateReportRequestKind. const ( GenerateReportRequestKindAttestation GenerateReportRequestKind = "attestation" @@ -1393,25 +1411,25 @@ func (e GetComplianceExceptionsParamsStatus) Valid() bool { // Defines values for GetIntelligenceEventsParamsSeverity. const ( - GetIntelligenceEventsParamsSeverityCritical GetIntelligenceEventsParamsSeverity = "critical" - GetIntelligenceEventsParamsSeverityHigh GetIntelligenceEventsParamsSeverity = "high" - GetIntelligenceEventsParamsSeverityInfo GetIntelligenceEventsParamsSeverity = "info" - GetIntelligenceEventsParamsSeverityLow GetIntelligenceEventsParamsSeverity = "low" - GetIntelligenceEventsParamsSeverityMedium GetIntelligenceEventsParamsSeverity = "medium" + Critical GetIntelligenceEventsParamsSeverity = "critical" + High GetIntelligenceEventsParamsSeverity = "high" + Info GetIntelligenceEventsParamsSeverity = "info" + Low GetIntelligenceEventsParamsSeverity = "low" + Medium GetIntelligenceEventsParamsSeverity = "medium" ) // Valid indicates whether the value is a known member of the GetIntelligenceEventsParamsSeverity enum. func (e GetIntelligenceEventsParamsSeverity) Valid() bool { switch e { - case GetIntelligenceEventsParamsSeverityCritical: + case Critical: return true - case GetIntelligenceEventsParamsSeverityHigh: + case High: return true - case GetIntelligenceEventsParamsSeverityInfo: + case Info: return true - case GetIntelligenceEventsParamsSeverityLow: + case Low: return true - case GetIntelligenceEventsParamsSeverityMedium: + case Medium: return true default: return false @@ -1724,6 +1742,13 @@ type CategoryEntry struct { Id string `json:"id"` } +// CategoryFreshness Map of fact category -> freshness. Absent categories were never +// observed. Discovery keys: os_release, uname, memory, disk, hostname, +// fqdn, selinux, apparmor, firewall. Intelligence keys: users, groups, +// ports, interfaces, routes, firewall, packages, services, kernel, +// uptime, mounts, config. +type CategoryFreshness map[string]FreshnessEntry + // ComplianceConfig Org-wide compliance-display config. default_framework is the default lens the score surfaces (dashboard/hosts avg compliance, host detail) project to: a framework family id (e.g. "stig", "cis") or empty for All rules (the full Kensa corpus). Resolved per-host to the OS key at query time. enabled_frameworks is the allowlist of families offered as lenses; empty means all corpus families are available. A non-empty default_framework must be in a non-empty enabled_frameworks. type ComplianceConfig struct { // DefaultFramework Framework family id @@ -2162,6 +2187,20 @@ type FleetTransactionChangeKind string // FleetTransactionStatus defines model for FleetTransaction.Status. type FleetTransactionStatus string +// FreshnessEntry Per-category collection freshness. status=ok when the category was +// observed on the most recent run; status=stale when the run did not +// observe it and the last-known-good value was carried forward +// (observed_at then points at the last successful observation). +// Spec system-host-discovery / system-os-intelligence. +type FreshnessEntry struct { + AttemptAt time.Time `json:"attempt_at"` + ObservedAt time.Time `json:"observed_at"` + Status FreshnessEntryStatus `json:"status"` +} + +// FreshnessEntryStatus defines model for FreshnessEntry.Status. +type FreshnessEntryStatus string + // GenerateReportRequest Optional kind + scope for the report. An empty body (or empty // object) generates the all-hosts, all-frameworks executive summary. type GenerateReportRequest struct { @@ -2675,12 +2714,13 @@ type HostScanContextScanState string // HostSystemInfo Result of a Discovery run. Mirrors the host_system_info table // column-for-column. Spec system-host-discovery. type HostSystemInfo struct { - ApparmorEnabled *bool `json:"apparmor_enabled,omitempty"` - Architecture *string `json:"architecture,omitempty"` - CollectedAt time.Time `json:"collected_at"` - DiskFreeGb *int `json:"disk_free_gb,omitempty"` - DiskTotalGb *int `json:"disk_total_gb,omitempty"` - DiskUsedGb *int `json:"disk_used_gb,omitempty"` + ApparmorEnabled *bool `json:"apparmor_enabled,omitempty"` + Architecture *string `json:"architecture,omitempty"` + CategoryFreshness *CategoryFreshness `json:"category_freshness,omitempty"` + CollectedAt time.Time `json:"collected_at"` + DiskFreeGb *int `json:"disk_free_gb,omitempty"` + DiskTotalGb *int `json:"disk_total_gb,omitempty"` + DiskUsedGb *int `json:"disk_used_gb,omitempty"` // FirewallService firewalld | ufw | nftables | iptables | empty FirewallService *string `json:"firewall_service,omitempty"` @@ -2770,8 +2810,9 @@ type IntelligenceEventsPage struct { // IntelligenceState defines model for IntelligenceState. type IntelligenceState struct { - CollectedAt time.Time `json:"collected_at"` - HostId openapi_types.UUID `json:"host_id"` + CategoryFreshness *CategoryFreshness `json:"category_freshness,omitempty"` + CollectedAt time.Time `json:"collected_at"` + HostId openapi_types.UUID `json:"host_id"` // Snapshot Free-form host_intelligence_state.snapshot — mirrors collector.Snapshot Snapshot map[string]interface{} `json:"snapshot"` diff --git a/internal/server/api_host_system_info_test.go b/internal/server/api_host_system_info_test.go index aa784469..bcc54efa 100644 --- a/internal/server/api_host_system_info_test.go +++ b/internal/server/api_host_system_info_test.go @@ -7,6 +7,7 @@ // AC-03 TestAPI_HostSystemInfo_GET_UnknownHost_404 // AC-04 TestAPI_HostSystemInfo_GET_Anonymous_Forbidden // AC-05 TestAPI_HostSystemInfo_Handler_UsesParameterizedSQL +// AC-06 TestAPI_HostSystemInfo_GET_ExposesCategoryFreshness package server @@ -205,6 +206,102 @@ func TestAPI_HostSystemInfo_Handler_UsesParameterizedSQL(t *testing.T) { }) } +// AC-06: a row whose category_freshness records a stale category is +// exposed on the 200 body; a row with NULL freshness omits the field. +// @ac AC-06 +func TestAPI_HostSystemInfo_GET_ExposesCategoryFreshness(t *testing.T) { + t.Run("api-host-system-info/AC-06", func(t *testing.T) { + url, pool := freshAPIServer(t) + + // Host WITH a stale-firewall freshness map. observed_at is two + // days before attempt_at — the firewall probe last succeeded two + // days ago and the latest run carried the value forward. + staleHid := uuid.Must(uuid.NewV7()) + _, err := pool.Exec(context.Background(), + `INSERT INTO hosts (id, hostname, ip_address, environment, created_by) + VALUES ($1, $2, $3::inet, 'production', (SELECT id FROM users LIMIT 1))`, + staleHid, "ow-fresh-stale", "192.0.2.10") + if err != nil { + t.Fatalf("seed host: %v", err) + } + _, err = pool.Exec(context.Background(), + `INSERT INTO host_system_info (host_id, os_family, collected_at, category_freshness) + VALUES ($1, 'rhel', now(), + jsonb_build_object( + 'firewall', jsonb_build_object( + 'status', 'stale', + 'observed_at', to_char((now() - interval '2 days') AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), + 'attempt_at', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')), + 'os_release', jsonb_build_object( + 'status', 'ok', + 'observed_at', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), + 'attempt_at', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))))`, + staleHid) + if err != nil { + t.Fatalf("seed system_info w/ freshness: %v", err) + } + + req := asRole(t, "GET", url+"/api/v1/hosts/"+staleHid.String()+"/system-info", auth.RoleViewer, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var body struct { + CategoryFreshness map[string]struct { + Status string `json:"status"` + ObservedAt string `json:"observed_at"` + AttemptAt string `json:"attempt_at"` + } `json:"category_freshness"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + fw, ok := body.CategoryFreshness["firewall"] + if !ok { + t.Fatalf("firewall freshness missing: %+v", body.CategoryFreshness) + } + if fw.Status != "stale" { + t.Errorf("firewall status = %q, want stale", fw.Status) + } + if !(fw.ObservedAt < fw.AttemptAt) { + t.Errorf("stale observed_at %q not earlier than attempt_at %q", fw.ObservedAt, fw.AttemptAt) + } + + // Host with NULL freshness: field omitted entirely. + plainHid := uuid.Must(uuid.NewV7()) + _, err = pool.Exec(context.Background(), + `INSERT INTO hosts (id, hostname, ip_address, environment, created_by) + VALUES ($1, $2, $3::inet, 'production', (SELECT id FROM users LIMIT 1))`, + plainHid, "ow-fresh-null", "192.0.2.11") + if err != nil { + t.Fatalf("seed plain host: %v", err) + } + _, err = pool.Exec(context.Background(), + `INSERT INTO host_system_info (host_id, os_family, collected_at) + VALUES ($1, 'rhel', now())`, plainHid) + if err != nil { + t.Fatalf("seed plain system_info: %v", err) + } + req2 := asRole(t, "GET", url+"/api/v1/hosts/"+plainHid.String()+"/system-info", auth.RoleViewer, nil) + resp2, err := http.DefaultClient.Do(req2) + if err != nil { + t.Fatalf("GET plain: %v", err) + } + defer resp2.Body.Close() + var raw map[string]json.RawMessage + if err := json.NewDecoder(resp2.Body).Decode(&raw); err != nil { + t.Fatalf("decode plain: %v", err) + } + if _, present := raw["category_freshness"]; present { + t.Errorf("category_freshness should be omitted for a NULL row, got %s", raw["category_freshness"]) + } + }) +} + // Placeholder type so the seedSystemInfo helper compiles cleanly even // without using it. Real seeding happens inline in AC-01. type pgconn struct{} diff --git a/internal/server/api_intelligence_test.go b/internal/server/api_intelligence_test.go index f54f211a..bf84b31b 100644 --- a/internal/server/api_intelligence_test.go +++ b/internal/server/api_intelligence_test.go @@ -12,6 +12,7 @@ // AC-08 TestAPI_Intelligence_State_WithSnapshot_Returns200 // AC-09 TestAPI_Intelligence_Events_UnknownSeverity_Returns400 // AC-10 TestAPI_Intelligence_Events_TimeRangeFilter +// AC-11 TestAPI_Intelligence_State_ExposesCategoryFreshness package server @@ -292,6 +293,71 @@ func TestAPI_Intelligence_State_WithSnapshot_Returns200(t *testing.T) { }) } +// AC-11: a state row whose category_freshness records a stale category +// is exposed on the 200 body; a row with NULL freshness omits the field. +// @ac AC-11 +func TestAPI_Intelligence_State_ExposesCategoryFreshness(t *testing.T) { + t.Run("api-os-intelligence/AC-11", func(t *testing.T) { + srv, pool := freshAPIServer(t) + + // Host WITH a stale-services freshness map. + staleHost := seedHostForIntel(t, pool) + seedIntelState(t, pool, staleHost, map[string]any{"kernel_release": "6.1.0"}, time.Now().UTC()) + _, err := pool.Exec(context.Background(), + `UPDATE host_intelligence_state + SET category_freshness = jsonb_build_object( + 'services', jsonb_build_object( + 'status', 'stale', + 'observed_at', to_char((now() - interval '3 hours') AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), + 'attempt_at', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))) + WHERE host_id = $1`, staleHost) + if err != nil { + t.Fatalf("update freshness: %v", err) + } + + req := asRole(t, "GET", srv+"/api/v1/intelligence/state/"+staleHost.String(), auth.RoleAdmin, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + var state api.IntelligenceState + if err := json.NewDecoder(resp.Body).Decode(&state); err != nil { + t.Fatalf("decode: %v", err) + } + if state.CategoryFreshness == nil { + t.Fatalf("category_freshness nil, want stale services entry") + } + svc, ok := (*state.CategoryFreshness)["services"] + if !ok { + t.Fatalf("services freshness missing: %+v", *state.CategoryFreshness) + } + if string(svc.Status) != "stale" { + t.Errorf("services status = %q, want stale", svc.Status) + } + + // Host with NULL freshness: field omitted. + plainHost := seedHostForIntel(t, pool) + seedIntelState(t, pool, plainHost, map[string]any{"kernel_release": "6.1.0"}, time.Now().UTC()) + req2 := asRole(t, "GET", srv+"/api/v1/intelligence/state/"+plainHost.String(), auth.RoleAdmin, nil) + resp2, err := http.DefaultClient.Do(req2) + if err != nil { + t.Fatalf("GET plain: %v", err) + } + defer resp2.Body.Close() + var raw map[string]json.RawMessage + if err := json.NewDecoder(resp2.Body).Decode(&raw); err != nil { + t.Fatalf("decode plain: %v", err) + } + if _, present := raw["category_freshness"]; present { + t.Errorf("category_freshness should be omitted for a NULL row, got %s", raw["category_freshness"]) + } + }) +} + // @ac AC-09 func TestAPI_Intelligence_Events_UnknownSeverity_Returns400(t *testing.T) { t.Run("api-os-intelligence/AC-09", func(t *testing.T) { diff --git a/internal/server/host_system_info_handler.go b/internal/server/host_system_info_handler.go index 8b22bf47..0b450caa 100644 --- a/internal/server/host_system_info_handler.go +++ b/internal/server/host_system_info_handler.go @@ -14,6 +14,7 @@ package server import ( + "encoding/json" "errors" "net/http" @@ -62,11 +63,12 @@ func (h *handlers) GetHostSystemInfo( hostname, fqdn, selinux_status, apparmor_enabled, firewall_service, firewall_status, - collected_at + collected_at, category_freshness FROM host_system_info WHERE host_id = $1` resp := api.HostSystemInfo{HostId: openapitypes.UUID(hid)} + var freshRaw []byte err := h.pool.QueryRow(r.Context(), q, hid).Scan( &resp.OsName, &resp.OsVersion, &resp.OsVersionFull, &resp.OsId, &resp.OsIdLike, &resp.OsPrettyName, &resp.PlatformIdentifier, &resp.OsFamily, @@ -76,7 +78,7 @@ func (h *handlers) GetHostSystemInfo( &resp.Hostname, &resp.Fqdn, &resp.SelinuxStatus, &resp.ApparmorEnabled, &resp.FirewallService, &resp.FirewallStatus, - &resp.CollectedAt, + &resp.CollectedAt, &freshRaw, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -90,5 +92,15 @@ func (h *handlers) GetHostSystemInfo( return } + // category_freshness is JSONB; NULL for rows written before migration + // 0052. Unmarshal into the typed map when present; a decode failure is + // non-fatal — the row's facts are still valid without freshness metadata. + if len(freshRaw) > 0 { + var cf api.CategoryFreshness + if json.Unmarshal(freshRaw, &cf) == nil && len(cf) > 0 { + resp.CategoryFreshness = &cf + } + } + writeJSON(w, http.StatusOK, resp) } diff --git a/internal/server/intelligence_handlers.go b/internal/server/intelligence_handlers.go index e0cadf67..690ef3eb 100644 --- a/internal/server/intelligence_handlers.go +++ b/internal/server/intelligence_handlers.go @@ -173,11 +173,12 @@ func (h *handlers) GetIntelligenceState( var ( snapshotBytes []byte collectedAt time.Time + freshRaw []byte ) err := h.pool.QueryRow(r.Context(), - `SELECT snapshot, collected_at FROM host_intelligence_state WHERE host_id = $1`, + `SELECT snapshot, collected_at, category_freshness FROM host_intelligence_state WHERE host_id = $1`, hid, - ).Scan(&snapshotBytes, &collectedAt) + ).Scan(&snapshotBytes, &collectedAt, &freshRaw) if err != nil { if errors.Is(err, pgx.ErrNoRows) { // Spec C-04: same envelope as "host unknown". @@ -194,9 +195,18 @@ func (h *handlers) GetIntelligenceState( if len(snapshotBytes) > 0 { _ = json.Unmarshal(snapshotBytes, &snap) } - writeJSON(w, http.StatusOK, api.IntelligenceState{ + resp := api.IntelligenceState{ HostId: openapitypes.UUID(hid), Snapshot: snap, CollectedAt: collectedAt, - }) + } + // category_freshness is JSONB; NULL for rows written before migration + // 0052. Decode failures are non-fatal — the snapshot is still valid. + if len(freshRaw) > 0 { + var cf api.CategoryFreshness + if json.Unmarshal(freshRaw, &cf) == nil && len(cf) > 0 { + resp.CategoryFreshness = &cf + } + } + writeJSON(w, http.StatusOK, resp) } diff --git a/specs/api/host-system-info.spec.yaml b/specs/api/host-system-info.spec.yaml index 2ae67230..a4b81a2e 100644 --- a/specs/api/host-system-info.spec.yaml +++ b/specs/api/host-system-info.spec.yaml @@ -1,7 +1,7 @@ spec: id: api-host-system-info title: GET /hosts/{id}/system-info — last Discovery result for one host - version: "1.0.0" + version: "1.1.0" status: approved tier: 1 @@ -69,6 +69,10 @@ spec: description: '200 response body MUST be the existing HostSystemInfo schema (declared by api/openapi.yaml, also returned by POST /hosts/{id}/discovery:run). No new schema, no transformation — the column-for-column shape stays consistent across the synchronous Discovery result and this read path' type: technical enforcement: error + - id: C-05 + description: 'When the host_system_info row carries a category_freshness JSONB map (migration 0052; written by system-host-discovery v1.6.0), the 200 body MUST expose it as the CategoryFreshness field so operators can tell a freshly-observed value from a carried-forward (stale) one. A NULL/absent map (rows written before 0052) MUST omit the field rather than emit an empty object — the endpoint stays backward compatible' + type: technical + enforcement: error acceptance_criteria: - id: AC-01 @@ -91,3 +95,7 @@ spec: description: 'Source inspection: the handler reads host_system_info via a parameterized pgx QueryRow with $1 bound to the host id — never a string-concat or fmt.Sprintf-built SQL. Source MUST contain WHERE host_id = $1' priority: critical references_constraints: [C-02] + - id: AC-06 + description: 'GET /api/v1/hosts/{id}/system-info with host:read on a row whose category_freshness JSONB records a stale category returns 200 with a category_freshness object; the stale category entry carries status="stale" and an observed_at earlier than attempt_at. A row with NULL category_freshness returns 200 with the field omitted' + priority: high + references_constraints: [C-05] diff --git a/specs/api/os-intelligence.spec.yaml b/specs/api/os-intelligence.spec.yaml index 3ec84159..cd8884a6 100644 --- a/specs/api/os-intelligence.spec.yaml +++ b/specs/api/os-intelligence.spec.yaml @@ -1,7 +1,7 @@ spec: id: api-os-intelligence title: OS Intelligence read API - version: "1.0.0" + version: "1.1.0" status: approved tier: 2 @@ -74,6 +74,10 @@ spec: description: 'The events response severity filter accepts the closed set {info, low, medium, high, critical}. Unknown severity values yield 400 validation.field_range' type: technical enforcement: error + - id: C-06 + description: 'When the host_intelligence_state row carries a category_freshness JSONB map (migration 0052; written by system-os-intelligence v1.3.0), GET /intelligence/state/{host_id} MUST expose it as the CategoryFreshness field so callers can distinguish a freshly-observed snapshot category from a carried-forward (stale) one. A NULL/absent map MUST omit the field, not emit an empty object' + type: technical + enforcement: error acceptance_criteria: - id: AC-01 @@ -116,3 +120,7 @@ spec: description: 'GET /api/v1/intelligence/events?since=&until= filters by detected_at within [since, until). Inclusive on since, exclusive on until — same semantics as /audit/events' priority: high references_constraints: [C-03] + - id: AC-11 + description: 'GET /api/v1/intelligence/state/{host_id} on a row whose category_freshness JSONB records a stale category returns 200 with a category_freshness object; the stale entry carries status="stale". A row with NULL category_freshness returns 200 with the field omitted' + priority: high + references_constraints: [C-06] diff --git a/specs/frontend/host-detail-system-card.spec.yaml b/specs/frontend/host-detail-system-card.spec.yaml index cd668656..f965854d 100644 --- a/specs/frontend/host-detail-system-card.spec.yaml +++ b/specs/frontend/host-detail-system-card.spec.yaml @@ -1,7 +1,7 @@ spec: id: frontend-host-detail-system-card title: Host detail System card — Distribution / Kernel / Uptime from real data - version: "1.0.0" + version: "1.1.0" status: approved tier: 2 @@ -98,6 +98,10 @@ spec: description: 'A 502 response (host unreachable) MUST surface inline next to the button with the message "Host unreachable — check SSH credentials and connectivity". A 403 MUST NOT happen at runtime because of C-03 but if it does, the message is "Permission denied". All other failures show the generic envelope.error.message' type: technical enforcement: error + - id: C-07 + description: 'When systemInfo.category_freshness marks a rendered category status="stale" (the value was carried forward from an earlier run because the latest Discovery did not re-observe it; api-host-system-info C-05), the card MUST render an inline "Last verified " caveat beneath that value so the operator knows the reading is last-known-good, not fresh. A category with status="ok" or an absent freshness map MUST render NO caveat. The relative helper MUST be pure of locale/timezone effects that would change the coarse bucket (m/h/d ago)' + type: technical + enforcement: error acceptance_criteria: - id: AC-01 @@ -128,3 +132,7 @@ spec: description: 'A 502 response surfaces the exact string "Host unreachable — check SSH credentials and connectivity" inline next to the Re-run Discovery button. Verified by source inspection — the constant string appears in the file' priority: high references_constraints: [C-06] + - id: AC-08 + description: 'CardSystem rendered with a discovered host whose systemInfo.category_freshness marks "firewall" status="stale" (observed_at ~2 days earlier) shows a "Last verified" caveat on the Firewall row; the same card with category_freshness null (or the firewall entry status="ok") renders NO "Last verified" text. formatTimeAgo(now-2d) buckets to "2d ago"; formatTimeAgo of a value < 1 minute old is "just now"' + priority: high + references_constraints: [C-07]