From e6a58f58ea1cf5f310286fb9669de8eb43475dfc Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 22 Aug 2026 16:12:35 -0700 Subject: [PATCH] Add a demo host-metrics fixture Pushes synthetic cairnobs.metrics/cairnobs.heartbeat-tagged records straight to ingest so a demo deployment's Hosts page and per-host detail page have CPU/memory/disk to show. Neither benchmark-fixture nor windows-fixture emits those two attributes -- only the real agent does -- so nothing else could populate that page. Superseded by /hack/demo-simulator in the next commit, which covers the same records as part of a whole fleet; kept because it is still the smallest thing that fills the Hosts page on its own. --- hack/demo-hosts-fixture/go.mod | 18 ++++ hack/demo-hosts-fixture/go.sum | 38 +++++++ hack/demo-hosts-fixture/main.go | 177 ++++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 hack/demo-hosts-fixture/go.mod create mode 100644 hack/demo-hosts-fixture/go.sum create mode 100644 hack/demo-hosts-fixture/main.go diff --git a/hack/demo-hosts-fixture/go.mod b/hack/demo-hosts-fixture/go.mod new file mode 100644 index 0000000..f31a56a --- /dev/null +++ b/hack/demo-hosts-fixture/go.mod @@ -0,0 +1,18 @@ +module github.com/cairnobs/cairnobs/hack/demo-hosts-fixture + +go 1.25.0 + +replace github.com/cairnobs/cairnobs/proto => ../../proto + +require ( + github.com/cairnobs/cairnobs/proto v0.0.0-00010101000000-000000000000 + google.golang.org/grpc v1.83.0 +) + +require ( + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.39.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/protobuf v1.36.12 // indirect +) diff --git a/hack/demo-hosts-fixture/go.sum b/hack/demo-hosts-fixture/go.sum new file mode 100644 index 0000000..481b598 --- /dev/null +++ b/hack/demo-hosts-fixture/go.sum @@ -0,0 +1,38 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= +google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/hack/demo-hosts-fixture/main.go b/hack/demo-hosts-fixture/main.go new file mode 100644 index 0000000..0e58a14 --- /dev/null +++ b/hack/demo-hosts-fixture/main.go @@ -0,0 +1,177 @@ +// Command demo-hosts-fixture pushes synthetic cairnobs.metrics/ +// cairnobs.heartbeat-tagged LogRecords directly to ingest's gRPC +// endpoint, one snapshot every -interval across -time-spread per +// synthetic host, so a demo deployment's Hosts page and per-host detail +// page (CPU/mem/disk charts) have something to show. Neither +// /hack/benchmark-fixture nor /hack/windows-fixture emit these two +// attributes -- only the real agent does +// (agent/cairnobs-agent/src/main.rs's send_metrics/send_heartbeat) -- +// so this fills that gap for demo/exploration data specifically, the +// same use case benchmark-fixture's -time-spread flag was already added +// for. +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "flag" + "fmt" + "math/rand" + "os" + "strconv" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + + logsv1 "github.com/cairnobs/cairnobs/proto/sentry/logs/v1" +) + +// Mirrors hack/benchmark-fixture's host/service pools so the same +// hosts show up consistently across both the raw log fixture and this +// one -- a viewer clicking from a host's log lines to its metrics page +// (or vice versa) sees the same host, not a disjoint set. +var ( + hosts = []string{"host-01", "host-02", "host-03", "host-04", "host-05", "host-06", "host-07", "host-08"} + hostServices = map[string]string{ + "host-01": "api", "host-02": "api", "host-03": "web", "host-04": "web", + "host-05": "worker", "host-06": "worker", "host-07": "db", "host-08": "auth", + } + osNames = []string{"Ubuntu 24.04.1 LTS", "Debian GNU/Linux 13 (trixie)", "Ubuntu 22.04.5 LTS"} + kernelVersions = []string{"6.8.0-45-generic", "6.1.0-25-amd64", "5.15.0-118-generic"} +) + +func main() { + addr := flag.String("addr", "localhost:4317", "ingest gRPC address") + caFile := flag.String("ca", "../dev-certs/out/ca.pem", "CA cert path") + certFile := flag.String("cert", "../dev-certs/out/client.pem", "client cert path") + keyFile := flag.String("key", "../dev-certs/out/client-key.pem", "client key path") + timeSpread := flag.Duration("time-spread", 24*time.Hour, "spread metrics snapshots uniformly across [now-spread, now], one per interval per host") + interval := flag.Duration("interval", 5*time.Minute, "spacing between synthetic metrics snapshots per host") + flag.Parse() + + tlsConf, err := loadTLSConfig(*caFile, *certFile, *keyFile) + if err != nil { + fmt.Fprintln(os.Stderr, "loading TLS config:", err) + os.Exit(1) + } + + conn, err := grpc.NewClient(*addr, grpc.WithTransportCredentials(credentials.NewTLS(tlsConf))) + if err != nil { + fmt.Fprintln(os.Stderr, "dialing ingest:", err) + os.Exit(1) + } + defer conn.Close() + + client := logsv1.NewLogIngestClient(conn) + + now := time.Now() + var records []*logsv1.LogRecord + for _, host := range hosts { + service := hostServices[host] + osName := osNames[rand.Intn(len(osNames))] + kernel := kernelVersions[rand.Intn(len(kernelVersions))] + cores := []int{2, 4, 8, 16}[rand.Intn(4)] + memTotal := []int64{4, 8, 16, 32}[rand.Intn(4)] * 1024 * 1024 * 1024 + diskTotal := []int64{50, 100, 250, 500}[rand.Intn(4)] * 1024 * 1024 * 1024 + // A gently drifting baseline per host so the charts show real + // variation instead of flat noise -- CPU/mem wander within a + // plausible band across the snapshot window. + cpuBase := 5 + rand.Float64()*40 + memBase := 0.3 + rand.Float64()*0.4 + diskBase := 0.2 + rand.Float64()*0.5 + uptime := int64(*timeSpread/time.Second) + rand.Int63n(30*86400) + + for t := now.Add(-*timeSpread); t.Before(now); t = t.Add(*interval) { + cpuPercent := clamp(cpuBase+rand.NormFloat64()*8, 0.5, 98) + memUsed := int64(float64(memTotal) * clamp(memBase+rand.NormFloat64()*0.05, 0.05, 0.95)) + diskUsed := int64(float64(diskTotal) * clamp(diskBase+rand.NormFloat64()*0.02, 0.05, 0.9)) + + records = append(records, &logsv1.LogRecord{ + TimestampUnixNano: t.UnixNano(), + Host: host, + Service: service, + Severity: logsv1.Severity_SEVERITY_INFO, + Message: "host metrics", + Attributes: map[string]string{ + "cairnobs.metrics": "true", + "cpu_percent": fmt.Sprintf("%.2f", cpuPercent), + "mem_used_bytes": strconv.FormatInt(memUsed, 10), + "mem_total_bytes": strconv.FormatInt(memTotal, 10), + "disk_used_bytes": strconv.FormatInt(diskUsed, 10), + "disk_total_bytes": strconv.FormatInt(diskTotal, 10), + "cpu_cores": strconv.Itoa(cores), + "os_name": osName, + "kernel_version": kernel, + "arch": "x86_64", + "uptime_seconds": strconv.FormatInt(uptime-int64(now.Sub(t).Seconds()), 10), + "ipv4_addresses": fmt.Sprintf("10.0.0.%d", 10+rand.Intn(240)), + "ipv6_addresses": "", + }, + }) + records = append(records, &logsv1.LogRecord{ + TimestampUnixNano: t.UnixNano(), + Host: host, + Service: service, + Severity: logsv1.Severity_SEVERITY_INFO, + Message: "agent heartbeat", + Attributes: map[string]string{"cairnobs.heartbeat": "true"}, + }) + } + } + + fmt.Printf("pushing %d synthetic host-metrics/heartbeat records for %d hosts...\n", len(records), len(hosts)) + + const batchSize = 500 + for offset := 0; offset < len(records); offset += batchSize { + end := offset + batchSize + if end > len(records) { + end = len(records) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + resp, err := client.PushBatch(ctx, &logsv1.PushBatchRequest{ + BatchId: fmt.Sprintf("demo-hosts-%d", offset), + Records: records[offset:end], + }) + cancel() + if err != nil { + fmt.Fprintf(os.Stderr, "PushBatch at offset %d failed: %v\n", offset, err) + os.Exit(1) + } + fmt.Printf("sent %d/%d (accepted %d)\n", end, len(records), resp.GetAccepted()) + } + + fmt.Println("done") +} + +func clamp(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func loadTLSConfig(caFile, certFile, keyFile string) (*tls.Config, error) { + caPEM, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("reading CA cert %s: %w", caFile, err) + } + caPool := x509.NewCertPool() + if !caPool.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("no valid certificates found in %s", caFile) + } + + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("loading client cert/key: %w", err) + } + + return &tls.Config{ + RootCAs: caPool, + Certificates: []tls.Certificate{cert}, + }, nil +}