Publish ITWorx Pulse source
Public source validation / validate (push) Failing after 3m8s

This commit is contained in:
ITWorx Pulse release export
2026-09-03 02:09:19 +02:00
commit bd774932d5
614 changed files with 77116 additions and 0 deletions
+350
View File
@@ -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...)
}