c05ccfcb5d
logarchiver replaces the plain Vector archiver leg of the centralized logging stack (argocd-apps #296) with a Go service that archives raw logs from NATS JetStream to S3 as zstd-compressed, OpenPGP-encrypted, indexed objects, plus an operator CLI to search the index and retrieve/decrypt archived logs. It adds the things that outgrew Vector: zstd compression, encryption keyed from Ben's Vault GPG secrets engine, a searchable ClickHouse index, and sink-conditional acks (a batch is acknowledged to JetStream only after the object is durably in S3 AND indexed). Service (`logarchiver run`): - Durable JetStream pull consumer (stream LOGS, durable archiver, subject filter default logs.k8s.vault.>), explicit acks, independent offsets. - Batch per subject by size/count/time -> NDJSON -> zstd -> encrypt -> S3 PUT -> ClickHouse index row -> ack. On any failure the batch is Nak'd and redelivered, so nothing is lost on a sink outage. - Encryption is a wrapped-DEK envelope (container LARC1): the bulk is AES-256-GCM framed under a random data key, and only that 32-byte key is OpenPGP-encrypted to the engine's public key. This is because the Vault GPG engine does whole-payload decrypt only; retrieval round-trips just the tiny wrapped key regardless of object size. Public key fetched from the engine or a mounted file (configurable); key fingerprint recorded per object; periodic pubkey refresh for rotation. - Prometheus metrics, structured slog, graceful drain on shutdown. CLI: - `search` queries the index (subject/host/time) and lists matching objects. - `fetch` downloads, decrypts via the Vault GPG engine, unzstds and emits NDJSON (optionally re-filtered by host/time). - `init-schema` creates/prints the ClickHouse archive_index DDL. - cobra `completion` subcommands. Config via file+env (k8s-friendly, secrets from env), boundaries (NATS/S3/ ClickHouse/Vault) behind interfaces with unit tests (config, batching, host/subject extraction, crypto roundtrip with a test key, ack-after-persist with fakes, search query building). go build/vet/test -race clean; golangci-lint v2 clean. Woodpecker CI: build/test/pre-commit on PR; on v* tag a container image plus a Gitea binary release + rpm-internal RPM. Docs per subcommand + architecture + retrieval runbook + deployment drop-in. Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
178 lines
4.3 KiB
Go
178 lines
4.3 KiB
Go
// Package batcher groups incoming log events into per-subject batches and
|
|
// decides when a batch is ready to become one archived object. It is
|
|
// deliberately not concurrent: the consumer run loop owns a Batcher and drives
|
|
// it from a single goroutine (Add on receive, DueByAge on a ticker, Drain on
|
|
// shutdown), which keeps the ack-after-persist accounting simple and race-free.
|
|
package batcher
|
|
|
|
import (
|
|
"bytes"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// Item is one log event routed into a batch. Ack is an opaque token (a
|
|
// jetstream.Msg in production) that the caller acknowledges only after the batch
|
|
// has been durably persisted.
|
|
type Item struct {
|
|
Subject string
|
|
Raw []byte
|
|
Host string
|
|
Timestamp time.Time
|
|
HasTS bool
|
|
Ack any
|
|
}
|
|
|
|
// Limits bound a single batch/object.
|
|
type Limits struct {
|
|
MaxBytes int64
|
|
MaxEvents int
|
|
MaxAge time.Duration
|
|
}
|
|
|
|
// Batch is a ready (or in-progress) group of events for one subject.
|
|
type Batch struct {
|
|
Subject string
|
|
Items []Item
|
|
RawBytes int64
|
|
OpenedAt time.Time
|
|
}
|
|
|
|
// Batcher accumulates open batches keyed by subject.
|
|
type Batcher struct {
|
|
limits Limits
|
|
open map[string]*Batch
|
|
nowFn func() time.Time
|
|
}
|
|
|
|
// New returns a Batcher enforcing limits.
|
|
func New(limits Limits) *Batcher {
|
|
return &Batcher{limits: limits, open: map[string]*Batch{}, nowFn: time.Now}
|
|
}
|
|
|
|
// Add appends it to its subject's open batch. If that batch is now full (by
|
|
// bytes or event count), it is removed from the open set and returned so the
|
|
// caller can flush it; otherwise Add returns nil.
|
|
func (b *Batcher) Add(it Item) *Batch {
|
|
batch := b.open[it.Subject]
|
|
if batch == nil {
|
|
batch = &Batch{Subject: it.Subject, OpenedAt: b.nowFn()}
|
|
b.open[it.Subject] = batch
|
|
}
|
|
batch.Items = append(batch.Items, it)
|
|
batch.RawBytes += int64(len(it.Raw))
|
|
|
|
if b.full(batch) {
|
|
delete(b.open, it.Subject)
|
|
return batch
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *Batcher) full(batch *Batch) bool {
|
|
if b.limits.MaxBytes > 0 && batch.RawBytes >= b.limits.MaxBytes {
|
|
return true
|
|
}
|
|
if b.limits.MaxEvents > 0 && len(batch.Items) >= b.limits.MaxEvents {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// DueByAge removes and returns every open batch older than MaxAge as of now.
|
|
func (b *Batcher) DueByAge(now time.Time) []*Batch {
|
|
if b.limits.MaxAge <= 0 {
|
|
return nil
|
|
}
|
|
var due []*Batch
|
|
for subj, batch := range b.open {
|
|
if now.Sub(batch.OpenedAt) >= b.limits.MaxAge {
|
|
due = append(due, batch)
|
|
delete(b.open, subj)
|
|
}
|
|
}
|
|
sortBatches(due)
|
|
return due
|
|
}
|
|
|
|
// Drain removes and returns all open batches (used on graceful shutdown so
|
|
// in-flight events are persisted and acked before exit).
|
|
func (b *Batcher) Drain() []*Batch {
|
|
var all []*Batch
|
|
for subj, batch := range b.open {
|
|
all = append(all, batch)
|
|
delete(b.open, subj)
|
|
}
|
|
sortBatches(all)
|
|
return all
|
|
}
|
|
|
|
// Pending reports how many events sit in open batches.
|
|
func (b *Batcher) Pending() int {
|
|
n := 0
|
|
for _, batch := range b.open {
|
|
n += len(batch.Items)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func sortBatches(bs []*Batch) {
|
|
sort.Slice(bs, func(i, j int) bool { return bs[i].Subject < bs[j].Subject })
|
|
}
|
|
|
|
// NDJSON renders the batch as newline-delimited JSON (one raw event per line),
|
|
// matching the raw archive format the logging stack expects.
|
|
func (b *Batch) NDJSON() []byte {
|
|
var buf bytes.Buffer
|
|
buf.Grow(int(b.RawBytes) + len(b.Items))
|
|
for _, it := range b.Items {
|
|
buf.Write(bytes.TrimRight(it.Raw, "\n"))
|
|
buf.WriteByte('\n')
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// Summary is the index-relevant projection of a batch.
|
|
type Summary struct {
|
|
Hosts []string
|
|
MinTS time.Time
|
|
MaxTS time.Time
|
|
EventCount int
|
|
HasTS bool
|
|
}
|
|
|
|
// Summarize computes hosts (unique, sorted) and the timestamp range. fallback is
|
|
// used for events whose payload lacked a parseable timestamp (ingest time).
|
|
func (b *Batch) Summarize(fallback time.Time) Summary {
|
|
s := Summary{EventCount: len(b.Items)}
|
|
hostSet := map[string]struct{}{}
|
|
for _, it := range b.Items {
|
|
if it.Host != "" {
|
|
hostSet[it.Host] = struct{}{}
|
|
}
|
|
ts := it.Timestamp
|
|
if !it.HasTS {
|
|
ts = fallback
|
|
} else {
|
|
s.HasTS = true
|
|
}
|
|
if s.MinTS.IsZero() || ts.Before(s.MinTS) {
|
|
s.MinTS = ts
|
|
}
|
|
if s.MaxTS.IsZero() || ts.After(s.MaxTS) {
|
|
s.MaxTS = ts
|
|
}
|
|
}
|
|
if s.MinTS.IsZero() {
|
|
s.MinTS = fallback
|
|
}
|
|
if s.MaxTS.IsZero() {
|
|
s.MaxTS = fallback
|
|
}
|
|
for h := range hostSet {
|
|
s.Hosts = append(s.Hosts, h)
|
|
}
|
|
sort.Strings(s.Hosts)
|
|
return s
|
|
}
|