This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
// 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/<pid>/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/<pid>/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...)
|
||||
}
|
||||
Reference in New Issue
Block a user