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
+372
View File
@@ -0,0 +1,372 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"time"
"github.com/itworx/pulse/internal/agentprotocol"
"github.com/itworx/pulse/internal/agentstore"
"github.com/itworx/pulse/internal/array"
"github.com/itworx/pulse/internal/container"
"github.com/itworx/pulse/internal/disk"
"github.com/itworx/pulse/internal/host"
"github.com/itworx/pulse/internal/pool"
"github.com/itworx/pulse/internal/process"
"github.com/itworx/pulse/internal/runtimeconfig"
"github.com/itworx/pulse/internal/share"
)
const (
// maxLoopInterval bounds one scheduling iteration so the heartbeat is refreshed at
// least every 10 seconds even when collection is configured to run far less often.
// See docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md point 3.
maxLoopInterval = 5 * time.Second
// operationTimeout bounds every blocking call inside one iteration — collection and
// the database write. It sits well under the 10 second heartbeat window so a hung
// database cannot stall the loop into a false "unhealthy" restart, and equally
// cannot hide a real hang: the call is abandoned and reported.
operationTimeout = 4 * time.Second
// heartbeatFileMode keeps the liveness file readable only by the agent's own user;
// the healthcheck script runs as the same user.
heartbeatFileMode = 0o600
)
// snapshotSource is the narrow view of the collector the runtime needs. It keeps the
// loop testable without a procfs tree.
type snapshotSource interface {
Host(context.Context) (host.RawSnapshot, error)
Processes(context.Context) (process.RawSnapshot, error)
}
type containerSnapshotSource interface {
Containers(context.Context) (container.RawSnapshot, error)
}
type arraySnapshotSource interface {
Array(context.Context) (array.RawSnapshot, error)
}
type diskSnapshotSource interface {
Disks(context.Context) (disk.RawSnapshot, error)
}
type poolSnapshotSource interface {
Pools(context.Context) (pool.RawSnapshot, error)
}
type shareSnapshotSource interface {
Shares(context.Context) (share.RawSnapshot, error)
}
// capability binds one telemetry surface to the collection that produces it. Every
// capability the agent announces is read-only; there is no write path in this binary.
type capability struct {
id agentstore.Capability
version string
collect func(context.Context) (json.RawMessage, time.Time, error)
}
// tickerFactory produces the loop's tick channel. Tests replace it with a channel they
// drive by hand so loop behaviour is asserted without sleeping.
type tickerFactory func(time.Duration) (<-chan time.Time, func())
type agent struct {
agentID string
writer agentstore.Writer
logger *slog.Logger
now func() time.Time
collectInterval time.Duration
loopInterval time.Duration
operationTimeout time.Duration
heartbeatPath string
capabilities []capability
newTicker tickerFactory
// nextCollect is the earliest time the next collection pass may run. It is only
// touched from the loop goroutine.
nextCollect time.Time
}
func realTicker(interval time.Duration) (<-chan time.Time, func()) {
ticker := time.NewTicker(interval)
return ticker.C, ticker.Stop
}
func newAgent(config runtimeconfig.AgentConfig, source snapshotSource, writer agentstore.Writer, logger *slog.Logger) *agent {
loopInterval := config.CollectInterval
if loopInterval > maxLoopInterval {
loopInterval = maxLoopInterval
}
return &agent{
agentID: config.AgentID,
writer: writer,
logger: logger,
now: time.Now,
collectInterval: config.CollectInterval,
loopInterval: loopInterval,
operationTimeout: operationTimeout,
heartbeatPath: config.Service.HeartbeatFile,
capabilities: capabilities(source),
newTicker: realTicker,
}
}
// capabilities lists what this agent reports. Adding a capability here is the only way
// to widen what the agent reads, which keeps the surface auditable.
func capabilities(source snapshotSource) []capability {
result := []capability{
{
id: agentstore.CapabilityHost,
version: host.ContractVersion,
collect: func(ctx context.Context) (json.RawMessage, time.Time, error) {
snapshot, err := source.Host(ctx)
if err != nil {
return nil, time.Time{}, err
}
payload, err := json.Marshal(snapshot)
if err != nil {
return nil, time.Time{}, err
}
return payload, snapshot.ObservedAt, nil
},
},
{
id: agentstore.CapabilityProcesses,
version: process.ContractVersion,
collect: func(ctx context.Context) (json.RawMessage, time.Time, error) {
snapshot, err := source.Processes(ctx)
if err != nil {
return nil, time.Time{}, err
}
payload, err := json.Marshal(snapshot)
if err != nil {
return nil, time.Time{}, err
}
return payload, snapshot.ObservedAt, nil
},
},
}
if containers, ok := source.(containerSnapshotSource); ok {
result = append(result, capability{
id: agentstore.CapabilityContainers, version: container.ContractVersion,
collect: func(ctx context.Context) (json.RawMessage, time.Time, error) {
snapshot, err := containers.Containers(ctx)
if err != nil {
return nil, time.Time{}, err
}
payload, err := json.Marshal(snapshot)
if err != nil {
return nil, time.Time{}, err
}
return payload, snapshot.ObservedAt, nil
},
})
}
if source, ok := source.(arraySnapshotSource); ok {
result = append(result, rawCapability(agentstore.CapabilityArray, array.ContractVersion, source.Array))
}
if source, ok := source.(diskSnapshotSource); ok {
result = append(result, rawCapability(agentstore.CapabilityDisks, disk.ContractVersion, source.Disks))
}
if source, ok := source.(poolSnapshotSource); ok {
result = append(result, rawCapability(agentstore.CapabilityPools, pool.ContractVersion, source.Pools))
}
if source, ok := source.(shareSnapshotSource); ok {
result = append(result, rawCapability(agentstore.CapabilityShares, share.ContractVersion, source.Shares))
}
return result
}
func rawCapability[T any](id agentstore.Capability, version string, collect func(context.Context) (T, error)) capability {
return capability{id: id, version: version, collect: func(ctx context.Context) (json.RawMessage, time.Time, error) {
snapshot, err := collect(ctx)
if err != nil {
return nil, time.Time{}, err
}
payload, err := json.Marshal(snapshot)
if err != nil {
return nil, time.Time{}, err
}
observed := observedAt(snapshot)
return payload, observed, nil
}}
}
// All raw snapshot contracts carry ObservedAt. Keep this tiny type assertion local to
// the agent rather than introducing a repository-wide generic telemetry abstraction.
func observedAt(snapshot any) time.Time {
switch value := snapshot.(type) {
case array.RawSnapshot:
return value.ObservedAt
case disk.RawSnapshot:
return value.ObservedAt
case pool.RawSnapshot:
return value.ObservedAt
case share.RawSnapshot:
return value.ObservedAt
default:
return time.Time{}
}
}
// hello is the capability announcement. Every entry is read-only, which is not a
// decoration: agentprotocol.Hello.Validate rejects a hello that claims anything else,
// so the agent's own start-up self-check fails loudly if a mutating capability is ever
// added here by mistake.
func (a *agent) hello() agentprotocol.Hello {
announced := make([]agentprotocol.Capability, 0, len(a.capabilities))
for _, item := range a.capabilities {
announced = append(announced, agentprotocol.Capability{
ID: string(item.id),
Version: item.version,
ReadOnly: true,
})
}
return agentprotocol.Hello{
Protocol: agentprotocol.Version,
AgentID: a.agentID,
ObservedAt: a.now().UTC(),
Capabilities: announced,
}
}
// run drives the collection loop until the context is cancelled.
//
// The order is fixed by the healthcheck contract: validate, log, write one heartbeat
// before the first blocking call, then loop. Each iteration does its bounded work and
// ends by refreshing the heartbeat, so a stuck iteration stops the heartbeat instead of
// a background ticker papering over the stall.
func (a *agent) run(ctx context.Context) error {
if a.writer == nil {
return errNoWriter
}
hello := a.hello()
if err := hello.Validate(a.now().UTC()); err != nil {
return fmt.Errorf("agent hello failed its own read-only validation: %w", err)
}
announced := make([]string, 0, len(hello.Capabilities))
for _, item := range hello.Capabilities {
announced = append(announced, item.ID)
}
a.logger.Info("pulse agent started",
"agent_id", a.agentID,
"protocol", hello.Protocol,
"capabilities", announced,
"read_only", true,
"collect_interval", a.collectInterval.String(),
"loop_interval", a.loopInterval.String(),
"operation_timeout", a.operationTimeout.String(),
"heartbeat_file", a.heartbeatPath,
)
// Contract point 4: a slow-but-healthy cold start must not look like a hang.
a.heartbeat()
ticks, stop := a.newTicker(a.loopInterval)
defer stop()
loop:
for ctx.Err() == nil {
a.iterate(ctx)
select {
case <-ctx.Done():
break loop
case <-ticks:
}
}
a.logger.Info("pulse agent stopped", "agent_id", a.agentID)
return nil
}
// iterate is one unit of work: collect when due, then heartbeat. An empty iteration —
// nothing due yet — is still a completed iteration and still heartbeats.
func (a *agent) iterate(ctx context.Context) {
now := a.now()
if !now.Before(a.nextCollect) {
a.collectOnce(ctx)
a.nextCollect = a.now().Add(a.collectInterval)
}
a.heartbeat()
}
// collectOnce publishes every capability independently. One failing capability is
// logged and skipped; it neither aborts the pass nor causes a stale or empty snapshot
// to be written in its place. A missing snapshot is exactly what the reader turns into
// Unknown (ADR-0008), which is the honest outcome.
func (a *agent) collectOnce(ctx context.Context) {
for _, item := range a.capabilities {
if ctx.Err() != nil {
return
}
started := a.now()
bytesWritten, err := a.publish(ctx, item)
if err != nil {
a.logger.Error("agent capability collection failed",
"agent_id", a.agentID,
"capability", string(item.id),
"duration_ms", a.now().Sub(started).Milliseconds(),
"error", err.Error(),
)
continue
}
a.logger.Info("agent snapshot published",
"agent_id", a.agentID,
"capability", string(item.id),
"payload_bytes", bytesWritten,
"duration_ms", a.now().Sub(started).Milliseconds(),
)
}
}
// publish collects and writes one capability under a bounded context.
func (a *agent) publish(ctx context.Context, item capability) (int, error) {
operation, cancel := context.WithTimeout(ctx, a.operationTimeout)
defer cancel()
payload, observedAt, err := item.collect(operation)
if err != nil {
return 0, fmt.Errorf("collect %s: %w", item.id, err)
}
if len(payload) == 0 {
return 0, fmt.Errorf("collect %s: empty payload", item.id)
}
if len(payload) > agentstore.MaxPayloadBytes {
// Writing a truncated snapshot would be worse than writing none: the reader
// cannot tell a truncated payload from a complete one.
return 0, fmt.Errorf("collect %s: payload of %d bytes exceeds the %d byte limit", item.id, len(payload), agentstore.MaxPayloadBytes)
}
if observedAt.IsZero() {
observedAt = a.now().UTC()
}
if err := a.writer.Put(operation, agentstore.Snapshot{
AgentID: a.agentID,
Capability: item.id,
ObservedAt: observedAt.UTC(),
Payload: payload,
}); err != nil {
return 0, fmt.Errorf("publish %s: %w", item.id, err)
}
return len(payload), nil
}
// heartbeat refreshes the liveness file the compose healthcheck watches. Only the mtime
// matters to the script; the RFC 3339 body exists so `docker exec … cat /tmp/healthy`
// tells an operator something. A write failure is logged and the loop continues:
// crashing on a tmpfs hiccup would turn a cosmetic problem into an outage, and a
// sustained failure surfaces on its own as staleness.
func (a *agent) heartbeat() {
if a.heartbeatPath == "" {
return
}
content := a.now().UTC().Format(time.RFC3339) + "\n"
if err := os.WriteFile(a.heartbeatPath, []byte(content), heartbeatFileMode); err != nil {
a.logger.Error("agent heartbeat write failed",
"agent_id", a.agentID,
"heartbeat_file", a.heartbeatPath,
"error", err.Error(),
)
}
}
var errNoWriter = errors.New("agent snapshot writer is required")
+543
View File
@@ -0,0 +1,543 @@
package main
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/itworx/pulse/internal/agentstore"
"github.com/itworx/pulse/internal/array"
"github.com/itworx/pulse/internal/container"
"github.com/itworx/pulse/internal/disk"
"github.com/itworx/pulse/internal/host"
"github.com/itworx/pulse/internal/pool"
"github.com/itworx/pulse/internal/process"
"github.com/itworx/pulse/internal/runtimeconfig"
"github.com/itworx/pulse/internal/share"
)
// fakeWriter records what the agent publishes and can fail a chosen capability.
type fakeWriter struct {
mu sync.Mutex
puts []agentstore.Snapshot
deadlines []bool
failures map[agentstore.Capability]error
blockUntil chan struct{}
}
func newFakeWriter() *fakeWriter {
return &fakeWriter{failures: map[agentstore.Capability]error{}}
}
func (w *fakeWriter) Put(ctx context.Context, snapshot agentstore.Snapshot) error {
w.mu.Lock()
failure := w.failures[snapshot.Capability]
block := w.blockUntil
w.mu.Unlock()
if block != nil {
select {
case <-block:
case <-ctx.Done():
return ctx.Err()
}
}
_, hasDeadline := ctx.Deadline()
w.mu.Lock()
defer w.mu.Unlock()
w.deadlines = append(w.deadlines, hasDeadline)
if failure != nil {
return failure
}
w.puts = append(w.puts, snapshot)
return nil
}
func (w *fakeWriter) recorded() []agentstore.Snapshot {
w.mu.Lock()
defer w.mu.Unlock()
return append([]agentstore.Snapshot(nil), w.puts...)
}
func (w *fakeWriter) countFor(capability agentstore.Capability) int {
count := 0
for _, snapshot := range w.recorded() {
if snapshot.Capability == capability {
count++
}
}
return count
}
func (w *fakeWriter) failCapability(capability agentstore.Capability, err error) {
w.mu.Lock()
defer w.mu.Unlock()
w.failures[capability] = err
}
// fakeSource stands in for the procfs collector.
type fakeSource struct {
mu sync.Mutex
hostErr error
processErr error
hostCalls int
procCalls int
release chan struct{}
hugePayload bool
}
type fakeContainerSource struct{ *fakeSource }
func (s fakeContainerSource) Containers(context.Context) (container.RawSnapshot, error) {
return container.RawSnapshot{Source: container.Source{ID: "unraid", Type: "unraid"}, Containers: []container.RawContainer{{ID: "runtime-1", Name: "pulse-api", State: "running", Health: "healthy"}}, ObservedAt: time.Date(2026, 8, 10, 2, 0, 0, 0, time.UTC)}, nil
}
type fakeInventorySource struct{ *fakeSource }
func (s fakeInventorySource) Containers(context.Context) (container.RawSnapshot, error) {
return container.RawSnapshot{Source: container.Source{ID: "unraid", Type: "unraid"}, Containers: []container.RawContainer{}, ObservedAt: time.Date(2026, 8, 10, 2, 0, 0, 0, time.UTC)}, nil
}
func (s fakeInventorySource) Array(context.Context) (array.RawSnapshot, error) {
return array.RawSnapshot{Source: array.Source{ID: "unraid", Type: "unraid"}, State: array.StateOperational, Members: []array.RawMember{}, ObservedAt: time.Date(2026, 8, 10, 2, 0, 0, 0, time.UTC)}, nil
}
func (s fakeInventorySource) Disks(context.Context) (disk.RawSnapshot, error) {
return disk.RawSnapshot{Source: disk.Source{ID: "unraid", Type: "unraid"}, Disks: []disk.RawDisk{}, ObservedAt: time.Date(2026, 8, 10, 2, 0, 0, 0, time.UTC)}, nil
}
func (s fakeInventorySource) Pools(context.Context) (pool.RawSnapshot, error) {
return pool.RawSnapshot{Source: pool.Source{ID: "unraid", Type: "unraid"}, Pools: []pool.RawPool{}, ObservedAt: time.Date(2026, 8, 10, 2, 0, 0, 0, time.UTC)}, nil
}
func (s fakeInventorySource) Shares(context.Context) (share.RawSnapshot, error) {
return share.RawSnapshot{Source: share.Source{ID: "unraid", Type: "unraid"}, Shares: []share.RawShare{}, ObservedAt: time.Date(2026, 8, 10, 2, 0, 0, 0, time.UTC)}, nil
}
func (s *fakeSource) Host(ctx context.Context) (host.RawSnapshot, error) {
s.mu.Lock()
release := s.release
err := s.hostErr
s.hostCalls++
huge := s.hugePayload
s.mu.Unlock()
if release != nil {
select {
case <-release:
case <-ctx.Done():
return host.RawSnapshot{}, ctx.Err()
}
}
if err != nil {
return host.RawSnapshot{}, err
}
snapshot := host.RawSnapshot{
Identity: host.HostIdentity{Name: "tower"},
UptimeSeconds: 100,
Memory: host.RawMemory{TotalBytes: 1024, AvailableBytes: 512},
ObservedAt: time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC),
}
if huge {
warning := strings.Repeat("x", agentstore.MaxPayloadBytes)
snapshot.Warnings = []string{warning}
}
return snapshot, nil
}
func (s *fakeSource) Processes(context.Context) (process.RawSnapshot, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.procCalls++
if s.processErr != nil {
return process.RawSnapshot{}, s.processErr
}
return process.RawSnapshot{
Processes: []process.RawProcess{{PID: 1, Name: "init", State: "sleeping"}},
ObservedAt: time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC),
}, nil
}
func (s *fakeSource) calls() (int, int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.hostCalls, s.procCalls
}
type testClock struct {
mu sync.Mutex
current time.Time
// step advances the clock on every read. A loop test needs strictly increasing
// timestamps, otherwise two iterations can observe the same instant and the second
// one is legitimately "not due yet".
step time.Duration
}
func (c *testClock) now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
current := c.current
c.current = c.current.Add(c.step)
return current
}
func (c *testClock) setStep(step time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.step = step
}
func (c *testClock) advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.current = c.current.Add(d)
}
func newTestAgent(t *testing.T, source snapshotSource, writer agentstore.Writer, collectInterval time.Duration) (*agent, *testClock, string) {
t.Helper()
heartbeat := filepath.Join(t.TempDir(), "healthy")
config := runtimeconfig.AgentConfig{
AgentID: "pulse-agent-test",
CollectInterval: collectInterval,
Service: runtimeconfig.ServiceConfig{ServiceName: "agent", HeartbeatFile: heartbeat},
}
logger := slog.New(slog.NewJSONHandler(io.Discard, nil))
instance := newAgent(config, source, writer, logger)
clock := &testClock{current: time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)}
instance.now = clock.now
return instance, clock, heartbeat
}
func readHeartbeat(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path) //nolint:gosec // test-controlled path
if err != nil {
t.Fatalf("read heartbeat: %v", err)
}
return strings.TrimSpace(string(data))
}
func TestIterateCollectsOnTheIntervalAndHeartbeatsEveryIteration(t *testing.T) {
writer := newFakeWriter()
source := &fakeSource{}
// A 15 second collection interval must not stretch the loop: newAgent caps the
// loop at maxLoopInterval so the heartbeat stays inside the contract's 10s window.
instance, clock, heartbeat := newTestAgent(t, source, writer, 15*time.Second)
if instance.loopInterval != maxLoopInterval {
t.Fatalf("loop interval = %s, want %s", instance.loopInterval, maxLoopInterval)
}
ctx := context.Background()
for iteration := 0; iteration < 7; iteration++ {
instance.iterate(ctx)
if got := readHeartbeat(t, heartbeat); got != clock.now().UTC().Format(time.RFC3339) {
t.Fatalf("iteration %d heartbeat = %q, want the current time", iteration, got)
}
clock.advance(instance.loopInterval)
}
// Seven iterations five seconds apart cover t=0s..t=30s: collection is due at
// t=0s, t=15s and t=30s. The four iterations in between do no work, yet every one
// of them completed and refreshed the heartbeat, which is what the contract asks
// for ("an empty poll is still a completed iteration").
hostCalls, procCalls := source.calls()
if hostCalls != 3 || procCalls != 3 {
t.Fatalf("collections = host %d, processes %d; want 3 each over 30 seconds", hostCalls, procCalls)
}
if writer.countFor(agentstore.CapabilityHost) != 3 || writer.countFor(agentstore.CapabilityProcesses) != 3 {
t.Fatalf("unexpected publications: %+v", writer.recorded())
}
}
func TestOneFailingCapabilityDoesNotStopTheOthers(t *testing.T) {
writer := newFakeWriter()
source := &fakeSource{hostErr: errors.New("procfs read failed")}
instance, _, _ := newTestAgent(t, source, writer, time.Second)
instance.collectOnce(context.Background())
recorded := writer.recorded()
if len(recorded) != 1 || recorded[0].Capability != agentstore.CapabilityProcesses {
t.Fatalf("expected only the process snapshot, got %+v", recorded)
}
// The reverse case: the store rejects one capability.
writer.failCapability(agentstore.CapabilityProcesses, errors.New("store rejected"))
source.mu.Lock()
source.hostErr = nil
source.mu.Unlock()
instance.collectOnce(context.Background())
if writer.countFor(agentstore.CapabilityHost) != 1 {
t.Fatalf("host must still be published: %+v", writer.recorded())
}
if writer.countFor(agentstore.CapabilityProcesses) != 1 {
t.Fatalf("the rejected capability must not be recorded twice: %+v", writer.recorded())
}
}
func TestFailedCollectionWritesNothingRatherThanAnEmptySnapshot(t *testing.T) {
writer := newFakeWriter()
source := &fakeSource{hostErr: errors.New("boom"), processErr: errors.New("boom")}
instance, _, _ := newTestAgent(t, source, writer, time.Second)
instance.collectOnce(context.Background())
if recorded := writer.recorded(); len(recorded) != 0 {
t.Fatalf("a failed collection must publish nothing, got %+v", recorded)
}
}
func TestOversizedPayloadIsRefused(t *testing.T) {
writer := newFakeWriter()
source := &fakeSource{hugePayload: true}
instance, _, _ := newTestAgent(t, source, writer, time.Second)
if _, err := instance.publish(context.Background(), instance.capabilities[0]); err == nil {
t.Fatal("expected an oversized payload to be refused")
}
if writer.countFor(agentstore.CapabilityHost) != 0 {
t.Fatal("an oversized payload must never reach the store")
}
}
func TestPublishUsesABoundedContext(t *testing.T) {
writer := newFakeWriter()
instance, _, _ := newTestAgent(t, &fakeSource{}, writer, time.Second)
instance.collectOnce(context.Background())
writer.mu.Lock()
defer writer.mu.Unlock()
if len(writer.deadlines) == 0 {
t.Fatal("no writes recorded")
}
for index, bounded := range writer.deadlines {
if !bounded {
t.Fatalf("write %d ran without a deadline", index)
}
}
}
func TestPublishedSnapshotCarriesTheObservedTimeAndPayload(t *testing.T) {
writer := newFakeWriter()
instance, _, _ := newTestAgent(t, &fakeSource{}, writer, time.Second)
instance.collectOnce(context.Background())
for _, snapshot := range writer.recorded() {
if snapshot.AgentID != "pulse-agent-test" {
t.Fatalf("agent id = %q", snapshot.AgentID)
}
if !snapshot.Capability.Valid() {
t.Fatalf("unknown capability %q", snapshot.Capability)
}
if snapshot.ObservedAt.IsZero() || snapshot.ObservedAt.Location() != time.UTC {
t.Fatalf("observed at = %v", snapshot.ObservedAt)
}
var decoded map[string]any
if err := json.Unmarshal(snapshot.Payload, &decoded); err != nil {
t.Fatalf("payload is not a JSON object: %v", err)
}
}
}
func TestRunWritesTheFirstHeartbeatBeforeTheFirstBlockingCall(t *testing.T) {
writer := newFakeWriter()
release := make(chan struct{})
source := &fakeSource{release: release}
instance, clock, heartbeat := newTestAgent(t, source, writer, time.Second)
ticks := make(chan time.Time)
instance.newTicker = func(time.Duration) (<-chan time.Time, func()) { return ticks, func() {} }
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- instance.run(ctx) }()
// The collector is blocked inside the first iteration, yet the heartbeat must
// already exist: a slow cold start is not a hang.
deadline := time.Now().Add(2 * time.Second)
for {
if _, err := os.Stat(heartbeat); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("no heartbeat was written before the first blocking call")
}
time.Sleep(time.Millisecond)
}
if got := readHeartbeat(t, heartbeat); got != clock.now().UTC().Format(time.RFC3339) {
t.Fatalf("heartbeat = %q", got)
}
close(release)
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("run returned error: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("run did not return after cancellation")
}
}
func TestRunHeartbeatsFromTheLoopAndShutsDownCleanly(t *testing.T) {
writer := newFakeWriter()
source := &fakeSource{}
instance, clock, heartbeat := newTestAgent(t, source, writer, time.Second)
clock.setStep(time.Second)
start := clock.now()
ticks := make(chan time.Time)
instance.newTicker = func(time.Duration) (<-chan time.Time, func()) { return ticks, func() {} }
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- instance.run(ctx) }()
// An unbuffered tick is only received once the previous iteration has finished, so
// each successful send proves one more completed loop iteration.
for iteration := 0; iteration < 3; iteration++ {
select {
case ticks <- time.Now():
case <-time.After(5 * time.Second):
t.Fatalf("iteration %d never completed", iteration)
}
}
if hostCalls, procCalls := source.calls(); hostCalls < 3 || procCalls < 3 {
t.Fatalf("collections = host %d, processes %d; want at least 3 each", hostCalls, procCalls)
}
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("run returned error: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("run did not stop on cancellation")
}
// Read the heartbeat only after the loop has stopped: the file is truncated and
// rewritten in place, so a concurrent reader can legitimately observe it empty.
// The healthcheck script reads only the mtime, which is why that is safe.
written, err := time.Parse(time.RFC3339, readHeartbeat(t, heartbeat))
if err != nil {
t.Fatalf("heartbeat content is not an RFC 3339 timestamp: %v", err)
}
if written.Before(start) {
t.Fatalf("heartbeat %s predates the loop start %s", written, start)
}
}
func TestRunDoesNotBlockShutdownOnAStuckWrite(t *testing.T) {
writer := newFakeWriter()
writer.blockUntil = make(chan struct{})
instance, _, _ := newTestAgent(t, &fakeSource{}, writer, time.Second)
ticks := make(chan time.Time)
instance.newTicker = func(time.Duration) (<-chan time.Time, func()) { return ticks, func() {} }
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- instance.run(ctx) }()
time.Sleep(20 * time.Millisecond)
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("run returned error: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("a blocked store write must not hold up shutdown")
}
}
func TestRunRefusesToStartWhenTheHelloIsNotValid(t *testing.T) {
writer := newFakeWriter()
instance, _, _ := newTestAgent(t, &fakeSource{}, writer, time.Second)
instance.agentID = ""
if err := instance.run(context.Background()); err == nil {
t.Fatal("an agent whose own hello fails validation must refuse to start")
}
if len(writer.recorded()) != 0 {
t.Fatal("nothing may be published before the self-check passes")
}
}
func TestRunRequiresAWriter(t *testing.T) {
instance, _, _ := newTestAgent(t, &fakeSource{}, nil, time.Second)
instance.writer = nil
if err := instance.run(context.Background()); !errors.Is(err, errNoWriter) {
t.Fatalf("error = %v, want errNoWriter", err)
}
}
func TestHelloAnnouncesOnlyReadOnlyCapabilities(t *testing.T) {
instance, clock, _ := newTestAgent(t, &fakeSource{}, newFakeWriter(), time.Second)
hello := instance.hello()
if err := hello.Validate(clock.now()); err != nil {
t.Fatalf("hello validation failed: %v", err)
}
if len(hello.Capabilities) != 2 {
t.Fatalf("capabilities = %+v", hello.Capabilities)
}
for _, item := range hello.Capabilities {
if !item.ReadOnly {
t.Fatalf("capability %q is not read-only", item.ID)
}
if !agentstore.Capability(item.ID).Valid() {
t.Fatalf("capability %q is not a recognised store capability", item.ID)
}
}
}
func TestContainerCapabilityIsPublishedOnlyWhenSourceSupportsIt(t *testing.T) {
writer := newFakeWriter()
instance, _, _ := newTestAgent(t, fakeContainerSource{fakeSource: &fakeSource{}}, writer, time.Second)
if len(instance.hello().Capabilities) != 3 {
t.Fatalf("capabilities = %+v, want host/processes/containers", instance.hello().Capabilities)
}
instance.collectOnce(context.Background())
if writer.countFor(agentstore.CapabilityContainers) != 1 {
t.Fatalf("container snapshot was not published: %+v", writer.recorded())
}
}
func TestInventoryCapabilitiesArePublishedOnlyForConfiguredUnraidSource(t *testing.T) {
writer := newFakeWriter()
instance, _, _ := newTestAgent(t, fakeInventorySource{fakeSource: &fakeSource{}}, writer, time.Second)
if got := len(instance.hello().Capabilities); got != 7 {
t.Fatalf("capabilities = %+v, want host/processes/containers/array/disks/pools/shares", instance.hello().Capabilities)
}
instance.collectOnce(context.Background())
for _, capability := range []agentstore.Capability{agentstore.CapabilityContainers, agentstore.CapabilityArray, agentstore.CapabilityDisks, agentstore.CapabilityPools, agentstore.CapabilityShares} {
if writer.countFor(capability) != 1 {
t.Fatalf("capability %q was not published: %+v", capability, writer.recorded())
}
}
}
func TestHeartbeatFailureIsNotFatal(t *testing.T) {
writer := newFakeWriter()
instance, _, _ := newTestAgent(t, &fakeSource{}, writer, time.Second)
instance.heartbeatPath = filepath.Join(t.TempDir(), "missing-directory", "healthy")
instance.iterate(context.Background())
if len(writer.recorded()) == 0 {
t.Fatal("a heartbeat write failure must not stop the collection loop")
}
}
func TestSnapshotWriterRefusesAMissingPool(t *testing.T) {
// A nil pool would let every publish fail deep in the driver. The agent must refuse
// to start instead, so an operator sees the misconfiguration rather than an agent
// that appears healthy while storing nothing.
if _, err := newSnapshotWriter(nil); !errors.Is(err, errStoreNotLinked) {
t.Fatalf("error = %v, want errStoreNotLinked", err)
}
}
+157
View File
@@ -0,0 +1,157 @@
// Command pulse-agent collects read-only host telemetry and publishes it as bounded
// snapshots for pulse-api to read.
//
// It exposes no network port, holds no Docker socket, and performs no mutation: it
// reads procfs/sysfs and writes one row per capability through agentstore.Writer. See
// docs/architecture/SYSTEM_ARCHITECTURE.md ("pulse-agent"), ADR-0005 and
// docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md.
package main
import (
"context"
"errors"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/itworx/pulse/internal/array"
"github.com/itworx/pulse/internal/container"
"github.com/itworx/pulse/internal/disk"
"github.com/itworx/pulse/internal/host"
"github.com/itworx/pulse/internal/hostcollect"
"github.com/itworx/pulse/internal/pool"
"github.com/itworx/pulse/internal/process"
"github.com/itworx/pulse/internal/runtimeconfig"
"github.com/itworx/pulse/internal/share"
"github.com/itworx/pulse/internal/unraid"
)
type configuredSource struct {
host *hostcollect.Collector
}
func (s configuredSource) Host(ctx context.Context) (host.RawSnapshot, error) {
return s.host.Host(ctx)
}
func (s configuredSource) Processes(ctx context.Context) (process.RawSnapshot, error) {
return s.host.Processes(ctx)
}
type configuredUnraidSource struct {
configuredSource
containers interface {
Snapshot(context.Context) (container.RawSnapshot, error)
}
array interface {
Snapshot(context.Context) (array.RawSnapshot, error)
}
disks interface {
Snapshot(context.Context) (disk.RawSnapshot, error)
}
pools interface {
Snapshot(context.Context) (pool.RawSnapshot, error)
}
shares interface {
Snapshot(context.Context) (share.RawSnapshot, error)
}
}
func (s configuredUnraidSource) Containers(ctx context.Context) (container.RawSnapshot, error) {
if s.containers == nil {
return container.RawSnapshot{}, errUnraidSourceNotConfigured
}
return s.containers.Snapshot(ctx)
}
func (s configuredUnraidSource) Array(ctx context.Context) (array.RawSnapshot, error) {
if s.array == nil {
return array.RawSnapshot{}, errUnraidSourceNotConfigured
}
return s.array.Snapshot(ctx)
}
func (s configuredUnraidSource) Disks(ctx context.Context) (disk.RawSnapshot, error) {
if s.disks == nil {
return disk.RawSnapshot{}, errUnraidSourceNotConfigured
}
return s.disks.Snapshot(ctx)
}
func (s configuredUnraidSource) Pools(ctx context.Context) (pool.RawSnapshot, error) {
if s.pools == nil {
return pool.RawSnapshot{}, errUnraidSourceNotConfigured
}
return s.pools.Snapshot(ctx)
}
func (s configuredUnraidSource) Shares(ctx context.Context) (share.RawSnapshot, error) {
if s.shares == nil {
return share.RawSnapshot{}, errUnraidSourceNotConfigured
}
return s.shares.Snapshot(ctx)
}
var errUnraidSourceNotConfigured = errors.New("Unraid source is not configured")
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if err := run(logger); err != nil {
logger.Error("pulse agent failed", "error", err.Error())
os.Exit(1)
}
}
func run(logger *slog.Logger) error {
config, err := runtimeconfig.LoadAgent()
if err != nil {
return err
}
collector, err := hostcollect.New(hostcollect.Options{
ProcRoot: config.ProcRoot,
SysRoot: config.SysRoot,
FilesystemRoot: config.FilesystemRoot,
HostName: config.HostName,
SourceID: "host",
ProcessLimits: process.Limits{MaxRows: config.MaxProcesses},
})
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
writer, closeStore, err := openStore(ctx, config)
if err != nil {
return err
}
defer closeStore()
logger.Info("pulse agent configuration loaded",
"agent_id", config.AgentID,
"proc_root", config.ProcRoot,
"sys_root", config.SysRoot,
"filesystem_root", config.FilesystemRoot,
"collect_interval", config.CollectInterval.String(),
"shutdown_timeout", config.Service.ShutdownAfter.String(),
)
var source snapshotSource = configuredSource{host: collector}
if config.UnraidURL != "" {
var client *unraid.Client
if config.UnraidCAFile == "" {
client, err = unraid.New(config.UnraidURL, config.UnraidAPIToken, nil)
} else {
info, statErr := os.Stat(config.UnraidCAFile)
if statErr != nil || info.Size() <= 0 || info.Size() > 1<<20 {
return errors.New("PULSE_UNRAID_CA_FILE must be a readable certificate no larger than 1 MiB")
}
caPEM, readErr := os.ReadFile(config.UnraidCAFile)
if readErr != nil {
return errors.New("read PULSE_UNRAID_CA_FILE")
}
client, err = unraid.NewWithCAPEM(config.UnraidURL, config.UnraidAPIToken, caPEM)
}
if err != nil {
return err
}
source = configuredUnraidSource{configuredSource: configuredSource{host: collector}, containers: unraid.ContainerSource{Client: client}, array: unraid.ArraySource{Client: client}, disks: unraid.DiskSource{Client: client}, pools: unraid.PoolSource{Client: client}, shares: unraid.ShareSource{Client: client}}
}
return newAgent(config, source, writer, logger).run(ctx)
}
+70
View File
@@ -0,0 +1,70 @@
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/itworx/pulse/internal/agentstore"
"github.com/itworx/pulse/internal/database"
"github.com/itworx/pulse/internal/runtimeconfig"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
// storeConnectTimeout bounds the start-up reachability check. The healthcheck
// contract wants the first heartbeat written after the database is reachable, so
// this must stay far below the 45 second staleness threshold.
storeConnectTimeout = 5 * time.Second
// agentPoolMaxConns keeps the agent's footprint on the shared database small: it
// issues one small write per capability per interval and never reads.
agentPoolMaxConns = 2
)
// errStoreNotLinked is returned by every write while no snapshot store implementation
// is compiled into this binary. It is deliberately an error on the write path rather
// than a silent drop: the reader turns a missing snapshot into Unknown, and the agent
// log states plainly why nothing arrives.
var errStoreNotLinked = errors.New("no agent snapshot store is linked into this build")
// openStore connects to PostgreSQL and returns the narrow Writer the agent publishes
// through, plus a close function.
//
// The agent depends on the agentstore.Writer interface only; the concrete PostgreSQL
// store owns migration 0016 and the agent_snapshots table.
func openStore(ctx context.Context, config runtimeconfig.AgentConfig) (agentstore.Writer, func(), error) {
pool, err := database.NewPool(ctx, database.Config{
URL: config.DatabaseURL,
MaxConns: agentPoolMaxConns,
MinConns: 1,
})
if err != nil {
// Never wrap the URL itself into the error: it carries the database password.
return nil, nil, errors.New("agent database pool could not be created")
}
reachable, cancel := context.WithTimeout(ctx, storeConnectTimeout)
defer cancel()
if err := database.Ping(reachable, pool); err != nil {
pool.Close()
return nil, nil, fmt.Errorf("agent database is unreachable: %w", err)
}
writer, err := newSnapshotWriter(pool)
if err != nil {
pool.Close()
return nil, nil, err
}
return writer, pool.Close, nil
}
// newSnapshotWriter builds the store the agent writes through. A nil pool would make
// every publish fail silently at the driver, so it is refused here instead: the agent
// must either have a real store or fail to start.
func newSnapshotWriter(pool *pgxpool.Pool) (agentstore.Writer, error) {
if pool == nil {
return nil, errStoreNotLinked
}
return agentstore.PostgresStore{Pool: pool}, nil
}
var _ agentstore.Writer = agentstore.PostgresStore{}