// Package hostcollect turns the kernel's read-only procfs and sysfs views into the // bounded domain snapshots defined by internal/host and internal/process. // // Everything here is read-only by construction: the collector opens files under a // configurable procfs/sysfs root and never writes, executes, or opens a socket. That // is what lets pulse-agent run with cap_drop: [ALL] and satisfy ADR-0005 — no Docker // socket, no privileged host access, no mutation path. // // The roots are injectable so the parsers can be exercised against committed fixture // directories: the test environment is never the target host, and a collector that can // only be tested on the real machine is a collector that is not tested at all. // // Two properties of /proc drive most of the design: // // - Utilisation is a delta, not a reading. /proc/stat and /proc//stat expose // monotonic counters in USER_HZ ticks, so a single sample cannot yield a percentage. // The collector keeps the previous sample and reports percentages only from the // second collection onwards. // - /proc is a live view of a moving system. Processes disappear between readdir and // open, counters reset when a kernel counter wraps or a task is replaced by a new // one with the same PID. Every such case degrades one field, never the collection. package hostcollect import ( "context" "errors" "fmt" "os" "path/filepath" "runtime" "sync" "time" "github.com/itworx/pulse/internal/host" "github.com/itworx/pulse/internal/process" ) const ( // DefaultProcRoot is the standard procfs mount point. DefaultProcRoot = "/proc" // DefaultSysRoot is the standard sysfs mount point. DefaultSysRoot = "/sys" // defaultClockTicks is the USER_HZ value the kernel exposes CPU times in. It is // 100 on every supported Linux/architecture combination Pulse targets; sysconf is // unavailable without cgo, so it stays configurable instead of guessed silently. defaultClockTicks = 100 // defaultPageSizeFallback is used only when the runtime reports a nonsensical page // size; RSS in /proc//stat is counted in pages, not bytes. defaultPageSizeFallback = 4096 maxFileBytes = 1 << 20 maxProcessFileBytes = 64 << 10 // maxCmdlineBytes bounds how much of a command line is read at all. Only the // program name is ever kept, so arguments — which routinely carry tokens and // passwords — are never copied into a snapshot. maxCmdlineBytes = 4 << 10 maxNameBytes = 255 ) // Warning codes attached to a host snapshot when one optional field degrades. They are // stable identifiers, safe to log and to render, and never contain host data. const ( WarningCPUUnavailable = "cpu_source_unavailable" WarningCPUFirstSample = "cpu_awaiting_second_sample" WarningCPUCounterReset = "cpu_counter_reset" WarningCPUTopologyChanged = "cpu_topology_changed" WarningCPUCoresTruncated = "cpu_cores_truncated" WarningLoadUnavailable = "load_source_unavailable" WarningNetworkUnavailable = "network_source_unavailable" WarningNetworkTruncated = "network_interfaces_truncated" WarningMountsUnavailable = "filesystem_source_unavailable" WarningFilesystemPartial = "filesystem_partially_unavailable" WarningFilesystemTruncated = "filesystems_truncated" WarningFilesystemDisabled = "filesystem_root_not_configured" WarningKernelUnavailable = "kernel_version_unavailable" WarningClockProbeAssumed = "clock_sync_unverified_assumed_synchronized" ) // FilesystemUsage is the bounded result of one statfs call. type FilesystemUsage struct { CapacityBytes uint64 UsedBytes uint64 InodesTotal uint64 InodesUsed uint64 } // Options configures a Collector. The zero value is usable and reads the real host. type Options struct { // ProcRoot is the procfs mount point, "/proc" by default. In a container it is the // read-only bind mount of the host's /proc (see deploy/compose.yaml). ProcRoot string // SysRoot is the sysfs mount point, "/sys" by default. Only used for interface // operational state today. SysRoot string // FilesystemRoot prefixes every mount point before statfs. It is empty by default, // which disables filesystem collection entirely: inside a container the host mount // points listed in /proc/mounts do not resolve, and statfs of an identically named // path would silently report the container's own overlay instead of the host's // array. Set it to "/" on a host, or to the prefix a host root is mounted at. FilesystemRoot string // HostName overrides the collected host name. /proc/sys/kernel/hostname is read // through the reader's UTS namespace, so inside a container it returns the // container's name even when the host's /proc is bind mounted. HostName string // ClockTicks is USER_HZ; 100 when unset. ClockTicks float64 // PageSize is the memory page size in bytes; the runtime value when unset. PageSize int // SourceID identifies this collector in the snapshot's Source block. SourceID string // HostLimits bounds the host snapshot (cores, filesystems, interfaces, warnings). HostLimits host.Limits // ProcessLimits bounds the process inventory. ProcessLimits process.Limits // Now returns the current time; time.Now when unset. Now func() time.Time // StatFS reads usage for one mount point; a real statfs syscall when unset. StatFS func(path string) (FilesystemUsage, error) // ClockSync reports host clock synchronisation; adjtimex(2) in read mode when unset. ClockSync func() (host.RawTime, error) } func (o Options) withDefaults() Options { if o.ProcRoot == "" { o.ProcRoot = DefaultProcRoot } if o.SysRoot == "" { o.SysRoot = DefaultSysRoot } if o.ClockTicks <= 0 { o.ClockTicks = defaultClockTicks } if o.PageSize <= 0 { o.PageSize = os.Getpagesize() } if o.PageSize <= 0 { o.PageSize = defaultPageSizeFallback } if o.SourceID == "" { o.SourceID = "host" } o.HostLimits = hostLimitsWithDefaults(o.HostLimits) o.ProcessLimits = processLimitsWithDefaults(o.ProcessLimits) if o.Now == nil { o.Now = time.Now } if o.StatFS == nil { o.StatFS = statFS } if o.ClockSync == nil { o.ClockSync = readClockSync } return o } // hostLimitsWithDefaults mirrors the defaults internal/host applies during // normalization; the collector must bound its output before the domain sees it, and // the domain's own defaulting is unexported. func hostLimitsWithDefaults(limits host.Limits) host.Limits { if limits.MaxCores == 0 { limits.MaxCores = 256 } if limits.MaxFilesystems == 0 { limits.MaxFilesystems = 256 } if limits.MaxInterfaces == 0 { limits.MaxInterfaces = 128 } if limits.MaxWarnings == 0 { limits.MaxWarnings = 20 } return limits } func processLimitsWithDefaults(limits process.Limits) process.Limits { if limits.MaxRows == 0 { limits.MaxRows = 1000 } if limits.MaxPageSize == 0 { limits.MaxPageSize = 100 } return limits } // Collector reads host and process telemetry. It is safe for concurrent use; the // previous samples it needs for delta calculation are guarded by a mutex. type Collector struct { options Options mu sync.Mutex cpu *cpuSample processes map[processKey]processCPUSample } // New validates the options and returns a ready collector. func New(options Options) (*Collector, error) { options = options.withDefaults() if !filepath.IsAbs(options.ProcRoot) && !isTestPath(options.ProcRoot) { return nil, fmt.Errorf("hostcollect: proc root %q must be an absolute path", options.ProcRoot) } if err := options.HostLimits.Validate(); err != nil { return nil, fmt.Errorf("hostcollect: %w", err) } if err := options.ProcessLimits.Validate(); err != nil { return nil, fmt.Errorf("hostcollect: %w", err) } return &Collector{options: options, processes: map[processKey]processCPUSample{}}, nil } // isTestPath allows relative fixture roots so tests can use testdata directories // without constructing absolute paths. func isTestPath(path string) bool { return path != "" && !filepath.IsAbs(path) } // Host reads one bounded host snapshot. It fails only when a field the domain requires // is unreadable (identity, uptime, memory); every optional field degrades into a // warning so a single unreadable file cannot blank the whole capability. func (c *Collector) Host(ctx context.Context) (host.RawSnapshot, error) { if c == nil { return host.RawSnapshot{}, errors.New("hostcollect: collector is nil") } if err := ctx.Err(); err != nil { return host.RawSnapshot{}, err } now := c.options.Now().UTC() warnings := newWarningSet(c.options.HostLimits.MaxWarnings) identity, err := c.identity() if err != nil { return host.RawSnapshot{}, err } uptimeSeconds, err := c.uptimeSeconds() if err != nil { return host.RawSnapshot{}, err } memory, err := c.memory() if err != nil { return host.RawSnapshot{}, err } bootTime := now.Add(-time.Duration(uptimeSeconds * float64(time.Second))).UTC() load, err := c.loadAverage() if err != nil { warnings.add(WarningLoadUnavailable) } cpu := c.cpuUsage(now, warnings) network := c.network(warnings) filesystems := c.filesystems(warnings) clock := c.clock(warnings) return host.RawSnapshot{ Source: host.Source{ ID: c.options.SourceID, Type: "agent", CapabilityVersion: host.ContractVersion, ObservedAt: now, }, Identity: identity, UptimeSeconds: uptimeSeconds, BootTime: &bootTime, CPU: cpu, Load: load, Memory: memory, Filesystems: filesystems, Network: network, Time: clock, ObservedAt: now, Warnings: warnings.list(), }, nil } func (c *Collector) identity() (host.HostIdentity, error) { identity := host.HostIdentity{Name: c.options.HostName, Arch: runtime.GOARCH} if identity.Name == "" { name, err := readTrimmed(c.procPath("sys", "kernel", "hostname"), maxProcessFileBytes) if err != nil { return host.HostIdentity{}, fmt.Errorf("hostcollect: read host name: %w", err) } identity.Name = name } if identity.Name == "" { return host.HostIdentity{}, errors.New("hostcollect: host name is empty") } if len(identity.Name) > maxNameBytes { identity.Name = identity.Name[:maxNameBytes] } if kernel, err := readTrimmed(c.procPath("sys", "kernel", "osrelease"), maxProcessFileBytes); err == nil { identity.Kernel = truncate(kernel, maxNameBytes) } return identity, nil } // clock reports host clock synchronisation. adjtimex(2) in read mode (Modes == 0) // mutates nothing, but Docker's default seccomp profile gates it behind CAP_SYS_TIME, // which the agent drops. When the probe is unavailable the collector reports the clock // as synchronised and records a warning rather than reporting an unsynchronised clock: // the domain maps "not synchronised" to Degraded with an explicit operator-facing // reason, and asserting a clock fault we never observed would be a false alarm on every // healthy host. The warning keeps the uncertainty visible in the snapshot. func (c *Collector) clock(warnings *warningSet) host.RawTime { clock, err := c.options.ClockSync() if err != nil { warnings.add(WarningClockProbeAssumed) return host.RawTime{Synchronized: true} } if clock.OffsetSeconds < 0 { clock.OffsetSeconds = -clock.OffsetSeconds } return clock } func (c *Collector) procPath(elements ...string) string { return filepath.Join(append([]string{c.options.ProcRoot}, elements...)...) } func (c *Collector) sysPath(elements ...string) string { return filepath.Join(append([]string{c.options.SysRoot}, elements...)...) } // warningSet collects deduplicated, bounded warning codes. type warningSet struct { max int seen map[string]struct{} items []string } func newWarningSet(max int) *warningSet { if max < 1 { max = 1 } return &warningSet{max: max, seen: map[string]struct{}{}} } func (w *warningSet) add(code string) { if code == "" || len(w.items) >= w.max { return } if _, exists := w.seen[code]; exists { return } w.seen[code] = struct{}{} w.items = append(w.items, code) } func (w *warningSet) list() []string { if len(w.items) == 0 { return nil } return append([]string(nil), w.items...) }