Initial implementation: NATS->S3 archiver + search/retrieve CLI
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
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
// Package archiver turns a ready batch into a stored, indexed object: build
|
||||
// NDJSON, seal it into a LARC1 container, PUT it to S3, then write the index
|
||||
// row. Store returns an error if ANY step fails; the caller must not ack the
|
||||
// batch's messages until Store succeeds (at-least-once, sink-conditional acks).
|
||||
package archiver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/logarchiver/internal/batcher"
|
||||
"git.unkin.net/unkin/logarchiver/internal/crypto"
|
||||
"git.unkin.net/unkin/logarchiver/internal/index"
|
||||
"git.unkin.net/unkin/logarchiver/internal/s3store"
|
||||
)
|
||||
|
||||
// Metrics is the optional metrics sink (implemented by internal/metrics). A nil
|
||||
// Metrics is fine (no-op).
|
||||
type Metrics interface {
|
||||
ObjectStored(subject string, events int, rawBytes, storedBytes int64)
|
||||
StoreFailed(subject string)
|
||||
IndexFailed(subject string)
|
||||
}
|
||||
|
||||
// Archiver persists batches.
|
||||
type Archiver struct {
|
||||
keys *KeyBuilder
|
||||
pubkeys *PubkeyProvider
|
||||
store s3store.ObjectStore
|
||||
idx index.Index // may be nil when indexing is disabled
|
||||
keyName string
|
||||
frameSize int
|
||||
metrics Metrics
|
||||
nowFn func() time.Time
|
||||
}
|
||||
|
||||
// Options configures an Archiver.
|
||||
type Options struct {
|
||||
Keys *KeyBuilder
|
||||
Pubkeys *PubkeyProvider
|
||||
Store s3store.ObjectStore
|
||||
Index index.Index
|
||||
KeyName string
|
||||
FrameSize int
|
||||
Metrics Metrics
|
||||
}
|
||||
|
||||
// New builds an Archiver.
|
||||
func New(o Options) (*Archiver, error) {
|
||||
if o.Keys == nil || o.Pubkeys == nil || o.Store == nil {
|
||||
return nil, fmt.Errorf("archiver requires keys, pubkeys and store")
|
||||
}
|
||||
fs := o.FrameSize
|
||||
if fs <= 0 {
|
||||
fs = 1 << 20
|
||||
}
|
||||
return &Archiver{
|
||||
keys: o.Keys,
|
||||
pubkeys: o.Pubkeys,
|
||||
store: o.Store,
|
||||
idx: o.Index,
|
||||
keyName: o.KeyName,
|
||||
frameSize: fs,
|
||||
metrics: o.Metrics,
|
||||
nowFn: time.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StoreResult reports what Store persisted.
|
||||
type StoreResult struct {
|
||||
ObjectKey string
|
||||
Events int
|
||||
RawBytes int64
|
||||
StoredBytes int64
|
||||
}
|
||||
|
||||
// Store seals, uploads and indexes a batch. On success the caller may ack.
|
||||
func (a *Archiver) Store(ctx context.Context, batch *batcher.Batch) (StoreResult, error) {
|
||||
if len(batch.Items) == 0 {
|
||||
return StoreResult{}, nil
|
||||
}
|
||||
now := a.nowFn().UTC()
|
||||
summary := batch.Summarize(now)
|
||||
|
||||
pub := a.pubkeys.Current()
|
||||
if pub == nil {
|
||||
a.metricStoreFailed(batch.Subject)
|
||||
return StoreResult{}, fmt.Errorf("no public key available")
|
||||
}
|
||||
|
||||
// Choose the object key from the batch's max timestamp so it lands in the
|
||||
// date partition of the newest event.
|
||||
key, err := a.keys.Build(batch.Subject, summary.MaxTS)
|
||||
if err != nil {
|
||||
a.metricStoreFailed(batch.Subject)
|
||||
return StoreResult{}, err
|
||||
}
|
||||
|
||||
ndjson := batch.NDJSON()
|
||||
var buf bytes.Buffer
|
||||
sealed, err := crypto.Seal(&buf, ndjson, pub, a.keyName, a.frameSize)
|
||||
if err != nil {
|
||||
a.metricStoreFailed(batch.Subject)
|
||||
return StoreResult{}, fmt.Errorf("seal object %s: %w", key, err)
|
||||
}
|
||||
|
||||
if err := a.store.Put(ctx, key, bytes.NewReader(buf.Bytes()), int64(buf.Len())); err != nil {
|
||||
a.metricStoreFailed(batch.Subject)
|
||||
return StoreResult{}, err
|
||||
}
|
||||
|
||||
if a.idx != nil {
|
||||
row := index.Row{
|
||||
ObjectKey: key,
|
||||
Bucket: a.store.Bucket(),
|
||||
Subject: batch.Subject,
|
||||
Hosts: summary.Hosts,
|
||||
MinTS: summary.MinTS,
|
||||
MaxTS: summary.MaxTS,
|
||||
EventCount: uint64(summary.EventCount),
|
||||
RawBytes: uint64(sealed.RawBytes),
|
||||
StoredBytes: uint64(sealed.StoredBytes),
|
||||
Compression: sealed.Header.Compression,
|
||||
Cipher: sealed.Header.Cipher,
|
||||
ContainerFormat: "LARC1",
|
||||
KeyName: a.keyName,
|
||||
KeyFingerprint: sealed.Header.KeyFingerprint,
|
||||
}
|
||||
if err := a.idx.Insert(ctx, row); err != nil {
|
||||
// The object is in S3 but unindexed. Do NOT ack: on redelivery the
|
||||
// batch is re-stored (a new object key) and re-indexed. The orphan
|
||||
// object is harmless (retrievable by prefix) and reaped by lifecycle.
|
||||
a.metricIndexFailed(batch.Subject)
|
||||
return StoreResult{}, fmt.Errorf("index object %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
if a.metrics != nil {
|
||||
a.metrics.ObjectStored(batch.Subject, summary.EventCount, sealed.RawBytes, sealed.StoredBytes)
|
||||
}
|
||||
return StoreResult{
|
||||
ObjectKey: key,
|
||||
Events: summary.EventCount,
|
||||
RawBytes: sealed.RawBytes,
|
||||
StoredBytes: sealed.StoredBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Archiver) metricStoreFailed(subject string) {
|
||||
if a.metrics != nil {
|
||||
a.metrics.StoreFailed(subject)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Archiver) metricIndexFailed(subject string) {
|
||||
if a.metrics != nil {
|
||||
a.metrics.IndexFailed(subject)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package archiver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/logarchiver/internal/batcher"
|
||||
"git.unkin.net/unkin/logarchiver/internal/crypto"
|
||||
"git.unkin.net/unkin/logarchiver/internal/index"
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
)
|
||||
|
||||
// --- fakes ---
|
||||
|
||||
type fakeStore struct {
|
||||
bucket string
|
||||
objects map[string][]byte
|
||||
failPut bool
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{bucket: "test-bucket", objects: map[string][]byte{}}
|
||||
}
|
||||
|
||||
func (f *fakeStore) Put(_ context.Context, key string, body io.Reader, _ int64) error {
|
||||
if f.failPut {
|
||||
return errors.New("simulated s3 failure")
|
||||
}
|
||||
data, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.objects[key] = data
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) Get(_ context.Context, key string) (io.ReadCloser, error) {
|
||||
data, ok := f.objects[key]
|
||||
if !ok {
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(data)), nil
|
||||
}
|
||||
func (f *fakeStore) List(_ context.Context, _ string) ([]string, error) { return nil, nil }
|
||||
func (f *fakeStore) Bucket() string { return f.bucket }
|
||||
|
||||
type fakeIndex struct {
|
||||
rows []index.Row
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (f *fakeIndex) Insert(_ context.Context, row index.Row) error {
|
||||
if f.fail {
|
||||
return errors.New("simulated index failure")
|
||||
}
|
||||
f.rows = append(f.rows, row)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeIndex) Search(context.Context, index.SearchQuery) ([]index.Result, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeIndex) InitSchema(context.Context) error { return nil }
|
||||
func (f *fakeIndex) Ping(context.Context) error { return nil }
|
||||
func (f *fakeIndex) Close() error { return nil }
|
||||
|
||||
func testPubkey(t *testing.T) *crypto.PublicKey {
|
||||
t.Helper()
|
||||
ent, err := openpgp.NewEntity("t", "", "t@unkin.net", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEntity: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
w, _ := armor.Encode(&buf, openpgp.PublicKeyType, nil)
|
||||
_ = ent.Serialize(w)
|
||||
_ = w.Close()
|
||||
pk, err := crypto.LoadPublicKey(buf.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPublicKey: %v", err)
|
||||
}
|
||||
return pk
|
||||
}
|
||||
|
||||
func newTestArchiver(t *testing.T, store *fakeStore, idx index.Index) *Archiver {
|
||||
t.Helper()
|
||||
pk := testPubkey(t)
|
||||
kb, _ := NewKeyBuilder("archive/{{.Subject}}/{{.Year}}/{{.Month}}/{{.Day}}/")
|
||||
prov, err := NewPubkeyProvider(context.Background(), func(context.Context) (*crypto.PublicKey, error) {
|
||||
return pk, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("provider: %v", err)
|
||||
}
|
||||
a, err := New(Options{
|
||||
Keys: kb,
|
||||
Pubkeys: prov,
|
||||
Store: store,
|
||||
Index: idx,
|
||||
KeyName: "logarchive",
|
||||
FrameSize: 4096,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func sampleBatch() *batcher.Batch {
|
||||
ts := time.Date(2026, 7, 27, 1, 0, 0, 0, time.UTC)
|
||||
return &batcher.Batch{
|
||||
Subject: "logs.k8s.vault.audit",
|
||||
Items: []batcher.Item{
|
||||
{Subject: "logs.k8s.vault.audit", Host: "node-1", Timestamp: ts, HasTS: true, Raw: []byte(`{"host":"node-1","message":"a"}`)},
|
||||
{Subject: "logs.k8s.vault.audit", Host: "node-2", Timestamp: ts.Add(time.Hour), HasTS: true, Raw: []byte(`{"host":"node-2","message":"b"}`)},
|
||||
},
|
||||
RawBytes: 62,
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreSuccessWritesObjectAndIndex(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
idx := &fakeIndex{}
|
||||
a := newTestArchiver(t, store, idx)
|
||||
|
||||
res, err := a.Store(context.Background(), sampleBatch())
|
||||
if err != nil {
|
||||
t.Fatalf("Store: %v", err)
|
||||
}
|
||||
if res.Events != 2 {
|
||||
t.Errorf("events = %d", res.Events)
|
||||
}
|
||||
if len(store.objects) != 1 {
|
||||
t.Fatalf("expected 1 stored object, got %d", len(store.objects))
|
||||
}
|
||||
// Stored bytes must be a valid LARC1 container.
|
||||
obj := store.objects[res.ObjectKey]
|
||||
if _, _, err := crypto.ReadHeader(bytes.NewReader(obj)); err != nil {
|
||||
t.Errorf("stored object is not a valid container: %v", err)
|
||||
}
|
||||
if len(idx.rows) != 1 {
|
||||
t.Fatalf("expected 1 index row, got %d", len(idx.rows))
|
||||
}
|
||||
row := idx.rows[0]
|
||||
if row.Subject != "logs.k8s.vault.audit" {
|
||||
t.Errorf("row subject = %q", row.Subject)
|
||||
}
|
||||
if row.EventCount != 2 {
|
||||
t.Errorf("row event_count = %d", row.EventCount)
|
||||
}
|
||||
if len(row.Hosts) != 2 || row.Hosts[0] != "node-1" || row.Hosts[1] != "node-2" {
|
||||
t.Errorf("row hosts = %v", row.Hosts)
|
||||
}
|
||||
if row.Bucket != "test-bucket" {
|
||||
t.Errorf("row bucket = %q", row.Bucket)
|
||||
}
|
||||
if row.ContainerFormat != "LARC1" || row.Compression != "zstd" {
|
||||
t.Errorf("row metadata wrong: %+v", row)
|
||||
}
|
||||
if row.KeyFingerprint == "" {
|
||||
t.Errorf("row missing key fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
// The central at-least-once property: if S3 fails, Store errors and NOTHING is
|
||||
// written to the index, so the caller will not ack.
|
||||
func TestStoreS3FailureNoIndexNoAck(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
store.failPut = true
|
||||
idx := &fakeIndex{}
|
||||
a := newTestArchiver(t, store, idx)
|
||||
|
||||
if _, err := a.Store(context.Background(), sampleBatch()); err == nil {
|
||||
t.Fatalf("expected error when S3 put fails")
|
||||
}
|
||||
if len(idx.rows) != 0 {
|
||||
t.Errorf("index written despite S3 failure: %d rows", len(idx.rows))
|
||||
}
|
||||
if len(store.objects) != 0 {
|
||||
t.Errorf("object recorded despite S3 failure")
|
||||
}
|
||||
}
|
||||
|
||||
// If indexing fails after the S3 put, Store still errors (so no ack); the object
|
||||
// exists but is unindexed — acceptable, it will be re-stored on redelivery.
|
||||
func TestStoreIndexFailureErrors(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
idx := &fakeIndex{fail: true}
|
||||
a := newTestArchiver(t, store, idx)
|
||||
|
||||
if _, err := a.Store(context.Background(), sampleBatch()); err == nil {
|
||||
t.Fatalf("expected error when index insert fails")
|
||||
}
|
||||
if len(store.objects) != 1 {
|
||||
t.Errorf("object should still be in S3 (orphan), got %d", len(store.objects))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreNilIndexOK(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
a := newTestArchiver(t, store, nil)
|
||||
if _, err := a.Store(context.Background(), sampleBatch()); err != nil {
|
||||
t.Fatalf("Store with nil index: %v", err)
|
||||
}
|
||||
if len(store.objects) != 1 {
|
||||
t.Errorf("expected object stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreEmptyBatchNoop(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
a := newTestArchiver(t, store, &fakeIndex{})
|
||||
res, err := a.Store(context.Background(), &batcher.Batch{Subject: "s"})
|
||||
if err != nil {
|
||||
t.Fatalf("empty batch: %v", err)
|
||||
}
|
||||
if res.ObjectKey != "" || len(store.objects) != 0 {
|
||||
t.Errorf("empty batch should store nothing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package archiver
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/logarchiver/internal/event"
|
||||
)
|
||||
|
||||
// ObjectExt is the suffix for logarchiver container objects (zstd + framed
|
||||
// AES-GCM + wrapped OpenPGP DEK). It is deliberately NOT .gz/.pgp because the
|
||||
// object is a logarchiver-specific container, not a bare gpg file.
|
||||
const ObjectExt = ".ndjson.zst.larc"
|
||||
|
||||
// KeyBuilder renders S3 object keys from a prefix template. Template fields:
|
||||
// {{.Subject}} (sanitized), {{.Year}} {{.Month}} {{.Day}} (UTC, zero-padded).
|
||||
type KeyBuilder struct {
|
||||
tmpl *template.Template
|
||||
}
|
||||
|
||||
// NewKeyBuilder compiles the prefix template.
|
||||
func NewKeyBuilder(prefixTemplate string) (*KeyBuilder, error) {
|
||||
t, err := template.New("key").Option("missingkey=error").Parse(prefixTemplate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse key_prefix template: %w", err)
|
||||
}
|
||||
return &KeyBuilder{tmpl: t}, nil
|
||||
}
|
||||
|
||||
type keyData struct {
|
||||
Subject string
|
||||
Year string
|
||||
Month string
|
||||
Day string
|
||||
}
|
||||
|
||||
// Build returns a unique object key for a batch of subject at ts. The filename
|
||||
// is <UTCstamp>-<random>.ndjson.zst.larc so keys are collision-free and sortable.
|
||||
func (k *KeyBuilder) Build(subject string, ts time.Time) (string, error) {
|
||||
ts = ts.UTC()
|
||||
var sb strings.Builder
|
||||
err := k.tmpl.Execute(&sb, keyData{
|
||||
Subject: event.SubjectToken(subject),
|
||||
Year: fmt.Sprintf("%04d", ts.Year()),
|
||||
Month: fmt.Sprintf("%02d", int(ts.Month())),
|
||||
Day: fmt.Sprintf("%02d", ts.Day()),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("render key prefix: %w", err)
|
||||
}
|
||||
prefix := sb.String()
|
||||
if prefix != "" && !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
suffix, err := randHex(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := ts.Format("20060102T150405Z") + "-" + suffix + ObjectExt
|
||||
return prefix + name, nil
|
||||
}
|
||||
|
||||
func randHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("random: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package archiver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestKeyBuilder(t *testing.T) {
|
||||
kb, err := NewKeyBuilder("archive/{{.Subject}}/{{.Year}}/{{.Month}}/{{.Day}}/")
|
||||
if err != nil {
|
||||
t.Fatalf("NewKeyBuilder: %v", err)
|
||||
}
|
||||
ts := time.Date(2026, 7, 5, 10, 15, 0, 0, time.UTC)
|
||||
key, err := kb.Build("logs.k8s.vault.audit", ts)
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(key, "archive/logs.k8s.vault.audit/2026/07/05/") {
|
||||
t.Errorf("unexpected prefix: %s", key)
|
||||
}
|
||||
if !strings.HasSuffix(key, ObjectExt) {
|
||||
t.Errorf("missing container suffix: %s", key)
|
||||
}
|
||||
if !strings.Contains(key, "20260705T101500Z-") {
|
||||
t.Errorf("missing timestamp token: %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBuilderUnique(t *testing.T) {
|
||||
kb, _ := NewKeyBuilder("p/{{.Subject}}/")
|
||||
ts := time.Date(2026, 7, 5, 10, 15, 0, 0, time.UTC)
|
||||
k1, _ := kb.Build("s", ts)
|
||||
k2, _ := kb.Build("s", ts)
|
||||
if k1 == k2 {
|
||||
t.Errorf("keys should be unique: %s", k1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBuilderSanitizesSubject(t *testing.T) {
|
||||
kb, _ := NewKeyBuilder("p/{{.Subject}}/")
|
||||
key, _ := kb.Build("logs.k8s.a/b", time.Now())
|
||||
if strings.Contains(key, "a/b") {
|
||||
t.Errorf("subject slash not sanitized: %s", key)
|
||||
}
|
||||
if !strings.Contains(key, "a_b") {
|
||||
t.Errorf("expected sanitized a_b: %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBuilderBadTemplate(t *testing.T) {
|
||||
if _, err := NewKeyBuilder("{{.Nope"); err == nil {
|
||||
t.Errorf("expected template parse error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package archiver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"git.unkin.net/unkin/logarchiver/internal/config"
|
||||
"git.unkin.net/unkin/logarchiver/internal/crypto"
|
||||
"git.unkin.net/unkin/logarchiver/internal/vaultgpg"
|
||||
)
|
||||
|
||||
// PubkeyLoader fetches the current armored public key from the configured source.
|
||||
type PubkeyLoader func(ctx context.Context) (*crypto.PublicKey, error)
|
||||
|
||||
// PubkeyProvider caches the current public key and supports periodic refresh so
|
||||
// key rotation in the Vault GPG engine is picked up without a restart.
|
||||
type PubkeyProvider struct {
|
||||
load PubkeyLoader
|
||||
mu sync.RWMutex
|
||||
key *crypto.PublicKey
|
||||
}
|
||||
|
||||
// NewPubkeyProvider builds a provider and loads the key once.
|
||||
func NewPubkeyProvider(ctx context.Context, load PubkeyLoader) (*PubkeyProvider, error) {
|
||||
p := &PubkeyProvider{load: load}
|
||||
if err := p.Refresh(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Refresh reloads the public key.
|
||||
func (p *PubkeyProvider) Refresh(ctx context.Context) error {
|
||||
key, err := p.load(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.key = key
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Current returns the cached public key.
|
||||
func (p *PubkeyProvider) Current() *crypto.PublicKey {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.key
|
||||
}
|
||||
|
||||
// PubkeyLoaderFromConfig builds a loader for the configured source. For
|
||||
// pubkey_source=vault it also verifies the parsed key's fingerprint matches the
|
||||
// fingerprint the engine reports, catching armor corruption.
|
||||
func PubkeyLoaderFromConfig(cfg config.CryptoConfig, vc *vaultgpg.Client) (PubkeyLoader, error) {
|
||||
switch cfg.Source {
|
||||
case config.PubkeyFile:
|
||||
path := cfg.PubkeyFile
|
||||
return func(_ context.Context) (*crypto.PublicKey, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pubkey file %s: %w", path, err)
|
||||
}
|
||||
return crypto.LoadPublicKey(data)
|
||||
}, nil
|
||||
case config.PubkeyVault:
|
||||
if vc == nil {
|
||||
return nil, fmt.Errorf("pubkey_source=vault requires a vault client")
|
||||
}
|
||||
name := cfg.KeyName
|
||||
return func(ctx context.Context) (*crypto.PublicKey, error) {
|
||||
pk, err := vc.FetchPublicKey(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := crypto.LoadPublicKey([]byte(pk.Armored))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pk.Fingerprint != "" && key.Fingerprint != pk.Fingerprint {
|
||||
return nil, fmt.Errorf("pubkey fingerprint mismatch: engine=%s parsed=%s", pk.Fingerprint, key.Fingerprint)
|
||||
}
|
||||
return key, nil
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown pubkey_source %q", cfg.Source)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package batcher
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func item(subject, host string, ts time.Time, hasTS bool, raw string) Item {
|
||||
return Item{Subject: subject, Host: host, Timestamp: ts, HasTS: hasTS, Raw: []byte(raw)}
|
||||
}
|
||||
|
||||
func TestFullByEvents(t *testing.T) {
|
||||
b := New(Limits{MaxEvents: 3})
|
||||
if got := b.Add(item("s", "h", time.Now(), true, "a")); got != nil {
|
||||
t.Fatalf("should not be full at 1")
|
||||
}
|
||||
if got := b.Add(item("s", "h", time.Now(), true, "b")); got != nil {
|
||||
t.Fatalf("should not be full at 2")
|
||||
}
|
||||
full := b.Add(item("s", "h", time.Now(), true, "c"))
|
||||
if full == nil {
|
||||
t.Fatalf("should be full at 3")
|
||||
}
|
||||
if len(full.Items) != 3 {
|
||||
t.Errorf("full batch has %d items", len(full.Items))
|
||||
}
|
||||
// After a full flush the subject batch is reset.
|
||||
if b.Pending() != 0 {
|
||||
t.Errorf("pending after flush = %d, want 0", b.Pending())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullByBytes(t *testing.T) {
|
||||
b := New(Limits{MaxBytes: 10})
|
||||
if b.Add(item("s", "h", time.Now(), true, "12345")) != nil {
|
||||
t.Fatalf("5 bytes should not fill")
|
||||
}
|
||||
full := b.Add(item("s", "h", time.Now(), true, "67890"))
|
||||
if full == nil {
|
||||
t.Fatalf("10 bytes should fill")
|
||||
}
|
||||
if full.RawBytes != 10 {
|
||||
t.Errorf("RawBytes = %d", full.RawBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeparateSubjects(t *testing.T) {
|
||||
b := New(Limits{MaxEvents: 2})
|
||||
b.Add(item("a", "h", time.Now(), true, "x"))
|
||||
b.Add(item("b", "h", time.Now(), true, "y"))
|
||||
if b.Pending() != 2 {
|
||||
t.Errorf("pending = %d, want 2 across subjects", b.Pending())
|
||||
}
|
||||
full := b.Add(item("a", "h", time.Now(), true, "z"))
|
||||
if full == nil || full.Subject != "a" {
|
||||
t.Fatalf("subject a should flush independently")
|
||||
}
|
||||
if b.Pending() != 1 {
|
||||
t.Errorf("pending after a flush = %d, want 1 (subject b)", b.Pending())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueByAge(t *testing.T) {
|
||||
base := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)
|
||||
b := New(Limits{MaxAge: time.Minute})
|
||||
b.nowFn = func() time.Time { return base }
|
||||
b.Add(item("s", "h", base, true, "x"))
|
||||
|
||||
if due := b.DueByAge(base.Add(30 * time.Second)); len(due) != 0 {
|
||||
t.Fatalf("not due at 30s")
|
||||
}
|
||||
due := b.DueByAge(base.Add(90 * time.Second))
|
||||
if len(due) != 1 {
|
||||
t.Fatalf("should be due at 90s, got %d", len(due))
|
||||
}
|
||||
if b.Pending() != 0 {
|
||||
t.Errorf("due batch not removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrain(t *testing.T) {
|
||||
b := New(Limits{MaxEvents: 100})
|
||||
b.Add(item("a", "h", time.Now(), true, "x"))
|
||||
b.Add(item("b", "h", time.Now(), true, "y"))
|
||||
all := b.Drain()
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("drain returned %d, want 2", len(all))
|
||||
}
|
||||
if b.Pending() != 0 {
|
||||
t.Errorf("pending after drain = %d", b.Pending())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNDJSON(t *testing.T) {
|
||||
b := &Batch{Subject: "s"}
|
||||
b.Items = []Item{
|
||||
{Raw: []byte(`{"a":1}`)},
|
||||
{Raw: []byte(`{"b":2}` + "\n")}, // trailing newline trimmed and re-added
|
||||
}
|
||||
got := string(b.NDJSON())
|
||||
want := "{\"a\":1}\n{\"b\":2}\n"
|
||||
if got != want {
|
||||
t.Errorf("NDJSON = %q, want %q", got, want)
|
||||
}
|
||||
if strings.Count(got, "\n") != 2 {
|
||||
t.Errorf("expected exactly 2 newlines")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarize(t *testing.T) {
|
||||
t1 := time.Date(2026, 7, 27, 1, 0, 0, 0, time.UTC)
|
||||
t2 := time.Date(2026, 7, 27, 3, 0, 0, 0, time.UTC)
|
||||
fallback := time.Date(2026, 7, 27, 9, 0, 0, 0, time.UTC)
|
||||
b := &Batch{Subject: "s"}
|
||||
b.Items = []Item{
|
||||
item("s", "host-b", t2, true, "x"),
|
||||
item("s", "host-a", t1, true, "y"),
|
||||
item("s", "", time.Time{}, false, "z"), // no ts -> fallback, no host
|
||||
item("s", "host-a", t1, true, "w"), // dup host
|
||||
}
|
||||
s := b.Summarize(fallback)
|
||||
if s.EventCount != 4 {
|
||||
t.Errorf("EventCount = %d", s.EventCount)
|
||||
}
|
||||
if len(s.Hosts) != 2 || s.Hosts[0] != "host-a" || s.Hosts[1] != "host-b" {
|
||||
t.Errorf("Hosts = %v, want sorted unique [host-a host-b]", s.Hosts)
|
||||
}
|
||||
if !s.MinTS.Equal(t1) {
|
||||
t.Errorf("MinTS = %v, want %v", s.MinTS, t1)
|
||||
}
|
||||
// max should be the fallback (9:00) since event z used fallback which is latest
|
||||
if !s.MaxTS.Equal(fallback) {
|
||||
t.Errorf("MaxTS = %v, want fallback %v", s.MaxTS, fallback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeAllFallback(t *testing.T) {
|
||||
fallback := time.Date(2026, 7, 27, 9, 0, 0, 0, time.UTC)
|
||||
b := &Batch{Items: []Item{{Raw: []byte("x")}}}
|
||||
s := b.Summarize(fallback)
|
||||
if !s.MinTS.Equal(fallback) || !s.MaxTS.Equal(fallback) {
|
||||
t.Errorf("all-fallback range wrong: %v..%v", s.MinTS, s.MaxTS)
|
||||
}
|
||||
if s.HasTS {
|
||||
t.Errorf("HasTS should be false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/logarchiver/internal/index"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// selectFlags are the shared object-selection flags for search and fetch.
|
||||
type selectFlags struct {
|
||||
subject string
|
||||
host string
|
||||
from string
|
||||
to string
|
||||
limit int
|
||||
}
|
||||
|
||||
func (s *selectFlags) bind(cmd *cobra.Command) {
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&s.subject, "subject", "", "NATS-style subject glob (e.g. 'logs.vm.*' or 'logs.k8s.vault.>')")
|
||||
f.StringVar(&s.host, "host", "", "source host to match (exact, or a glob with '*')")
|
||||
f.StringVar(&s.from, "from", "", "start of time window (RFC3339, 'YYYY-MM-DD', or relative like '-24h')")
|
||||
f.StringVar(&s.to, "to", "", "end of time window (RFC3339, 'YYYY-MM-DD', or relative like '-1h')")
|
||||
f.IntVar(&s.limit, "limit", 100, "max objects to return (0 = no limit)")
|
||||
}
|
||||
|
||||
// query builds an index.SearchQuery from the flags.
|
||||
func (s *selectFlags) query(now time.Time) (index.SearchQuery, error) {
|
||||
q := index.SearchQuery{Subject: s.subject, Host: s.host, Limit: s.limit}
|
||||
if s.from != "" {
|
||||
t, err := parseTimeArg(s.from, now)
|
||||
if err != nil {
|
||||
return q, fmt.Errorf("--from: %w", err)
|
||||
}
|
||||
q.From = t
|
||||
}
|
||||
if s.to != "" {
|
||||
t, err := parseTimeArg(s.to, now)
|
||||
if err != nil {
|
||||
return q, fmt.Errorf("--to: %w", err)
|
||||
}
|
||||
q.To = t
|
||||
}
|
||||
if !q.From.IsZero() && !q.To.IsZero() && q.To.Before(q.From) {
|
||||
return q, fmt.Errorf("--to (%s) is before --from (%s)", q.To, q.From)
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// parseTimeArg accepts RFC3339[/Nano], "YYYY-MM-DD", "YYYY-MM-DDTHH:MM:SS", or a
|
||||
// signed Go duration relative to now (e.g. "-24h", "30m").
|
||||
func parseTimeArg(s string, now time.Time) (time.Time, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05", "2006-01-02"} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
}
|
||||
if d, err := time.ParseDuration(s); err == nil {
|
||||
return now.Add(d).UTC(), nil
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("unrecognized time %q (use RFC3339, YYYY-MM-DD, or a duration like -24h)", s)
|
||||
}
|
||||
|
||||
func newIndexClient(cmd *cobra.Command) (index.Index, error) {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !cfg.Index.Enabled {
|
||||
return nil, fmt.Errorf("index is disabled in config; search/fetch-by-query require the ClickHouse index")
|
||||
}
|
||||
return index.NewClickHouse(cmd.Context(), indexConfig(cfg.Index))
|
||||
}
|
||||
|
||||
func humanBytes(n uint64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%dB", n)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for x := n / unit; x >= unit; x /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f%ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/logarchiver/internal/crypto"
|
||||
"git.unkin.net/unkin/logarchiver/internal/s3store"
|
||||
"git.unkin.net/unkin/logarchiver/internal/vaultgpg"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newFetchCmd() *cobra.Command {
|
||||
var sel selectFlags
|
||||
var output string
|
||||
cmd := &cobra.Command{
|
||||
Use: "fetch [object-key ...]",
|
||||
Short: "Download, decrypt and decompress archived objects to NDJSON",
|
||||
Long: `fetch retrieves archived objects, decrypts them via the Vault GPG engine
|
||||
(the engine decrypts only the tiny wrapped data key; the bulk is streamed and
|
||||
decrypted locally), decompresses the zstd bulk, and emits the original NDJSON.
|
||||
|
||||
Objects are selected either by object key arguments, or by the same
|
||||
--subject/--host/--from/--to query used by 'search'. When --host/--from/--to are
|
||||
given they ALSO re-filter the emitted events to just the matching lines.`,
|
||||
Example: ` logarchiver search --subject 'logs.k8s.vault.>' --from -1h
|
||||
logarchiver fetch archive/logs.k8s.vault._/2026/07/27/20260727T101500Z-ab12cd34.ndjson.zst.larc -o -
|
||||
logarchiver fetch --subject 'logs.vm.*' --host db-1 --from -24h -o ./out`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
q, err := sel.query(now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Resolve object keys: explicit args, else via index search.
|
||||
keys := args
|
||||
if len(keys) == 0 {
|
||||
if !cfg.Index.Enabled {
|
||||
return fmt.Errorf("no object keys given and index is disabled")
|
||||
}
|
||||
idx, err := newIndexClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = idx.Close() }()
|
||||
results, err := idx.Search(cmd.Context(), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range results {
|
||||
keys = append(keys, r.ObjectKey)
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "no matching objects")
|
||||
return nil
|
||||
}
|
||||
|
||||
store, err := s3store.New(cmd.Context(), s3store.Config{
|
||||
Endpoint: cfg.S3.Endpoint,
|
||||
Bucket: cfg.S3.Bucket,
|
||||
Region: cfg.S3.Region,
|
||||
PathStyle: cfg.S3.PathStyle,
|
||||
CAFile: cfg.S3.CAFile,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("s3 init: %w", err)
|
||||
}
|
||||
// Operator decrypt path: force token auth (ambient VAULT_TOKEN /
|
||||
// ~/.vault-token), like passv, regardless of the service auth_method.
|
||||
vcfg := vaultConfig(cfg.Crypto.Vault)
|
||||
vcfg.AuthMethod = "token"
|
||||
vc, err := vaultgpg.New(cmd.Context(), vcfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("vault init: %w", err)
|
||||
}
|
||||
|
||||
filter := newLineFilter(sel.host, q.From, q.To)
|
||||
|
||||
var failures int
|
||||
for _, key := range keys {
|
||||
if err := fetchOne(cmd.Context(), store, vc, key, output, filter); err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "fetch %s: %v\n", key, err)
|
||||
failures++
|
||||
}
|
||||
}
|
||||
if failures > 0 {
|
||||
return fmt.Errorf("%d of %d objects failed", failures, len(keys))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
sel.bind(cmd)
|
||||
cmd.Flags().StringVarP(&output, "output", "o", "-",
|
||||
"output: '-' for stdout, or a directory to write one NDJSON file per object")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// fetchOne downloads, decrypts and decompresses a single object, applying the
|
||||
// optional line filter, to stdout or a per-object file under a directory.
|
||||
func fetchOne(ctx context.Context, store s3store.ObjectStore, vc *vaultgpg.Client, key, output string, filter lineFilter) error {
|
||||
body, err := store.Get(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = body.Close() }()
|
||||
|
||||
// Buffer the (bounded) object so we can read the header for its key name
|
||||
// before decrypting.
|
||||
data, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read object: %w", err)
|
||||
}
|
||||
hdr, _, err := crypto.ReadHeader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keyName := hdr.KeyName
|
||||
if keyName == "" {
|
||||
return fmt.Errorf("object header has no key_name")
|
||||
}
|
||||
unwrap := func(wrapped []byte) ([]byte, error) {
|
||||
return vc.Decrypt(ctx, keyName, wrapped)
|
||||
}
|
||||
|
||||
var dst io.Writer
|
||||
var closer io.Closer
|
||||
if output == "-" || output == "" {
|
||||
dst = os.Stdout
|
||||
} else {
|
||||
if err := os.MkdirAll(output, 0o755); err != nil {
|
||||
return fmt.Errorf("create output dir: %w", err)
|
||||
}
|
||||
outPath := filepath.Join(output, sanitizeKey(key))
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create output subdir: %w", err)
|
||||
}
|
||||
f, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create output file: %w", err)
|
||||
}
|
||||
dst = f
|
||||
closer = f
|
||||
}
|
||||
|
||||
fw := newFilterWriter(dst, filter)
|
||||
if err := crypto.Open(bytes.NewReader(data), fw, unwrap); err != nil {
|
||||
if closer != nil {
|
||||
_ = closer.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := fw.Flush(); err != nil {
|
||||
if closer != nil {
|
||||
_ = closer.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
if closer != nil {
|
||||
return closer.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeKey turns an object key into a safe relative output filename, dropping
|
||||
// the .larc container suffix in favor of a plain .ndjson.
|
||||
func sanitizeKey(key string) string {
|
||||
name := strings.TrimSuffix(key, ".larc")
|
||||
if !strings.HasSuffix(name, ".ndjson") && !strings.HasSuffix(name, ".ndjson.zst") {
|
||||
name += ".ndjson"
|
||||
}
|
||||
name = strings.TrimSuffix(name, ".zst")
|
||||
return filepath.Clean("/" + name)[1:]
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/logarchiver/internal/event"
|
||||
)
|
||||
|
||||
// lineFilter is a predicate over a single NDJSON event line.
|
||||
type lineFilter func(raw []byte) bool
|
||||
|
||||
// newLineFilter builds a predicate from optional host/time constraints. A nil
|
||||
// filter (all constraints empty) means "pass everything".
|
||||
func newLineFilter(host string, from, to time.Time) lineFilter {
|
||||
if host == "" && from.IsZero() && to.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return func(raw []byte) bool {
|
||||
meta := event.Extract(raw)
|
||||
if host != "" && !globMatch(host, meta.Host) {
|
||||
return false
|
||||
}
|
||||
if !from.IsZero() || !to.IsZero() {
|
||||
// Events without a parseable timestamp are kept (we cannot exclude
|
||||
// them on time grounds without dropping data).
|
||||
if meta.Ok {
|
||||
if !from.IsZero() && meta.Timestamp.Before(from) {
|
||||
return false
|
||||
}
|
||||
if !to.IsZero() && meta.Timestamp.After(to) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// filterWriter forwards only complete NDJSON lines that satisfy filter. It
|
||||
// buffers a trailing partial line across Writes so streaming decryption can feed
|
||||
// it arbitrary chunks. Flush must be called at end to emit any final unterminated
|
||||
// line. A nil filter forwards bytes verbatim.
|
||||
type filterWriter struct {
|
||||
dst io.Writer
|
||||
filter lineFilter
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func newFilterWriter(dst io.Writer, filter lineFilter) *filterWriter {
|
||||
return &filterWriter{dst: dst, filter: filter}
|
||||
}
|
||||
|
||||
func (w *filterWriter) Write(p []byte) (int, error) {
|
||||
if w.filter == nil {
|
||||
return w.dst.Write(p)
|
||||
}
|
||||
w.buf.Write(p)
|
||||
for {
|
||||
data := w.buf.Bytes()
|
||||
i := bytes.IndexByte(data, '\n')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
line := data[:i]
|
||||
if len(bytes.TrimSpace(line)) > 0 && w.filter(line) {
|
||||
if _, err := w.dst.Write(line); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := w.dst.Write([]byte{'\n'}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
w.buf.Next(i + 1)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Flush emits a trailing line that had no terminating newline.
|
||||
func (w *filterWriter) Flush() error {
|
||||
if w.filter == nil {
|
||||
return nil
|
||||
}
|
||||
line := bytes.TrimRight(w.buf.Bytes(), "\n")
|
||||
w.buf.Reset()
|
||||
if len(bytes.TrimSpace(line)) > 0 && w.filter(line) {
|
||||
if _, err := w.dst.Write(line); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.dst.Write([]byte{'\n'}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// globMatch matches pattern against s where '*' matches any run of characters.
|
||||
// With no '*', it is an exact match.
|
||||
func globMatch(pattern, s string) bool {
|
||||
if !strings.Contains(pattern, "*") {
|
||||
return pattern == s
|
||||
}
|
||||
parts := strings.Split(pattern, "*")
|
||||
// Anchor first part.
|
||||
if !strings.HasPrefix(s, parts[0]) {
|
||||
return false
|
||||
}
|
||||
s = s[len(parts[0]):]
|
||||
for _, part := range parts[1 : len(parts)-1] {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
idx := strings.Index(s, part)
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
s = s[idx+len(part):]
|
||||
}
|
||||
// Anchor last part.
|
||||
return strings.HasSuffix(s, parts[len(parts)-1])
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGlobMatch(t *testing.T) {
|
||||
cases := []struct {
|
||||
pattern, s string
|
||||
want bool
|
||||
}{
|
||||
{"node-1", "node-1", true},
|
||||
{"node-1", "node-2", false},
|
||||
{"db-*", "db-1", true},
|
||||
{"db-*", "web-1", false},
|
||||
{"*-1", "node-1", true},
|
||||
{"*vault*", "logs-vault-audit", true},
|
||||
{"*vault*", "logs-web", false},
|
||||
{"a*b*c", "axxbyyc", true},
|
||||
{"a*b*c", "axxc", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := globMatch(c.pattern, c.s); got != c.want {
|
||||
t.Errorf("globMatch(%q,%q) = %v, want %v", c.pattern, c.s, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterWriterPassAll(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
fw := newFilterWriter(&out, nil) // nil filter = passthrough
|
||||
_, _ = fw.Write([]byte("line1\nline2\n"))
|
||||
_ = fw.Flush()
|
||||
if out.String() != "line1\nline2\n" {
|
||||
t.Errorf("passthrough altered data: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterWriterHostFilterAcrossChunks(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
filter := newLineFilter("node-1", time.Time{}, time.Time{})
|
||||
fw := newFilterWriter(&out, filter)
|
||||
// Feed a line split across two Writes to exercise buffering.
|
||||
_, _ = fw.Write([]byte(`{"host":"node-1","m":"keep"}` + "\n" + `{"host":"node`))
|
||||
_, _ = fw.Write([]byte(`-2","m":"drop"}` + "\n" + `{"host":"node-1","m":"keep2"}` + "\n"))
|
||||
_ = fw.Flush()
|
||||
|
||||
got := out.String()
|
||||
if want := `{"host":"node-1","m":"keep"}` + "\n" + `{"host":"node-1","m":"keep2"}` + "\n"; got != want {
|
||||
t.Errorf("filtered output = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterWriterTimeWindow(t *testing.T) {
|
||||
from := time.Date(2026, 7, 27, 1, 0, 0, 0, time.UTC)
|
||||
to := time.Date(2026, 7, 27, 2, 0, 0, 0, time.UTC)
|
||||
var out bytes.Buffer
|
||||
fw := newFilterWriter(&out, newLineFilter("", from, to))
|
||||
lines := `{"host":"h","timestamp":"2026-07-27T00:30:00Z","m":"before"}` + "\n" +
|
||||
`{"host":"h","timestamp":"2026-07-27T01:30:00Z","m":"in"}` + "\n" +
|
||||
`{"host":"h","timestamp":"2026-07-27T03:00:00Z","m":"after"}` + "\n" +
|
||||
`{"host":"h","m":"no-ts-kept"}` + "\n"
|
||||
_, _ = fw.Write([]byte(lines))
|
||||
_ = fw.Flush()
|
||||
|
||||
got := out.String()
|
||||
if !bytes.Contains(out.Bytes(), []byte(`"in"`)) {
|
||||
t.Errorf("in-window line dropped: %q", got)
|
||||
}
|
||||
if bytes.Contains(out.Bytes(), []byte(`"before"`)) || bytes.Contains(out.Bytes(), []byte(`"after"`)) {
|
||||
t.Errorf("out-of-window line kept: %q", got)
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte(`"no-ts-kept"`)) {
|
||||
t.Errorf("event without timestamp should be kept: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTimeArg(t *testing.T) {
|
||||
now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)
|
||||
rfc, err := parseTimeArg("2026-07-27T01:00:00Z", now)
|
||||
if err != nil || !rfc.Equal(time.Date(2026, 7, 27, 1, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("RFC3339 parse: %v %v", rfc, err)
|
||||
}
|
||||
d, err := parseTimeArg("2026-07-27", now)
|
||||
if err != nil || !d.Equal(time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("date parse: %v %v", d, err)
|
||||
}
|
||||
rel, err := parseTimeArg("-24h", now)
|
||||
if err != nil || !rel.Equal(now.Add(-24*time.Hour)) {
|
||||
t.Errorf("relative parse: %v %v", rel, err)
|
||||
}
|
||||
if _, err := parseTimeArg("nonsense", now); err == nil {
|
||||
t.Errorf("expected error for nonsense time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectFlagsQueryOrdering(t *testing.T) {
|
||||
now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)
|
||||
s := &selectFlags{from: "-1h", to: "-2h"} // to before from
|
||||
if _, err := s.query(now); err == nil {
|
||||
t.Errorf("expected error when --to before --from")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeKey(t *testing.T) {
|
||||
got := sanitizeKey("archive/logs.vm.db-1/2026/07/27/20260727T101500Z-abcd.ndjson.zst.larc")
|
||||
if got != "archive/logs.vm.db-1/2026/07/27/20260727T101500Z-abcd.ndjson" {
|
||||
t.Errorf("sanitizeKey = %q", got)
|
||||
}
|
||||
// Path traversal is neutralized.
|
||||
if bad := sanitizeKey("../../etc/passwd"); bad != "etc/passwd.ndjson" {
|
||||
t.Errorf("sanitizeKey traversal = %q", bad)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.unkin.net/unkin/logarchiver/internal/index"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newInitSchemaCmd() *cobra.Command {
|
||||
var printOnly bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "init-schema",
|
||||
Short: "Create the ClickHouse archive-index database and table (idempotent)",
|
||||
Long: `init-schema creates the ClickHouse database and archive_index table.
|
||||
|
||||
In-cluster the argocd bootstrap Job owns schema creation (like the logging
|
||||
stack's clickhouse-schema PostSync hook); this command is for local/dev use and
|
||||
for emitting the DDL (--print) to embed in that Job.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if printOnly {
|
||||
out := cmd.OutOrStdout()
|
||||
_, _ = fmt.Fprintln(out, index.CreateDatabaseSQL(cfg.Index.Database)+";")
|
||||
_, _ = fmt.Fprintln(out, index.CreateTableSQL(cfg.Index.Database, cfg.Index.Table)+";")
|
||||
return nil
|
||||
}
|
||||
ch, err := index.NewClickHouse(cmd.Context(), indexConfig(cfg.Index))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = ch.Close() }()
|
||||
if err := ch.InitSchema(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "schema ready: %s.%s\n", cfg.Index.Database, cfg.Index.Table)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&printOnly, "print", false, "print the DDL instead of executing it")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package cli implements the logarchiver command tree (service + operator CLI)
|
||||
// using cobra, which also provides the `completion` subcommand the estate's
|
||||
// nfpm packaging installs.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.unkin.net/unkin/logarchiver/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// version is set at build time via -ldflags "-X ...cli.version=...".
|
||||
var version = "dev"
|
||||
|
||||
// SetVersion lets main inject the linker-provided version string.
|
||||
func SetVersion(v string) {
|
||||
if v != "" {
|
||||
version = v
|
||||
}
|
||||
}
|
||||
|
||||
var configPath string
|
||||
|
||||
// NewRootCmd builds the root command.
|
||||
func NewRootCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "logarchiver",
|
||||
Short: "Archive NATS JetStream logs to S3 (zstd + OpenPGP) and search/retrieve them",
|
||||
Long: `logarchiver archives raw logs from the centralized logging JetStream stream
|
||||
to S3 as zstd-compressed, OpenPGP-encrypted, indexed objects, and provides a
|
||||
CLI to search the index and retrieve/decrypt archived logs.`,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
root.PersistentFlags().StringVarP(&configPath, "config", "c", os.Getenv("LOGARCHIVER_CONFIG"),
|
||||
"path to config file (env LOGARCHIVER_CONFIG)")
|
||||
|
||||
root.AddCommand(
|
||||
newRunCmd(),
|
||||
newSearchCmd(),
|
||||
newFetchCmd(),
|
||||
newInitSchemaCmd(),
|
||||
newVersionCmd(),
|
||||
)
|
||||
return root
|
||||
}
|
||||
|
||||
// Execute runs the root command.
|
||||
func Execute() int {
|
||||
if err := NewRootCmd().Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func newVersionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the version",
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), version)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// loadConfig loads config from the --config path (or defaults+env).
|
||||
func loadConfig() (config.Config, error) {
|
||||
return config.Load(configPath)
|
||||
}
|
||||
|
||||
// newLogger builds a slog logger from config.
|
||||
func newLogger(cfg config.LogConfig) *slog.Logger {
|
||||
level := slog.LevelInfo
|
||||
switch strings.ToLower(cfg.Level) {
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
case "warn":
|
||||
level = slog.LevelWarn
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
}
|
||||
opts := &slog.HandlerOptions{Level: level}
|
||||
var h slog.Handler
|
||||
if strings.ToLower(cfg.Format) == "text" {
|
||||
h = slog.NewTextHandler(os.Stderr, opts)
|
||||
} else {
|
||||
h = slog.NewJSONHandler(os.Stderr, opts)
|
||||
}
|
||||
return slog.New(h)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/logarchiver/internal/archiver"
|
||||
"git.unkin.net/unkin/logarchiver/internal/batcher"
|
||||
"git.unkin.net/unkin/logarchiver/internal/config"
|
||||
"git.unkin.net/unkin/logarchiver/internal/consumer"
|
||||
"git.unkin.net/unkin/logarchiver/internal/index"
|
||||
"git.unkin.net/unkin/logarchiver/internal/metrics"
|
||||
"git.unkin.net/unkin/logarchiver/internal/s3store"
|
||||
"git.unkin.net/unkin/logarchiver/internal/vaultgpg"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newRunCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "run",
|
||||
Short: "Run the archiver service (JetStream consumer -> S3 + index)",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runService(cmd.Context(), cfg)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// persistAdapter bridges *archiver.Archiver to consumer.Persister (different
|
||||
// StoreResult types across package boundaries).
|
||||
type persistAdapter struct{ a *archiver.Archiver }
|
||||
|
||||
func (p persistAdapter) Store(ctx context.Context, b *batcher.Batch) (consumer.StoreResult, error) {
|
||||
res, err := p.a.Store(ctx, b)
|
||||
return consumer.StoreResult{
|
||||
ObjectKey: res.ObjectKey,
|
||||
Events: res.Events,
|
||||
RawBytes: res.RawBytes,
|
||||
StoredBytes: res.StoredBytes,
|
||||
}, err
|
||||
}
|
||||
|
||||
func runService(parent context.Context, cfg config.Config) error {
|
||||
log := newLogger(cfg.Log)
|
||||
slog.SetDefault(log)
|
||||
|
||||
ctx, stop := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Metrics.
|
||||
var met *metrics.Metrics
|
||||
var metricsSrv *http.Server
|
||||
if cfg.Metrics.Enabled {
|
||||
reg := prometheus.NewRegistry()
|
||||
met = metrics.New(reg)
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
metricsSrv = &http.Server{Addr: cfg.Metrics.Address, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
go func() {
|
||||
log.Info("metrics listening", "addr", cfg.Metrics.Address)
|
||||
if err := metricsSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Error("metrics server failed", "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Vault client only when we source the public key from Vault; the service
|
||||
// never needs Vault otherwise (file-mounted pubkey is the default).
|
||||
var vc *vaultgpg.Client
|
||||
if cfg.Crypto.Source == config.PubkeyVault {
|
||||
var err error
|
||||
vc, err = vaultgpg.New(ctx, vaultConfig(cfg.Crypto.Vault))
|
||||
if err != nil {
|
||||
return fmt.Errorf("vault init: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Public key provider.
|
||||
loader, err := archiver.PubkeyLoaderFromConfig(cfg.Crypto, vc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pubkeys, err := archiver.NewPubkeyProvider(ctx, loader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load public key: %w", err)
|
||||
}
|
||||
log.Info("public key loaded",
|
||||
"source", cfg.Crypto.Source, "key_name", cfg.Crypto.KeyName,
|
||||
"fingerprint", pubkeys.Current().Fingerprint)
|
||||
go refreshPubkey(ctx, log, pubkeys, cfg.Crypto.RefreshInterval)
|
||||
|
||||
// S3.
|
||||
store, err := s3store.New(ctx, s3store.Config{
|
||||
Endpoint: cfg.S3.Endpoint,
|
||||
Bucket: cfg.S3.Bucket,
|
||||
Region: cfg.S3.Region,
|
||||
PathStyle: cfg.S3.PathStyle,
|
||||
CAFile: cfg.S3.CAFile,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("s3 init: %w", err)
|
||||
}
|
||||
|
||||
// Index.
|
||||
var idx index.Index
|
||||
if cfg.Index.Enabled {
|
||||
ch, err := index.NewClickHouse(ctx, indexConfig(cfg.Index))
|
||||
if err != nil {
|
||||
return fmt.Errorf("clickhouse init: %w", err)
|
||||
}
|
||||
defer func() { _ = ch.Close() }()
|
||||
idx = ch
|
||||
}
|
||||
|
||||
keys, err := archiver.NewKeyBuilder(cfg.S3.KeyPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
arch, err := archiver.New(archiver.Options{
|
||||
Keys: keys,
|
||||
Pubkeys: pubkeys,
|
||||
Store: store,
|
||||
Index: idx,
|
||||
KeyName: cfg.Crypto.KeyName,
|
||||
FrameSize: cfg.Crypto.FrameSize,
|
||||
Metrics: met,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// NATS + consumer.
|
||||
nc, js, err := consumer.Connect(cfg.NATS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer nc.Close()
|
||||
cons, err := consumer.EnsureConsumer(ctx, js, cfg.NATS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info("consumer bound",
|
||||
"stream", cfg.NATS.Stream, "durable", cfg.NATS.Durable, "subjects", cfg.NATS.Subjects)
|
||||
|
||||
bat := batcher.New(batcher.Limits{
|
||||
MaxBytes: cfg.Batch.MaxBytes,
|
||||
MaxEvents: cfg.Batch.MaxEvents,
|
||||
MaxAge: cfg.Batch.MaxAge,
|
||||
})
|
||||
|
||||
var runnerMetrics consumer.Metrics
|
||||
if met != nil {
|
||||
runnerMetrics = met
|
||||
}
|
||||
runner := consumer.NewRunner(consumer.Options{
|
||||
Consumer: cons,
|
||||
Batcher: bat,
|
||||
Persister: persistAdapter{a: arch},
|
||||
Logger: log,
|
||||
Metrics: runnerMetrics,
|
||||
FetchBatch: cfg.NATS.FetchBatch,
|
||||
PollWait: pollWait(cfg.Batch.MaxAge),
|
||||
})
|
||||
|
||||
runErr := runner.Run(ctx)
|
||||
|
||||
if metricsSrv != nil {
|
||||
shCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
_ = metricsSrv.Shutdown(shCtx)
|
||||
cancel()
|
||||
}
|
||||
if runErr != nil && !errors.Is(runErr, context.Canceled) {
|
||||
return runErr
|
||||
}
|
||||
log.Info("shutdown complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
// pollWait picks a fetch/age-check interval that is a fraction of MaxAge so
|
||||
// aged batches flush promptly, clamped to a sane range.
|
||||
func pollWait(maxAge time.Duration) time.Duration {
|
||||
if maxAge <= 0 {
|
||||
return time.Second
|
||||
}
|
||||
w := maxAge / 10
|
||||
if w < time.Second {
|
||||
w = time.Second
|
||||
}
|
||||
if w > 10*time.Second {
|
||||
w = 10 * time.Second
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func refreshPubkey(ctx context.Context, log *slog.Logger, p *archiver.PubkeyProvider, every time.Duration) {
|
||||
if every <= 0 {
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := p.Refresh(ctx); err != nil {
|
||||
log.Warn("pubkey refresh failed; keeping previous key", "err", err)
|
||||
continue
|
||||
}
|
||||
log.Debug("pubkey refreshed", "fingerprint", p.Current().Fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func vaultConfig(v config.VaultConfig) vaultgpg.Config {
|
||||
return vaultgpg.Config{
|
||||
Address: v.Address,
|
||||
Mount: v.Mount,
|
||||
AuthMethod: v.AuthMethod,
|
||||
K8sRole: v.K8sRole,
|
||||
K8sMount: v.K8sMount,
|
||||
K8sJWTPath: v.K8sJWTPath,
|
||||
CAFile: v.CAFile,
|
||||
}
|
||||
}
|
||||
|
||||
func indexConfig(i config.IndexConfig) index.Config {
|
||||
return index.Config{
|
||||
Address: i.Address,
|
||||
Database: i.Database,
|
||||
Table: i.Table,
|
||||
Username: i.Username,
|
||||
Password: i.Password,
|
||||
TLS: i.TLS,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newSearchCmd() *cobra.Command {
|
||||
var sel selectFlags
|
||||
var asJSON bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "Search the archive index for matching objects",
|
||||
Long: `search queries the ClickHouse archive index and lists the S3 objects whose
|
||||
subject/host/time-range match, with event counts and sizes. Use the object keys
|
||||
with 'logarchiver fetch' to retrieve and decrypt their contents.`,
|
||||
Example: ` logarchiver search --subject 'logs.k8s.vault.>' --host node-1 --from -24h`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
q, err := sel.query(time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
idx, err := newIndexClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = idx.Close() }()
|
||||
|
||||
results, err := idx.Search(cmd.Context(), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
if asJSON {
|
||||
enc := json.NewEncoder(out)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(results)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
_, _ = fmt.Fprintln(out, "no matching objects")
|
||||
return nil
|
||||
}
|
||||
tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0)
|
||||
_, _ = fmt.Fprintln(tw, "OBJECT_KEY\tSUBJECT\tHOSTS\tMIN_TS\tMAX_TS\tEVENTS\tSTORED")
|
||||
var totalEvents, totalStored uint64
|
||||
for _, r := range results {
|
||||
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%d\t%s\n",
|
||||
r.ObjectKey, r.Subject, strings.Join(r.Hosts, ","),
|
||||
r.MinTS.UTC().Format(time.RFC3339), r.MaxTS.UTC().Format(time.RFC3339),
|
||||
r.EventCount, humanBytes(r.StoredBytes))
|
||||
totalEvents += r.EventCount
|
||||
totalStored += r.StoredBytes
|
||||
}
|
||||
_ = tw.Flush()
|
||||
_, _ = fmt.Fprintf(out, "\n%d objects, %d events, %s stored\n", len(results), totalEvents, humanBytes(totalStored))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
sel.bind(cmd)
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "output results as JSON")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// Package config defines logarchiver's configuration and loads it from a YAML
|
||||
// file with environment-variable overrides, so the same binary is
|
||||
// k8s-friendly (env/secret-driven) and laptop-friendly (a config file).
|
||||
//
|
||||
// Precedence: built-in defaults < YAML file < environment variables.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config is the full logarchiver configuration.
|
||||
type Config struct {
|
||||
NATS NATSConfig `yaml:"nats"`
|
||||
Batch BatchConfig `yaml:"batch"`
|
||||
S3 S3Config `yaml:"s3"`
|
||||
Crypto CryptoConfig `yaml:"crypto"`
|
||||
Index IndexConfig `yaml:"index"`
|
||||
Metrics MetricsConfig `yaml:"metrics"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
}
|
||||
|
||||
// NATSConfig configures the JetStream pull consumer that logarchiver binds. It
|
||||
// mirrors the logging stack's `LOGS` stream / `archiver` durable / `log-consumer`
|
||||
// user conventions (argocd-apps #296).
|
||||
type NATSConfig struct {
|
||||
URL string `yaml:"url"`
|
||||
Stream string `yaml:"stream"`
|
||||
Durable string `yaml:"durable"`
|
||||
Subjects []string `yaml:"subjects"`
|
||||
User string `yaml:"user"`
|
||||
// Password is the NATS user password. In-cluster it comes from the
|
||||
// nats-auth secret via NATS_CONSUMER_PASSWORD (see PasswordEnv).
|
||||
Password string `yaml:"password"`
|
||||
// PasswordEnv names the env var holding the password when Password is empty.
|
||||
PasswordEnv string `yaml:"password_env"`
|
||||
// CAFile trusts a custom CA for TLS to NATS (usually unset; in-cluster is plaintext).
|
||||
CAFile string `yaml:"ca_file"`
|
||||
// FetchBatch is the max messages pulled per Fetch call.
|
||||
FetchBatch int `yaml:"fetch_batch"`
|
||||
// AckWait is the JetStream redelivery timeout; must exceed a worst-case
|
||||
// batch flush (compress+encrypt+S3 PUT+index write).
|
||||
AckWait time.Duration `yaml:"ack_wait"`
|
||||
}
|
||||
|
||||
// BatchConfig bounds a single archived object. A per-subject batch is flushed
|
||||
// when any bound is hit. Keep MaxBytes well under the crypto/engine ceiling so
|
||||
// even a whole-object decrypt path stays viable; the wrapped-DEK envelope means
|
||||
// object size is not limited by Vault, but smaller objects retrieve faster.
|
||||
type BatchConfig struct {
|
||||
MaxBytes int64 `yaml:"max_bytes"`
|
||||
MaxEvents int `yaml:"max_events"`
|
||||
MaxAge time.Duration `yaml:"max_age"`
|
||||
}
|
||||
|
||||
// S3Config targets the Ceph RGW bucket. Credentials are read from the standard
|
||||
// AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env vars (cephrgw BucketAccess
|
||||
// secret logs-archive-s3), so they are intentionally absent here.
|
||||
type S3Config struct {
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
Bucket string `yaml:"bucket"`
|
||||
Region string `yaml:"region"`
|
||||
PathStyle bool `yaml:"path_style"`
|
||||
// KeyPrefix is a text/template with {{.Subject}} {{.Year}} {{.Month}} {{.Day}}.
|
||||
KeyPrefix string `yaml:"key_prefix"`
|
||||
// CAFile trusts the internal Vault-PKI CA for https://s3.ceph.unkin.net.
|
||||
CAFile string `yaml:"ca_file"`
|
||||
// EndpointEnv / BucketEnv let the cephrgw secret (S3_ENDPOINT / BUCKET_NAME)
|
||||
// override endpoint/bucket without a config edit.
|
||||
EndpointEnv string `yaml:"endpoint_env"`
|
||||
BucketEnv string `yaml:"bucket_env"`
|
||||
}
|
||||
|
||||
// PubkeySource selects where the OpenPGP public key is fetched from.
|
||||
type PubkeySource string
|
||||
|
||||
const (
|
||||
PubkeyVault PubkeySource = "vault" // read gpg/keys/<name> from the Vault GPG engine
|
||||
PubkeyFile PubkeySource = "file" // read an armored public key from a mounted file
|
||||
)
|
||||
|
||||
// CryptoConfig controls encryption. The service only ever needs the PUBLIC key;
|
||||
// decryption (CLI fetch) always goes through the Vault GPG engine.
|
||||
type CryptoConfig struct {
|
||||
KeyName string `yaml:"key_name"`
|
||||
Source PubkeySource `yaml:"pubkey_source"`
|
||||
// PubkeyFile is the armored public key path when Source==file.
|
||||
PubkeyFile string `yaml:"pubkey_file"`
|
||||
// RefreshInterval re-fetches the public key periodically (rotation aware).
|
||||
RefreshInterval time.Duration `yaml:"refresh_interval"`
|
||||
// FrameSize is the AES-GCM frame plaintext size in bytes (streaming decrypt).
|
||||
FrameSize int `yaml:"frame_size"`
|
||||
Vault VaultConfig `yaml:"vault"`
|
||||
}
|
||||
|
||||
// VaultConfig configures access to the Vault GPG secrets engine. For the
|
||||
// service (pubkey fetch) k8s auth is used in-cluster; the CLI relies on the
|
||||
// operator's ambient VAULT_TOKEN (~/.vault-token), like passv.
|
||||
type VaultConfig struct {
|
||||
Address string `yaml:"address"`
|
||||
// Mount is the GPG engine mount path (e.g. "gpg").
|
||||
Mount string `yaml:"mount"`
|
||||
// AuthMethod is "token" or "kubernetes".
|
||||
AuthMethod string `yaml:"auth_method"`
|
||||
// K8sRole / K8sMount / K8sJWTPath configure kubernetes auth.
|
||||
K8sRole string `yaml:"k8s_role"`
|
||||
K8sMount string `yaml:"k8s_mount"`
|
||||
K8sJWTPath string `yaml:"k8s_jwt_path"`
|
||||
CAFile string `yaml:"ca_file"`
|
||||
}
|
||||
|
||||
// IndexConfig targets the ClickHouse archive index. Credentials come from the
|
||||
// clickhouse-credentials secret via env by default.
|
||||
type IndexConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Address string `yaml:"address"` // host:port for the native protocol (9000)
|
||||
Database string `yaml:"database"`
|
||||
Table string `yaml:"table"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
PasswordEnv string `yaml:"password_env"`
|
||||
TLS bool `yaml:"tls"`
|
||||
}
|
||||
|
||||
// MetricsConfig configures the Prometheus /metrics listener.
|
||||
type MetricsConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Address string `yaml:"address"`
|
||||
}
|
||||
|
||||
// LogConfig configures structured logging.
|
||||
type LogConfig struct {
|
||||
Level string `yaml:"level"` // debug|info|warn|error
|
||||
Format string `yaml:"format"` // json|text
|
||||
}
|
||||
|
||||
// Default returns a Config pre-populated with the logging-stack conventions so
|
||||
// an in-cluster deployment needs only secrets (creds) supplied via env.
|
||||
func Default() Config {
|
||||
return Config{
|
||||
NATS: NATSConfig{
|
||||
URL: "nats://nats.logging.svc.cluster.local:4222",
|
||||
Stream: "LOGS",
|
||||
Durable: "archiver",
|
||||
Subjects: []string{"logs.k8s.vault.>"},
|
||||
User: "log-consumer",
|
||||
PasswordEnv: "NATS_CONSUMER_PASSWORD",
|
||||
FetchBatch: 512,
|
||||
AckWait: 2 * time.Minute,
|
||||
},
|
||||
Batch: BatchConfig{
|
||||
MaxBytes: 64 * 1024 * 1024, // 64 MiB raw NDJSON per object
|
||||
MaxEvents: 200000,
|
||||
MaxAge: 5 * time.Minute,
|
||||
},
|
||||
S3: S3Config{
|
||||
Endpoint: "https://s3.ceph.unkin.net",
|
||||
Bucket: "logs-archive",
|
||||
Region: "us-east-1",
|
||||
PathStyle: true,
|
||||
KeyPrefix: "archive/{{.Subject}}/{{.Year}}/{{.Month}}/{{.Day}}/",
|
||||
CAFile: "/etc/vault-ca/ca.crt",
|
||||
EndpointEnv: "S3_ENDPOINT",
|
||||
BucketEnv: "BUCKET_NAME",
|
||||
},
|
||||
Crypto: CryptoConfig{
|
||||
KeyName: "logarchive",
|
||||
Source: PubkeyFile,
|
||||
PubkeyFile: "/etc/logarchiver/pubkey.asc",
|
||||
RefreshInterval: time.Hour,
|
||||
FrameSize: 1 << 20, // 1 MiB frames
|
||||
Vault: VaultConfig{
|
||||
Mount: "gpg",
|
||||
AuthMethod: "kubernetes",
|
||||
K8sMount: "k8s/au/syd1",
|
||||
K8sRole: "default",
|
||||
K8sJWTPath: "/var/run/secrets/kubernetes.io/serviceaccount/token",
|
||||
},
|
||||
},
|
||||
Index: IndexConfig{
|
||||
Enabled: true,
|
||||
Address: "clickhouse-logs.logging.svc.cluster.local:9000",
|
||||
Database: "logs",
|
||||
Table: "archive_index",
|
||||
Username: "vector",
|
||||
PasswordEnv: "CLICKHOUSE_PASSWORD",
|
||||
TLS: false,
|
||||
},
|
||||
Metrics: MetricsConfig{Enabled: true, Address: ":9090"},
|
||||
Log: LogConfig{Level: "info", Format: "json"},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads defaults, overlays the YAML file at path (if non-empty), then
|
||||
// applies environment overrides, and validates the result.
|
||||
func Load(path string) (Config, error) {
|
||||
cfg := Default()
|
||||
if path != "" {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("parse config %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
cfg.applyEnv()
|
||||
cfg.resolveSecretEnvs()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// applyEnv overlays scalar overrides from the environment. Only the knobs an
|
||||
// operator commonly flips are wired; secrets are handled by resolveSecretEnvs.
|
||||
func (c *Config) applyEnv() {
|
||||
if v := os.Getenv("LOGARCHIVER_NATS_URL"); v != "" {
|
||||
c.NATS.URL = v
|
||||
}
|
||||
if v := os.Getenv("LOGARCHIVER_NATS_DURABLE"); v != "" {
|
||||
c.NATS.Durable = v
|
||||
}
|
||||
if v := os.Getenv("ARCHIVE_SUBJECTS"); v != "" {
|
||||
c.NATS.Subjects = splitFields(v)
|
||||
}
|
||||
if v := os.Getenv("LOGARCHIVER_S3_ENDPOINT"); v != "" {
|
||||
c.S3.Endpoint = v
|
||||
}
|
||||
if v := os.Getenv("LOGARCHIVER_S3_BUCKET"); v != "" {
|
||||
c.S3.Bucket = v
|
||||
}
|
||||
if v := os.Getenv("LOGARCHIVER_KEY_NAME"); v != "" {
|
||||
c.Crypto.KeyName = v
|
||||
}
|
||||
if v := os.Getenv("LOGARCHIVER_PUBKEY_SOURCE"); v != "" {
|
||||
c.Crypto.Source = PubkeySource(v)
|
||||
}
|
||||
if v := os.Getenv("LOGARCHIVER_PUBKEY_FILE"); v != "" {
|
||||
c.Crypto.PubkeyFile = v
|
||||
}
|
||||
if v := os.Getenv("VAULT_ADDR"); v != "" && c.Crypto.Vault.Address == "" {
|
||||
c.Crypto.Vault.Address = v
|
||||
}
|
||||
if v := os.Getenv("LOGARCHIVER_VAULT_MOUNT"); v != "" {
|
||||
c.Crypto.Vault.Mount = v
|
||||
}
|
||||
if v := os.Getenv("LOGARCHIVER_CLICKHOUSE_ADDR"); v != "" {
|
||||
c.Index.Address = v
|
||||
}
|
||||
if v := os.Getenv("CLICKHOUSE_USER"); v != "" {
|
||||
c.Index.Username = v
|
||||
}
|
||||
if v := os.Getenv("LOGARCHIVER_METRICS_ADDR"); v != "" {
|
||||
c.Metrics.Address = v
|
||||
}
|
||||
if v := os.Getenv("LOGARCHIVER_LOG_LEVEL"); v != "" {
|
||||
c.Log.Level = v
|
||||
}
|
||||
if v := os.Getenv("LOGARCHIVER_LOG_FORMAT"); v != "" {
|
||||
c.Log.Format = v
|
||||
}
|
||||
// Endpoint/bucket sourced from the cephrgw secret, if present.
|
||||
if c.S3.EndpointEnv != "" {
|
||||
if v := os.Getenv(c.S3.EndpointEnv); v != "" {
|
||||
c.S3.Endpoint = v
|
||||
}
|
||||
}
|
||||
if c.S3.BucketEnv != "" {
|
||||
if v := os.Getenv(c.S3.BucketEnv); v != "" {
|
||||
c.S3.Bucket = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveSecretEnvs pulls passwords from their named env vars when not set inline.
|
||||
func (c *Config) resolveSecretEnvs() {
|
||||
if c.NATS.Password == "" && c.NATS.PasswordEnv != "" {
|
||||
c.NATS.Password = os.Getenv(c.NATS.PasswordEnv)
|
||||
}
|
||||
if c.Index.Password == "" && c.Index.PasswordEnv != "" {
|
||||
c.Index.Password = os.Getenv(c.Index.PasswordEnv)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks required fields and coherence.
|
||||
func (c *Config) Validate() error {
|
||||
if c.NATS.URL == "" {
|
||||
return fmt.Errorf("nats.url is required")
|
||||
}
|
||||
if c.NATS.Stream == "" {
|
||||
return fmt.Errorf("nats.stream is required")
|
||||
}
|
||||
if c.NATS.Durable == "" {
|
||||
return fmt.Errorf("nats.durable is required")
|
||||
}
|
||||
if len(c.NATS.Subjects) == 0 {
|
||||
return fmt.Errorf("nats.subjects must list at least one filter subject")
|
||||
}
|
||||
if c.S3.Bucket == "" {
|
||||
return fmt.Errorf("s3.bucket is required")
|
||||
}
|
||||
if c.S3.Endpoint == "" {
|
||||
return fmt.Errorf("s3.endpoint is required")
|
||||
}
|
||||
if c.Crypto.KeyName == "" {
|
||||
return fmt.Errorf("crypto.key_name is required")
|
||||
}
|
||||
switch c.Crypto.Source {
|
||||
case PubkeyVault:
|
||||
if c.Crypto.Vault.Address == "" {
|
||||
return fmt.Errorf("crypto.vault.address is required when pubkey_source=vault")
|
||||
}
|
||||
if c.Crypto.Vault.Mount == "" {
|
||||
return fmt.Errorf("crypto.vault.mount is required when pubkey_source=vault")
|
||||
}
|
||||
case PubkeyFile:
|
||||
if c.Crypto.PubkeyFile == "" {
|
||||
return fmt.Errorf("crypto.pubkey_file is required when pubkey_source=file")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("crypto.pubkey_source must be 'vault' or 'file', got %q", c.Crypto.Source)
|
||||
}
|
||||
if c.Crypto.FrameSize <= 0 {
|
||||
return fmt.Errorf("crypto.frame_size must be positive")
|
||||
}
|
||||
if c.Batch.MaxBytes <= 0 && c.Batch.MaxEvents <= 0 && c.Batch.MaxAge <= 0 {
|
||||
return fmt.Errorf("batch must set at least one of max_bytes/max_events/max_age")
|
||||
}
|
||||
if c.Index.Enabled && c.Index.Address == "" {
|
||||
return fmt.Errorf("index.address is required when index.enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitFields(s string) []string {
|
||||
var out []string
|
||||
for _, f := range strings.Fields(s) {
|
||||
if f != "" {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseSize parses a byte size like "64Mi", "128MB", "1024". It is a helper for
|
||||
// CLI flags; the YAML fields are plain integers.
|
||||
func ParseSize(s string) (int64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("empty size")
|
||||
}
|
||||
mult := int64(1)
|
||||
switch {
|
||||
case strings.HasSuffix(s, "Gi"):
|
||||
mult, s = 1<<30, strings.TrimSuffix(s, "Gi")
|
||||
case strings.HasSuffix(s, "Mi"):
|
||||
mult, s = 1<<20, strings.TrimSuffix(s, "Mi")
|
||||
case strings.HasSuffix(s, "Ki"):
|
||||
mult, s = 1<<10, strings.TrimSuffix(s, "Ki")
|
||||
case strings.HasSuffix(s, "GB"):
|
||||
mult, s = 1e9, strings.TrimSuffix(s, "GB")
|
||||
case strings.HasSuffix(s, "MB"):
|
||||
mult, s = 1e6, strings.TrimSuffix(s, "MB")
|
||||
case strings.HasSuffix(s, "KB"):
|
||||
mult, s = 1e3, strings.TrimSuffix(s, "KB")
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse size %q: %w", s, err)
|
||||
}
|
||||
return n * mult, nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultIsValid(t *testing.T) {
|
||||
cfg := Default()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("default config should validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEnvOverrides(t *testing.T) {
|
||||
t.Setenv("ARCHIVE_SUBJECTS", "logs.vm.> logs.k8s.vault.>")
|
||||
t.Setenv("LOGARCHIVER_S3_BUCKET", "my-bucket")
|
||||
t.Setenv("LOGARCHIVER_NATS_DURABLE", "archiver-canary")
|
||||
t.Setenv("NATS_CONSUMER_PASSWORD", "s3cr3t")
|
||||
t.Setenv("S3_ENDPOINT", "https://rgw.internal")
|
||||
t.Setenv("BUCKET_NAME", "logs-archive-override")
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got, want := cfg.NATS.Subjects, []string{"logs.vm.>", "logs.k8s.vault.>"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Errorf("subjects = %v, want %v", got, want)
|
||||
}
|
||||
if cfg.NATS.Durable != "archiver-canary" {
|
||||
t.Errorf("durable = %q", cfg.NATS.Durable)
|
||||
}
|
||||
if cfg.NATS.Password != "s3cr3t" {
|
||||
t.Errorf("password from env not resolved: %q", cfg.NATS.Password)
|
||||
}
|
||||
// BUCKET_NAME (secret) should win over LOGARCHIVER_S3_BUCKET default flow.
|
||||
if cfg.S3.Bucket != "logs-archive-override" {
|
||||
t.Errorf("bucket = %q, want cephrgw secret override", cfg.S3.Bucket)
|
||||
}
|
||||
if cfg.S3.Endpoint != "https://rgw.internal" {
|
||||
t.Errorf("endpoint = %q", cfg.S3.Endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateErrors(t *testing.T) {
|
||||
cases := map[string]func(*Config){
|
||||
"no subjects": func(c *Config) { c.NATS.Subjects = nil },
|
||||
"no bucket": func(c *Config) { c.S3.Bucket = "" },
|
||||
"no key name": func(c *Config) { c.Crypto.KeyName = "" },
|
||||
"bad source": func(c *Config) { c.Crypto.Source = "elsewhere" },
|
||||
"file no path": func(c *Config) { c.Crypto.Source = PubkeyFile; c.Crypto.PubkeyFile = "" },
|
||||
"vault no addr": func(c *Config) { c.Crypto.Source = PubkeyVault; c.Crypto.Vault.Address = "" },
|
||||
"zero frame": func(c *Config) { c.Crypto.FrameSize = 0 },
|
||||
"no batch bounds": func(c *Config) { c.Batch = BatchConfig{} },
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
mutate(&cfg)
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Errorf("expected validation error for %q", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateVaultSourceOK(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Crypto.Source = PubkeyVault
|
||||
cfg.Crypto.Vault.Address = "https://vault.example:8200"
|
||||
cfg.Crypto.Vault.Mount = "gpg"
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("vault source should validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSize(t *testing.T) {
|
||||
cases := map[string]int64{
|
||||
"1024": 1024,
|
||||
"64Mi": 64 << 20,
|
||||
"2Gi": 2 << 30,
|
||||
"1Ki": 1024,
|
||||
"5MB": 5_000_000,
|
||||
" 10 ": 10,
|
||||
}
|
||||
for in, want := range cases {
|
||||
got, err := ParseSize(in)
|
||||
if err != nil {
|
||||
t.Errorf("ParseSize(%q): %v", in, err)
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("ParseSize(%q) = %d, want %d", in, got, want)
|
||||
}
|
||||
}
|
||||
if _, err := ParseSize("bogus"); err == nil {
|
||||
t.Errorf("expected error for bogus size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultBatchDurations(t *testing.T) {
|
||||
cfg := Default()
|
||||
if cfg.Batch.MaxAge != 5*time.Minute {
|
||||
t.Errorf("default MaxAge = %v", cfg.Batch.MaxAge)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package consumer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/logarchiver/internal/config"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Connect dials NATS as the configured user and returns the connection and a
|
||||
// JetStream context. Callers must Close the returned *nats.Conn.
|
||||
func Connect(cfg config.NATSConfig) (*nats.Conn, jetstream.JetStream, error) {
|
||||
opts := []nats.Option{
|
||||
nats.Name("logarchiver"),
|
||||
nats.MaxReconnects(-1),
|
||||
nats.ReconnectWait(2 * time.Second),
|
||||
}
|
||||
if cfg.User != "" {
|
||||
opts = append(opts, nats.UserInfo(cfg.User, cfg.Password))
|
||||
}
|
||||
if cfg.CAFile != "" {
|
||||
pool := x509.NewCertPool()
|
||||
pem, err := os.ReadFile(cfg.CAFile)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read nats ca %s: %w", cfg.CAFile, err)
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, nil, fmt.Errorf("no certs parsed from nats ca %s", cfg.CAFile)
|
||||
}
|
||||
opts = append(opts, nats.Secure(&tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}))
|
||||
}
|
||||
nc, err := nats.Connect(cfg.URL, opts...)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("connect nats %s: %w", cfg.URL, err)
|
||||
}
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
nc.Close()
|
||||
return nil, nil, fmt.Errorf("jetstream context: %w", err)
|
||||
}
|
||||
return nc, js, nil
|
||||
}
|
||||
|
||||
// EnsureConsumer creates or updates the durable pull consumer on the stream with
|
||||
// the configured subject filters. Independent offsets and explicit acks give
|
||||
// logarchiver at-least-once delivery decoupled from the transform tier.
|
||||
func EnsureConsumer(ctx context.Context, js jetstream.JetStream, cfg config.NATSConfig) (jetstream.Consumer, error) {
|
||||
ackWait := cfg.AckWait
|
||||
if ackWait <= 0 {
|
||||
ackWait = 2 * time.Minute
|
||||
}
|
||||
consCfg := jetstream.ConsumerConfig{
|
||||
Durable: cfg.Durable,
|
||||
Name: cfg.Durable,
|
||||
AckPolicy: jetstream.AckExplicitPolicy,
|
||||
DeliverPolicy: jetstream.DeliverAllPolicy,
|
||||
AckWait: ackWait,
|
||||
MaxDeliver: -1,
|
||||
ReplayPolicy: jetstream.ReplayInstantPolicy,
|
||||
}
|
||||
switch len(cfg.Subjects) {
|
||||
case 0:
|
||||
return nil, fmt.Errorf("no subject filters configured")
|
||||
case 1:
|
||||
consCfg.FilterSubject = cfg.Subjects[0]
|
||||
default:
|
||||
consCfg.FilterSubjects = cfg.Subjects
|
||||
}
|
||||
cons, err := js.CreateOrUpdateConsumer(ctx, cfg.Stream, consCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensure consumer %s on stream %s: %w", cfg.Durable, cfg.Stream, err)
|
||||
}
|
||||
return cons, nil
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package consumer
|
||||
|
||||
import "git.unkin.net/unkin/logarchiver/internal/event"
|
||||
|
||||
// extractMeta projects the host/timestamp fields from a raw event payload.
|
||||
func extractMeta(raw []byte) event.Meta {
|
||||
return event.Extract(raw)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Package consumer binds the JetStream pull consumer and runs the archive loop.
|
||||
//
|
||||
// The core correctness property: a batch's messages are acknowledged ONLY after
|
||||
// the batch has been sealed, uploaded to S3, and indexed in ClickHouse. If any
|
||||
// of those fails the messages are Nak'd (with a backoff) and JetStream
|
||||
// redelivers them, so nothing is lost on a sink outage. This sink-conditional
|
||||
// acking is the main thing logarchiver does that a stock Vector NATS consumer
|
||||
// cannot.
|
||||
package consumer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/logarchiver/internal/batcher"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Persister stores a ready batch durably. On success the caller acks.
|
||||
type Persister interface {
|
||||
Store(ctx context.Context, batch *batcher.Batch) (StoreResult, error)
|
||||
}
|
||||
|
||||
// StoreResult mirrors archiver.StoreResult (kept local to avoid an import cycle;
|
||||
// the archiver's result is adapted at the call site).
|
||||
type StoreResult struct {
|
||||
ObjectKey string
|
||||
Events int
|
||||
RawBytes int64
|
||||
StoredBytes int64
|
||||
}
|
||||
|
||||
// Metrics is the optional metrics surface for the loop.
|
||||
type Metrics interface {
|
||||
MessagesFetched(n int)
|
||||
Acked(n int)
|
||||
BatchFlushed(trigger string)
|
||||
SetPending(n int)
|
||||
}
|
||||
|
||||
// Runner drives the fetch → batch → persist → ack loop.
|
||||
type Runner struct {
|
||||
cons jetstream.Consumer
|
||||
batcher *batcher.Batcher
|
||||
persist Persister
|
||||
log *slog.Logger
|
||||
metrics Metrics
|
||||
fetchBatch int
|
||||
pollWait time.Duration
|
||||
nakBackoff time.Duration
|
||||
drainTO time.Duration
|
||||
nowFn func() time.Time
|
||||
}
|
||||
|
||||
// Options configures a Runner.
|
||||
type Options struct {
|
||||
Consumer jetstream.Consumer
|
||||
Batcher *batcher.Batcher
|
||||
Persister Persister
|
||||
Logger *slog.Logger
|
||||
Metrics Metrics
|
||||
FetchBatch int
|
||||
// PollWait bounds each Fetch and thus how often age-based flushes are checked.
|
||||
PollWait time.Duration
|
||||
// NakBackoff delays redelivery after a persist failure.
|
||||
NakBackoff time.Duration
|
||||
// DrainTimeout bounds the shutdown flush.
|
||||
DrainTimeout time.Duration
|
||||
}
|
||||
|
||||
// NewRunner builds a Runner.
|
||||
func NewRunner(o Options) *Runner {
|
||||
fetch := o.FetchBatch
|
||||
if fetch <= 0 {
|
||||
fetch = 512
|
||||
}
|
||||
poll := o.PollWait
|
||||
if poll <= 0 {
|
||||
poll = time.Second
|
||||
}
|
||||
nak := o.NakBackoff
|
||||
if nak <= 0 {
|
||||
nak = 10 * time.Second
|
||||
}
|
||||
drain := o.DrainTimeout
|
||||
if drain <= 0 {
|
||||
drain = 30 * time.Second
|
||||
}
|
||||
log := o.Logger
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Runner{
|
||||
cons: o.Consumer,
|
||||
batcher: o.Batcher,
|
||||
persist: o.Persister,
|
||||
log: log,
|
||||
metrics: o.Metrics,
|
||||
fetchBatch: fetch,
|
||||
pollWait: poll,
|
||||
nakBackoff: nak,
|
||||
drainTO: drain,
|
||||
nowFn: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Run loops until ctx is cancelled, then drains open batches before returning.
|
||||
func (r *Runner) Run(ctx context.Context) error {
|
||||
r.log.Info("archive loop started",
|
||||
"fetch_batch", r.fetchBatch, "poll_wait", r.pollWait.String())
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return r.drain()
|
||||
}
|
||||
|
||||
// Age-based flush before fetching more.
|
||||
r.flushBatches(ctx, r.batcher.DueByAge(r.nowFn()), "age")
|
||||
|
||||
msgs, err := r.cons.Fetch(r.fetchBatch, jetstream.FetchMaxWait(r.pollWait))
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
||||
return r.drain()
|
||||
}
|
||||
r.log.Warn("fetch failed", "err", err)
|
||||
r.sleep(ctx, r.pollWait)
|
||||
continue
|
||||
}
|
||||
|
||||
n := 0
|
||||
for msg := range msgs.Messages() {
|
||||
n++
|
||||
r.route(ctx, msg)
|
||||
}
|
||||
if ferr := msgs.Error(); ferr != nil && !errors.Is(ferr, context.Canceled) {
|
||||
r.log.Warn("fetch iteration error", "err", ferr)
|
||||
}
|
||||
if r.metrics != nil {
|
||||
r.metrics.MessagesFetched(n)
|
||||
r.metrics.SetPending(r.batcher.Pending())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// route decodes a message and adds it to the batcher, flushing if the batch
|
||||
// becomes full.
|
||||
func (r *Runner) route(ctx context.Context, msg jetstream.Msg) {
|
||||
meta := extractMeta(msg.Data())
|
||||
full := r.batcher.Add(batcher.Item{
|
||||
Subject: msg.Subject(),
|
||||
Raw: msg.Data(),
|
||||
Host: meta.Host,
|
||||
Timestamp: meta.Timestamp,
|
||||
HasTS: meta.Ok,
|
||||
Ack: msg,
|
||||
})
|
||||
if full != nil {
|
||||
r.flush(ctx, full, "full")
|
||||
}
|
||||
}
|
||||
|
||||
// flushBatches flushes a slice of batches with the given trigger label.
|
||||
func (r *Runner) flushBatches(ctx context.Context, batches []*batcher.Batch, trigger string) {
|
||||
for _, b := range batches {
|
||||
r.flush(ctx, b, trigger)
|
||||
}
|
||||
}
|
||||
|
||||
// flush persists a batch and, only on success, acks its messages. On failure it
|
||||
// Naks with a backoff so JetStream redelivers.
|
||||
func (r *Runner) flush(ctx context.Context, b *batcher.Batch, trigger string) {
|
||||
if len(b.Items) == 0 {
|
||||
return
|
||||
}
|
||||
res, err := r.persist.Store(ctx, b)
|
||||
if err != nil {
|
||||
r.log.Error("persist failed; batch will be redelivered",
|
||||
"subject", b.Subject, "events", len(b.Items), "trigger", trigger, "err", err)
|
||||
r.nakAll(b)
|
||||
return
|
||||
}
|
||||
acked := r.ackAll(b)
|
||||
if r.metrics != nil {
|
||||
r.metrics.Acked(acked)
|
||||
r.metrics.BatchFlushed(trigger)
|
||||
}
|
||||
r.log.Info("object archived",
|
||||
"subject", b.Subject, "object_key", res.ObjectKey,
|
||||
"events", res.Events, "raw_bytes", res.RawBytes, "stored_bytes", res.StoredBytes,
|
||||
"trigger", trigger)
|
||||
}
|
||||
|
||||
func (r *Runner) ackAll(b *batcher.Batch) int {
|
||||
n := 0
|
||||
for _, it := range b.Items {
|
||||
if msg, ok := it.Ack.(jetstream.Msg); ok {
|
||||
if err := msg.Ack(); err != nil {
|
||||
r.log.Warn("ack failed", "subject", b.Subject, "err", err)
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (r *Runner) nakAll(b *batcher.Batch) {
|
||||
for _, it := range b.Items {
|
||||
if msg, ok := it.Ack.(jetstream.Msg); ok {
|
||||
_ = msg.NakWithDelay(r.nakBackoff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drain flushes all open batches during shutdown with a bounded timeout.
|
||||
func (r *Runner) drain() error {
|
||||
batches := r.batcher.Drain()
|
||||
if len(batches) == 0 {
|
||||
r.log.Info("archive loop stopped; nothing to drain")
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), r.drainTO)
|
||||
defer cancel()
|
||||
r.log.Info("draining open batches", "batches", len(batches))
|
||||
r.flushBatches(ctx, batches, "shutdown")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) sleep(ctx context.Context, d time.Duration) {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package consumer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/logarchiver/internal/batcher"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// fakeMsg is a minimal jetstream.Msg recording ack/nak calls.
|
||||
type fakeMsg struct {
|
||||
subject string
|
||||
data []byte
|
||||
mu sync.Mutex
|
||||
acked bool
|
||||
naked bool
|
||||
}
|
||||
|
||||
func (m *fakeMsg) Metadata() (*jetstream.MsgMetadata, error) { return &jetstream.MsgMetadata{}, nil }
|
||||
func (m *fakeMsg) Data() []byte { return m.data }
|
||||
func (m *fakeMsg) Headers() nats.Header { return nil }
|
||||
func (m *fakeMsg) Subject() string { return m.subject }
|
||||
func (m *fakeMsg) Reply() string { return "" }
|
||||
func (m *fakeMsg) Ack() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.acked = true
|
||||
return nil
|
||||
}
|
||||
func (m *fakeMsg) DoubleAck(context.Context) error { return nil }
|
||||
func (m *fakeMsg) Nak() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.naked = true
|
||||
return nil
|
||||
}
|
||||
func (m *fakeMsg) NakWithDelay(time.Duration) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.naked = true
|
||||
return nil
|
||||
}
|
||||
func (m *fakeMsg) InProgress() error { return nil }
|
||||
func (m *fakeMsg) Term() error { return nil }
|
||||
func (m *fakeMsg) TermWithReason(string) error { return nil }
|
||||
|
||||
func (m *fakeMsg) isAcked() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.acked
|
||||
}
|
||||
func (m *fakeMsg) isNaked() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.naked
|
||||
}
|
||||
|
||||
// fakePersister records calls and can be made to fail.
|
||||
type fakePersister struct {
|
||||
fail bool
|
||||
called int
|
||||
}
|
||||
|
||||
func (p *fakePersister) Store(_ context.Context, b *batcher.Batch) (StoreResult, error) {
|
||||
p.called++
|
||||
if p.fail {
|
||||
return StoreResult{}, errors.New("boom")
|
||||
}
|
||||
return StoreResult{ObjectKey: "k", Events: len(b.Items)}, nil
|
||||
}
|
||||
|
||||
func batchWith(msgs ...*fakeMsg) *batcher.Batch {
|
||||
b := &batcher.Batch{Subject: "s"}
|
||||
for _, m := range msgs {
|
||||
b.Items = append(b.Items, batcher.Item{Subject: "s", Raw: m.data, Ack: jetstream.Msg(m)})
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TestFlushAcksOnlyAfterPersist is the core correctness test: messages are acked
|
||||
// exactly when Store succeeds, and Nak'd (never acked) when it fails.
|
||||
func TestFlushAcksOnSuccess(t *testing.T) {
|
||||
p := &fakePersister{}
|
||||
r := NewRunner(Options{Persister: p})
|
||||
m1 := &fakeMsg{subject: "s", data: []byte(`{"host":"h"}`)}
|
||||
m2 := &fakeMsg{subject: "s", data: []byte(`{"host":"h2"}`)}
|
||||
|
||||
r.flush(context.Background(), batchWith(m1, m2), "test")
|
||||
|
||||
if p.called != 1 {
|
||||
t.Fatalf("Store called %d times, want 1", p.called)
|
||||
}
|
||||
if !m1.isAcked() || !m2.isAcked() {
|
||||
t.Errorf("messages should be acked after successful persist")
|
||||
}
|
||||
if m1.isNaked() || m2.isNaked() {
|
||||
t.Errorf("messages must not be naked on success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushNaksOnFailure(t *testing.T) {
|
||||
p := &fakePersister{fail: true}
|
||||
r := NewRunner(Options{Persister: p})
|
||||
m1 := &fakeMsg{subject: "s", data: []byte(`{"host":"h"}`)}
|
||||
|
||||
r.flush(context.Background(), batchWith(m1), "test")
|
||||
|
||||
if m1.isAcked() {
|
||||
t.Errorf("message must NOT be acked when persist fails")
|
||||
}
|
||||
if !m1.isNaked() {
|
||||
t.Errorf("message should be naked so JetStream redelivers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushEmptyBatchNoop(t *testing.T) {
|
||||
p := &fakePersister{}
|
||||
r := NewRunner(Options{Persister: p})
|
||||
r.flush(context.Background(), &batcher.Batch{Subject: "s"}, "test")
|
||||
if p.called != 0 {
|
||||
t.Errorf("empty batch should not call Store")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouteAndFlushIntegration wires a real batcher: adding enough messages to
|
||||
// fill the batch triggers a full flush that persists and acks exactly those.
|
||||
func TestRouteFlushViaBatcher(t *testing.T) {
|
||||
p := &fakePersister{}
|
||||
bat := batcher.New(batcher.Limits{MaxEvents: 2})
|
||||
r := NewRunner(Options{Persister: p, Batcher: bat})
|
||||
|
||||
m1 := &fakeMsg{subject: "s", data: []byte(`{"host":"a"}`)}
|
||||
m2 := &fakeMsg{subject: "s", data: []byte(`{"host":"b"}`)}
|
||||
r.route(context.Background(), m1)
|
||||
if m1.isAcked() {
|
||||
t.Errorf("first message should not be acked before batch fills")
|
||||
}
|
||||
r.route(context.Background(), m2) // fills batch -> flush
|
||||
|
||||
if p.called != 1 {
|
||||
t.Fatalf("Store called %d times, want 1 after fill", p.called)
|
||||
}
|
||||
if !m1.isAcked() || !m2.isAcked() {
|
||||
t.Errorf("both messages should be acked after the full-batch flush")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
)
|
||||
|
||||
// genTestKey creates an OpenPGP keypair, returns the armored public key (as the
|
||||
// service would export from Vault) and an unwrap func that decrypts the wrapped
|
||||
// DEK with the private key — simulating the Vault GPG engine's decrypt endpoint
|
||||
// (which returns the plaintext of a whole OpenPGP message).
|
||||
func genTestKey(t *testing.T) (armoredPub []byte, unwrap UnwrapFunc) {
|
||||
t.Helper()
|
||||
ent, err := openpgp.NewEntity("logarchiver-test", "unit test", "test@unkin.net", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEntity: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("armor encode: %v", err)
|
||||
}
|
||||
if err := ent.Serialize(w); err != nil {
|
||||
t.Fatalf("serialize public: %v", err)
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
unwrap = func(wrapped []byte) ([]byte, error) {
|
||||
md, err := openpgp.ReadMessage(bytes.NewReader(wrapped), openpgp.EntityList{ent}, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.ReadAll(md.UnverifiedBody)
|
||||
}
|
||||
return buf.Bytes(), unwrap
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
armoredPub, unwrap := genTestKey(t)
|
||||
pub, err := LoadPublicKey(armoredPub)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPublicKey: %v", err)
|
||||
}
|
||||
if len(pub.Fingerprint) != 40 {
|
||||
t.Errorf("fingerprint = %q, want 40 hex chars", pub.Fingerprint)
|
||||
}
|
||||
if pub.Fingerprint != strings.ToUpper(pub.Fingerprint) {
|
||||
t.Errorf("fingerprint should be uppercase: %q", pub.Fingerprint)
|
||||
}
|
||||
|
||||
// A multi-line NDJSON payload larger than the frame size (forces >1 frame).
|
||||
var payload bytes.Buffer
|
||||
for i := 0; i < 5000; i++ {
|
||||
payload.WriteString(`{"host":"node-1","message":"line `)
|
||||
payload.WriteString(strings.Repeat("x", 50))
|
||||
payload.WriteString(`"}` + "\n")
|
||||
}
|
||||
plaintext := payload.Bytes()
|
||||
|
||||
var sealed bytes.Buffer
|
||||
res, err := Seal(&sealed, plaintext, pub, "logarchive", 4096)
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
if res.RawBytes != int64(len(plaintext)) {
|
||||
t.Errorf("RawBytes = %d, want %d", res.RawBytes, len(plaintext))
|
||||
}
|
||||
if int64(sealed.Len()) != res.StoredBytes {
|
||||
t.Errorf("StoredBytes = %d, buffer = %d", res.StoredBytes, sealed.Len())
|
||||
}
|
||||
// Compression should shrink this highly repetitive payload.
|
||||
if res.StoredBytes >= res.RawBytes {
|
||||
t.Errorf("stored (%d) not smaller than raw (%d)", res.StoredBytes, res.RawBytes)
|
||||
}
|
||||
// Design property: only a tiny wrapped DEK goes to Vault, regardless of size.
|
||||
if res.Header.WrappedDEKLen > 4096 {
|
||||
t.Errorf("wrapped DEK unexpectedly large: %d bytes", res.Header.WrappedDEKLen)
|
||||
}
|
||||
if res.Header.KeyFingerprint != pub.Fingerprint {
|
||||
t.Errorf("header fingerprint mismatch")
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := Open(bytes.NewReader(sealed.Bytes()), &out, unwrap); err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if !bytes.Equal(out.Bytes(), plaintext) {
|
||||
t.Fatalf("round-trip mismatch: got %d bytes, want %d", out.Len(), len(plaintext))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTripEmpty(t *testing.T) {
|
||||
armoredPub, unwrap := genTestKey(t)
|
||||
pub, _ := LoadPublicKey(armoredPub)
|
||||
var sealed bytes.Buffer
|
||||
if _, err := Seal(&sealed, []byte{}, pub, "k", 4096); err != nil {
|
||||
t.Fatalf("Seal empty: %v", err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := Open(bytes.NewReader(sealed.Bytes()), &out, unwrap); err != nil {
|
||||
t.Fatalf("Open empty: %v", err)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Errorf("empty round-trip produced %d bytes", out.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperDetected(t *testing.T) {
|
||||
armoredPub, unwrap := genTestKey(t)
|
||||
pub, _ := LoadPublicKey(armoredPub)
|
||||
var sealed bytes.Buffer
|
||||
if _, err := Seal(&sealed, []byte("hello world\n"), pub, "k", 4096); err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
data := sealed.Bytes()
|
||||
// Flip a byte near the end (inside a frame's ciphertext/tag).
|
||||
data[len(data)-3] ^= 0xff
|
||||
var out bytes.Buffer
|
||||
if err := Open(bytes.NewReader(data), &out, unwrap); err == nil {
|
||||
t.Fatalf("expected GCM authentication failure on tampered ciphertext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadMagic(t *testing.T) {
|
||||
_, _, err := ReadHeader(bytes.NewReader([]byte("NOTLARC.....")))
|
||||
if err == nil {
|
||||
t.Fatalf("expected bad-magic error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHeaderFields(t *testing.T) {
|
||||
armoredPub, _ := genTestKey(t)
|
||||
pub, _ := LoadPublicKey(armoredPub)
|
||||
var sealed bytes.Buffer
|
||||
if _, err := Seal(&sealed, []byte("x\n"), pub, "logarchive", 4096); err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
hdr, wrapped, err := ReadHeader(bytes.NewReader(sealed.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadHeader: %v", err)
|
||||
}
|
||||
if hdr.KeyName != "logarchive" {
|
||||
t.Errorf("KeyName = %q", hdr.KeyName)
|
||||
}
|
||||
if hdr.Compression != "zstd" || hdr.Cipher != "AES-256-GCM" {
|
||||
t.Errorf("algo metadata wrong: %+v", hdr)
|
||||
}
|
||||
if len(wrapped) != hdr.WrappedDEKLen {
|
||||
t.Errorf("wrapped len %d != header %d", len(wrapped), hdr.WrappedDEKLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestArmoredStable(t *testing.T) {
|
||||
// Guards the test helper used elsewhere; identical input -> identical digest.
|
||||
a := digestArmored([]byte("abc"))
|
||||
b := digestArmored([]byte("abc"))
|
||||
if a != b || a == "" {
|
||||
t.Errorf("digestArmored not stable: %q %q", a, b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/packet"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
// ContainerMagic identifies a logarchiver object and its container version.
|
||||
var ContainerMagic = []byte("LARC1\n")
|
||||
|
||||
const (
|
||||
dekSize = 32 // AES-256
|
||||
noncePrefixSize = 4
|
||||
counterSize = 8
|
||||
// maxFrameCiphertext bounds a single frame read to avoid unbounded allocation
|
||||
// from a corrupt/hostile length prefix.
|
||||
maxFrameCiphertext = 128 << 20
|
||||
)
|
||||
|
||||
// Header is the LARC1 object header (JSON), written after the magic and a
|
||||
// uint32 big-endian length prefix.
|
||||
type Header struct {
|
||||
Version int `json:"v"`
|
||||
KeyName string `json:"key_name"`
|
||||
KeyFingerprint string `json:"key_fingerprint"`
|
||||
WrappedDEKLen int `json:"wrapped_dek_len"`
|
||||
NoncePrefix []byte `json:"nonce_prefix"` // base64 in JSON
|
||||
FrameSize int `json:"frame_size"` // plaintext (compressed) bytes per frame
|
||||
Compression string `json:"compression"` // "zstd"
|
||||
Cipher string `json:"cipher"` // "AES-256-GCM"
|
||||
}
|
||||
|
||||
// SealResult reports what Seal produced (for the index row).
|
||||
type SealResult struct {
|
||||
Header Header
|
||||
RawBytes int64 // input NDJSON length
|
||||
StoredBytes int64 // full container length
|
||||
}
|
||||
|
||||
// Seal compresses plaintext with zstd, encrypts it under a fresh random DEK
|
||||
// using framed AES-256-GCM, wraps the DEK to pub with OpenPGP, and writes the
|
||||
// LARC1 container to w. keyName is recorded in the header for operator context.
|
||||
func Seal(w io.Writer, plaintext []byte, pub *PublicKey, keyName string, frameSize int) (SealResult, error) {
|
||||
if pub == nil {
|
||||
return SealResult{}, fmt.Errorf("nil public key")
|
||||
}
|
||||
if frameSize <= 0 {
|
||||
frameSize = 1 << 20
|
||||
}
|
||||
|
||||
// 1. Compress.
|
||||
enc, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.SpeedBetterCompression))
|
||||
if err != nil {
|
||||
return SealResult{}, fmt.Errorf("zstd writer: %w", err)
|
||||
}
|
||||
compressed := enc.EncodeAll(plaintext, nil)
|
||||
_ = enc.Close()
|
||||
|
||||
// 2. DEK + nonce prefix.
|
||||
dek := make([]byte, dekSize)
|
||||
if _, err := rand.Read(dek); err != nil {
|
||||
return SealResult{}, fmt.Errorf("gen dek: %w", err)
|
||||
}
|
||||
noncePrefix := make([]byte, noncePrefixSize)
|
||||
if _, err := rand.Read(noncePrefix); err != nil {
|
||||
return SealResult{}, fmt.Errorf("gen nonce prefix: %w", err)
|
||||
}
|
||||
|
||||
// 3. Wrap the DEK to the public key (small standard OpenPGP message).
|
||||
wrapped, err := wrapDEK(dek, pub)
|
||||
if err != nil {
|
||||
return SealResult{}, err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(dek)
|
||||
if err != nil {
|
||||
return SealResult{}, fmt.Errorf("aes cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return SealResult{}, fmt.Errorf("gcm: %w", err)
|
||||
}
|
||||
|
||||
hdr := Header{
|
||||
Version: 1,
|
||||
KeyName: keyName,
|
||||
KeyFingerprint: pub.Fingerprint,
|
||||
WrappedDEKLen: len(wrapped),
|
||||
NoncePrefix: noncePrefix,
|
||||
FrameSize: frameSize,
|
||||
Compression: "zstd",
|
||||
Cipher: "AES-256-GCM",
|
||||
}
|
||||
hdrJSON, err := json.Marshal(hdr)
|
||||
if err != nil {
|
||||
return SealResult{}, fmt.Errorf("marshal header: %w", err)
|
||||
}
|
||||
|
||||
cw := &countingWriter{w: w}
|
||||
|
||||
// magic
|
||||
if _, err := cw.Write(ContainerMagic); err != nil {
|
||||
return SealResult{}, err
|
||||
}
|
||||
// header length + header
|
||||
if err := writeUint32(cw, uint32(len(hdrJSON))); err != nil {
|
||||
return SealResult{}, err
|
||||
}
|
||||
if _, err := cw.Write(hdrJSON); err != nil {
|
||||
return SealResult{}, err
|
||||
}
|
||||
// wrapped DEK
|
||||
if _, err := cw.Write(wrapped); err != nil {
|
||||
return SealResult{}, err
|
||||
}
|
||||
|
||||
// frames
|
||||
for i, off := 0, 0; off < len(compressed); i++ {
|
||||
end := off + frameSize
|
||||
if end > len(compressed) {
|
||||
end = len(compressed)
|
||||
}
|
||||
nonce := frameNonce(noncePrefix, uint64(i))
|
||||
aad := aadFor(uint64(i))
|
||||
ct := gcm.Seal(nil, nonce, compressed[off:end], aad)
|
||||
if err := writeUint32(cw, uint32(len(ct))); err != nil {
|
||||
return SealResult{}, err
|
||||
}
|
||||
if _, err := cw.Write(ct); err != nil {
|
||||
return SealResult{}, err
|
||||
}
|
||||
off = end
|
||||
}
|
||||
// terminating zero-length frame
|
||||
if err := writeUint32(cw, 0); err != nil {
|
||||
return SealResult{}, err
|
||||
}
|
||||
|
||||
return SealResult{Header: hdr, RawBytes: int64(len(plaintext)), StoredBytes: cw.n}, nil
|
||||
}
|
||||
|
||||
// wrapDEK OpenPGP-encrypts the DEK to pub, producing a compact binary message.
|
||||
func wrapDEK(dek []byte, pub *PublicKey) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
cfg := &packet.Config{
|
||||
DefaultCipher: packet.CipherAES256,
|
||||
}
|
||||
wc, err := openpgp.Encrypt(&buf, []*openpgp.Entity{pub.entity}, nil, nil, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openpgp encrypt dek: %w", err)
|
||||
}
|
||||
if _, err := wc.Write(dek); err != nil {
|
||||
return nil, fmt.Errorf("write dek: %w", err)
|
||||
}
|
||||
if err := wc.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close openpgp: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// UnwrapFunc recovers the DEK from the wrapped OpenPGP blob. In production this
|
||||
// calls the Vault GPG engine decrypt endpoint; tests supply a local one.
|
||||
type UnwrapFunc func(wrappedDEK []byte) (dek []byte, err error)
|
||||
|
||||
// ReadHeader reads and validates the LARC1 magic + header and the wrapped DEK,
|
||||
// leaving r positioned at the first frame. It does not require decryption keys,
|
||||
// so it is cheap for `search`/metadata inspection.
|
||||
func ReadHeader(r io.Reader) (Header, []byte, error) {
|
||||
magic := make([]byte, len(ContainerMagic))
|
||||
if _, err := io.ReadFull(r, magic); err != nil {
|
||||
return Header{}, nil, fmt.Errorf("read magic: %w", err)
|
||||
}
|
||||
if !bytes.Equal(magic, ContainerMagic) {
|
||||
return Header{}, nil, fmt.Errorf("bad magic: not a logarchiver (LARC1) object")
|
||||
}
|
||||
hlen, err := readUint32(r)
|
||||
if err != nil {
|
||||
return Header{}, nil, fmt.Errorf("read header len: %w", err)
|
||||
}
|
||||
if hlen == 0 || hlen > 1<<20 {
|
||||
return Header{}, nil, fmt.Errorf("implausible header length %d", hlen)
|
||||
}
|
||||
hdrJSON := make([]byte, hlen)
|
||||
if _, err := io.ReadFull(r, hdrJSON); err != nil {
|
||||
return Header{}, nil, fmt.Errorf("read header: %w", err)
|
||||
}
|
||||
var hdr Header
|
||||
if err := json.Unmarshal(hdrJSON, &hdr); err != nil {
|
||||
return Header{}, nil, fmt.Errorf("parse header: %w", err)
|
||||
}
|
||||
if hdr.Version != 1 {
|
||||
return Header{}, nil, fmt.Errorf("unsupported container version %d", hdr.Version)
|
||||
}
|
||||
if hdr.WrappedDEKLen <= 0 || hdr.WrappedDEKLen > 1<<20 {
|
||||
return Header{}, nil, fmt.Errorf("implausible wrapped dek length %d", hdr.WrappedDEKLen)
|
||||
}
|
||||
wrapped := make([]byte, hdr.WrappedDEKLen)
|
||||
if _, err := io.ReadFull(r, wrapped); err != nil {
|
||||
return Header{}, nil, fmt.Errorf("read wrapped dek: %w", err)
|
||||
}
|
||||
return hdr, wrapped, nil
|
||||
}
|
||||
|
||||
// Open reads a LARC1 container from r, recovers the DEK via unwrap, and streams
|
||||
// the decrypted, decompressed NDJSON to w.
|
||||
func Open(r io.Reader, w io.Writer, unwrap UnwrapFunc) error {
|
||||
hdr, wrapped, err := ReadHeader(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dek, err := unwrap(wrapped)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unwrap dek: %w", err)
|
||||
}
|
||||
if len(dek) != dekSize {
|
||||
return fmt.Errorf("unwrapped dek has wrong length %d", len(dek))
|
||||
}
|
||||
block, err := aes.NewCipher(dek)
|
||||
if err != nil {
|
||||
return fmt.Errorf("aes cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gcm: %w", err)
|
||||
}
|
||||
|
||||
fr := &frameReader{r: r, gcm: gcm, noncePrefix: hdr.NoncePrefix}
|
||||
zr, err := zstd.NewReader(fr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("zstd reader: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
if _, err := io.Copy(w, zr); err != nil {
|
||||
return fmt.Errorf("decompress: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// frameReader decrypts LARC1 frames on demand, presenting the decrypted
|
||||
// (compressed) bytes as an io.Reader for the zstd decoder.
|
||||
type frameReader struct {
|
||||
r io.Reader
|
||||
gcm cipher.AEAD
|
||||
noncePrefix []byte
|
||||
counter uint64
|
||||
buf []byte // leftover decrypted plaintext not yet consumed
|
||||
done bool
|
||||
}
|
||||
|
||||
func (f *frameReader) Read(p []byte) (int, error) {
|
||||
if len(f.buf) == 0 && !f.done {
|
||||
if err := f.next(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if len(f.buf) == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(p, f.buf)
|
||||
f.buf = f.buf[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (f *frameReader) next() error {
|
||||
ln, err := readUint32(f.r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read frame len: %w", err)
|
||||
}
|
||||
if ln == 0 { // terminator
|
||||
f.done = true
|
||||
return nil
|
||||
}
|
||||
if ln > maxFrameCiphertext {
|
||||
return fmt.Errorf("frame length %d exceeds max", ln)
|
||||
}
|
||||
ct := make([]byte, ln)
|
||||
if _, err := io.ReadFull(f.r, ct); err != nil {
|
||||
return fmt.Errorf("read frame: %w", err)
|
||||
}
|
||||
nonce := frameNonce(f.noncePrefix, f.counter)
|
||||
aad := aadFor(f.counter)
|
||||
pt, err := f.gcm.Open(nil, nonce, ct, aad)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt frame %d: %w", f.counter, err)
|
||||
}
|
||||
f.counter++
|
||||
f.buf = pt
|
||||
return nil
|
||||
}
|
||||
|
||||
func frameNonce(prefix []byte, counter uint64) []byte {
|
||||
nonce := make([]byte, noncePrefixSize+counterSize)
|
||||
copy(nonce, prefix)
|
||||
binary.BigEndian.PutUint64(nonce[noncePrefixSize:], counter)
|
||||
return nonce
|
||||
}
|
||||
|
||||
func aadFor(counter uint64) []byte {
|
||||
aad := make([]byte, counterSize)
|
||||
binary.BigEndian.PutUint64(aad, counter)
|
||||
return aad
|
||||
}
|
||||
|
||||
func writeUint32(w io.Writer, v uint32) error {
|
||||
var b [4]byte
|
||||
binary.BigEndian.PutUint32(b[:], v)
|
||||
_, err := w.Write(b[:])
|
||||
return err
|
||||
}
|
||||
|
||||
func readUint32(r io.Reader) (uint32, error) {
|
||||
var b [4]byte
|
||||
if _, err := io.ReadFull(r, b[:]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return binary.BigEndian.Uint32(b[:]), nil
|
||||
}
|
||||
|
||||
type countingWriter struct {
|
||||
w io.Writer
|
||||
n int64
|
||||
}
|
||||
|
||||
func (c *countingWriter) Write(p []byte) (int, error) {
|
||||
n, err := c.w.Write(p)
|
||||
c.n += int64(n)
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Package crypto implements logarchiver's object encryption.
|
||||
//
|
||||
// # Why not plain OpenPGP-encrypt the whole object?
|
||||
//
|
||||
// The private key lives only in Ben's Vault GPG secrets engine
|
||||
// (vault-plugin-secrets-gpg). That engine's decrypt endpoint does WHOLE-payload
|
||||
// inline decryption only: you POST the entire OpenPGP message (base64 in a JSON
|
||||
// body) and it returns the entire plaintext (base64). There is no session-key /
|
||||
// PKESK extraction and no streaming, so a multi-hundred-MiB archive could not be
|
||||
// retrieved without blowing Vault's request-size limit and buffering everything
|
||||
// twice in the server.
|
||||
//
|
||||
// # The wrapped-DEK envelope (container "LARC1")
|
||||
//
|
||||
// logarchiver therefore does hybrid encryption itself:
|
||||
//
|
||||
// - a fresh random 256-bit Data Encryption Key (DEK) per object;
|
||||
// - the bulk (zstd-compressed NDJSON) is encrypted locally with AES-256-GCM in
|
||||
// independent frames, so decryption streams frame-by-frame;
|
||||
// - only the 32-byte DEK is OpenPGP-encrypted to the engine's PUBLIC key,
|
||||
// producing a small (~hundreds of bytes) standard OpenPGP message.
|
||||
//
|
||||
// On retrieval the CLI sends ONLY that small wrapped-DEK blob to the engine's
|
||||
// decrypt endpoint, recovers the DEK, and streams the bulk locally. The Vault
|
||||
// round-trip is tiny and constant regardless of object size, and the private key
|
||||
// never leaves Vault. The trade-off vs. a single standard OpenPGP object: these
|
||||
// objects are a logarchiver-specific container, not decryptable by a bare `gpg`
|
||||
// even with the private key. The retrieval runbook documents the format.
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
)
|
||||
|
||||
// PublicKey is a parsed OpenPGP public key plus its fingerprint (uppercase hex,
|
||||
// no spaces — matching the Vault GPG engine's `%X` fingerprint format).
|
||||
type PublicKey struct {
|
||||
entity *openpgp.Entity
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
// LoadPublicKey parses an ASCII-armored (or binary) OpenPGP public key.
|
||||
func LoadPublicKey(data []byte) (*PublicKey, error) {
|
||||
var keyring openpgp.EntityList
|
||||
var err error
|
||||
if strings.Contains(string(data), "BEGIN PGP") {
|
||||
block, berr := armor.Decode(strings.NewReader(string(data)))
|
||||
if berr != nil {
|
||||
return nil, fmt.Errorf("decode armor: %w", berr)
|
||||
}
|
||||
keyring, err = openpgp.ReadKeyRing(block.Body)
|
||||
} else {
|
||||
keyring, err = openpgp.ReadKeyRing(strings.NewReader(string(data)))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read public key: %w", err)
|
||||
}
|
||||
if len(keyring) == 0 {
|
||||
return nil, fmt.Errorf("no public key found")
|
||||
}
|
||||
ent := keyring[0]
|
||||
if ent.PrimaryKey == nil {
|
||||
return nil, fmt.Errorf("key has no primary public key")
|
||||
}
|
||||
return &PublicKey{
|
||||
entity: ent,
|
||||
Fingerprint: fmt.Sprintf("%X", ent.PrimaryKey.Fingerprint),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// digestArmored is used by tests to sanity check key identity independent of
|
||||
// go-crypto internals.
|
||||
func digestArmored(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return fmt.Sprintf("%x", sum[:8])
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Package event extracts the fields logarchiver needs (host, timestamp) from a
|
||||
// raw log event as it flows through the centralized logging JetStream stream
|
||||
// (argocd-apps #296). Events are one JSON object per NATS message and are NOT
|
||||
// normalized — logarchiver persists them raw, so extraction must be tolerant of
|
||||
// the two shapes that share the logs.> subject space:
|
||||
//
|
||||
// - k8s pod logs (Vector kubernetes_logs): host lives at .kubernetes.pod_node_name,
|
||||
// timestamp at .timestamp (RFC3339).
|
||||
// - VM logs (vm-ingest): host at .host (fallback .hostname), timestamp at
|
||||
// .timestamp (fallback .ts).
|
||||
//
|
||||
// The NATS subject itself is authoritative for partitioning and is supplied by
|
||||
// the consumer, not read from the payload.
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Meta is the minimal, index-relevant projection of a raw log event.
|
||||
type Meta struct {
|
||||
// Host is the best-effort source host/node for the event, or "" if none
|
||||
// could be determined.
|
||||
Host string
|
||||
// Timestamp is the event time. Ok reports whether a timestamp field was
|
||||
// found and parsed; when false callers should fall back to ingest time.
|
||||
Timestamp time.Time
|
||||
Ok bool
|
||||
}
|
||||
|
||||
// hostPaths and tsPaths are tried in order. Dotted paths descend into nested
|
||||
// objects (only .kubernetes.pod_node_name is nested today).
|
||||
var (
|
||||
hostPaths = [][]string{
|
||||
{"host"},
|
||||
{"hostname"},
|
||||
{"kubernetes", "pod_node_name"},
|
||||
}
|
||||
tsPaths = [][]string{
|
||||
{"timestamp"},
|
||||
{"ts"},
|
||||
{"@timestamp"},
|
||||
}
|
||||
)
|
||||
|
||||
// Extract parses raw (a single JSON log event) and returns its host/timestamp
|
||||
// projection. It never errors: malformed or field-less events yield a zero-value
|
||||
// Meta (Host=="", Ok==false) so the archiver still stores the raw bytes and the
|
||||
// caller can fall back to ingest time. Only the fields of interest are decoded.
|
||||
func Extract(raw []byte) Meta {
|
||||
var doc map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return Meta{}
|
||||
}
|
||||
m := Meta{}
|
||||
m.Host = firstString(doc, hostPaths)
|
||||
if ts, ok := firstTime(doc, tsPaths); ok {
|
||||
m.Timestamp = ts
|
||||
m.Ok = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// firstString walks each path and returns the first value that decodes to a
|
||||
// non-empty string.
|
||||
func firstString(doc map[string]json.RawMessage, paths [][]string) string {
|
||||
for _, p := range paths {
|
||||
if v, ok := lookup(doc, p); ok {
|
||||
var s string
|
||||
if json.Unmarshal(v, &s) == nil && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// firstTime walks each path and returns the first value that parses as a
|
||||
// timestamp (RFC3339/RFC3339Nano string, or a numeric unix seconds/millis).
|
||||
func firstTime(doc map[string]json.RawMessage, paths [][]string) (time.Time, bool) {
|
||||
for _, p := range paths {
|
||||
v, ok := lookup(doc, p)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(v, &s) == nil && s != "" {
|
||||
if t, err := parseTimeString(s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
var n json.Number
|
||||
if json.Unmarshal(v, &n) == nil {
|
||||
if t, ok := parseNumericTime(n); ok {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// lookup descends doc following path. Intermediate elements must be JSON objects.
|
||||
func lookup(doc map[string]json.RawMessage, path []string) (json.RawMessage, bool) {
|
||||
cur := doc
|
||||
for i, key := range path {
|
||||
v, ok := cur[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if i == len(path)-1 {
|
||||
return v, true
|
||||
}
|
||||
var next map[string]json.RawMessage
|
||||
if json.Unmarshal(v, &next) != nil {
|
||||
return nil, false
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var timeLayouts = []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05.999999999Z0700",
|
||||
"2006-01-02 15:04:05.999999999Z07:00",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
|
||||
func parseTimeString(s string) (time.Time, error) {
|
||||
var lastErr error
|
||||
for _, l := range timeLayouts {
|
||||
t, err := time.Parse(l, s)
|
||||
if err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return time.Time{}, lastErr
|
||||
}
|
||||
|
||||
// parseNumericTime interprets n as unix seconds, milliseconds, microseconds, or
|
||||
// nanoseconds based on magnitude. Fractional seconds are supported.
|
||||
func parseNumericTime(n json.Number) (time.Time, bool) {
|
||||
f, err := n.Float64()
|
||||
if err != nil || f <= 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
switch {
|
||||
case f >= 1e18: // nanoseconds
|
||||
return time.Unix(0, int64(f)).UTC(), true
|
||||
case f >= 1e15: // microseconds
|
||||
return time.Unix(0, int64(f*1e3)).UTC(), true
|
||||
case f >= 1e12: // milliseconds
|
||||
return time.Unix(0, int64(f*1e6)).UTC(), true
|
||||
default: // seconds (possibly fractional)
|
||||
sec := int64(f)
|
||||
nsec := int64((f - float64(sec)) * 1e9)
|
||||
return time.Unix(sec, nsec).UTC(), true
|
||||
}
|
||||
}
|
||||
|
||||
// SubjectToken sanitizes a NATS subject into a filesystem/object-key-safe token,
|
||||
// matching the logging stack's convention of replacing [^a-zA-Z0-9_.-] with '_'.
|
||||
// Dots are preserved because subjects are dot-delimited.
|
||||
func SubjectToken(subject string) string {
|
||||
if subject == "" {
|
||||
return "_"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range subject {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '.', r == '-':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExtractK8s(t *testing.T) {
|
||||
raw := []byte(`{"message":"hello from pod","stream":"stdout","timestamp":"2026-07-27T00:00:00Z","kubernetes":{"pod_name":"web-abc","pod_namespace":"shop","container_name":"web","pod_node_name":"node-1"},"ns_token":"shop","cont_token":"web"}`)
|
||||
m := Extract(raw)
|
||||
if m.Host != "node-1" {
|
||||
t.Errorf("host = %q, want node-1 (pod_node_name)", m.Host)
|
||||
}
|
||||
if !m.Ok {
|
||||
t.Fatalf("timestamp not parsed")
|
||||
}
|
||||
if !m.Timestamp.Equal(time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("timestamp = %v", m.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractVMHostAndFallbacks(t *testing.T) {
|
||||
raw := []byte(`{"message":"sshd started","host":"vm-db-1","severity":"info","role":"database","host_token":"vm-db-1"}`)
|
||||
m := Extract(raw)
|
||||
if m.Host != "vm-db-1" {
|
||||
t.Errorf("host = %q, want vm-db-1", m.Host)
|
||||
}
|
||||
if m.Ok {
|
||||
t.Errorf("no timestamp field present; Ok should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHostnameFallback(t *testing.T) {
|
||||
m := Extract([]byte(`{"hostname":"legacy-box","ts":"2026-01-02T03:04:05Z"}`))
|
||||
if m.Host != "legacy-box" {
|
||||
t.Errorf("host = %q, want legacy-box (hostname fallback)", m.Host)
|
||||
}
|
||||
if !m.Ok || !m.Timestamp.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) {
|
||||
t.Errorf("ts fallback failed: ok=%v ts=%v", m.Ok, m.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHostPrecedence(t *testing.T) {
|
||||
// .host wins over .kubernetes.pod_node_name when both present.
|
||||
m := Extract([]byte(`{"host":"explicit","kubernetes":{"pod_node_name":"node-x"}}`))
|
||||
if m.Host != "explicit" {
|
||||
t.Errorf("host precedence wrong: %q", m.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractNumericTimestamp(t *testing.T) {
|
||||
// unix millis
|
||||
m := Extract([]byte(`{"host":"h","timestamp":1769472000000}`))
|
||||
if !m.Ok {
|
||||
t.Fatalf("numeric millis not parsed")
|
||||
}
|
||||
if !m.Timestamp.Equal(time.Date(2026, 1, 27, 0, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("numeric ts = %v", m.Timestamp.UTC())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMalformed(t *testing.T) {
|
||||
m := Extract([]byte(`not json`))
|
||||
if m.Host != "" || m.Ok {
|
||||
t.Errorf("malformed event should yield zero Meta, got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmptyHost(t *testing.T) {
|
||||
m := Extract([]byte(`{"host":"","hostname":"backup"}`))
|
||||
if m.Host != "backup" {
|
||||
t.Errorf("empty host should fall through to hostname, got %q", m.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubjectToken(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"logs.k8s.vault.audit": "logs.k8s.vault.audit",
|
||||
"logs.vm.vm-db-1": "logs.vm.vm-db-1",
|
||||
"logs.k8s.a/b.c": "logs.k8s.a_b.c",
|
||||
"": "_",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := SubjectToken(in); got != want {
|
||||
t.Errorf("SubjectToken(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package index
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CreateDatabaseSQL creates the index database if absent.
|
||||
func CreateDatabaseSQL(database string) string {
|
||||
return fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", database)
|
||||
}
|
||||
|
||||
// CreateTableSQL returns the DDL for the archive index table. One row is written
|
||||
// per archived S3 object. In-cluster the argocd bootstrap Job owns table
|
||||
// creation (like the logging stack's clickhouse-schema PostSync hook); this DDL
|
||||
// is also shipped as schema/archive_index.sql and applied by `logarchiver
|
||||
// init-schema`.
|
||||
//
|
||||
// PARTITION BY month of min_ts keeps partitions coarse (few objects/day).
|
||||
// ORDER BY (subject, min_ts) matches the primary search axes. A bloom_filter
|
||||
// skip index on hosts accelerates host lookups without a per-host column.
|
||||
func CreateTableSQL(database, table string) string {
|
||||
return fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s.%s
|
||||
(
|
||||
object_key String,
|
||||
bucket LowCardinality(String),
|
||||
subject LowCardinality(String),
|
||||
hosts Array(LowCardinality(String)),
|
||||
min_ts DateTime64(3),
|
||||
max_ts DateTime64(3),
|
||||
event_count UInt64,
|
||||
raw_bytes UInt64,
|
||||
stored_bytes UInt64,
|
||||
compression LowCardinality(String),
|
||||
cipher LowCardinality(String),
|
||||
container_format LowCardinality(String),
|
||||
key_name LowCardinality(String),
|
||||
key_fingerprint String,
|
||||
created_at DateTime64(3) DEFAULT now64(3),
|
||||
INDEX idx_hosts hosts TYPE bloom_filter GRANULARITY 1
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
PARTITION BY toYYYYMM(min_ts)
|
||||
ORDER BY (subject, min_ts, object_key)`, database, table)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Package index writes and queries the ClickHouse archive index — one row per
|
||||
// stored S3 object — so operators can answer "which objects hold vault logs
|
||||
// from host X between Y and Z" without scanning S3. The concrete store is
|
||||
// behind the Index interface so the archiver and CLI test against a fake.
|
||||
package index
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
)
|
||||
|
||||
// Row is one archive-index record.
|
||||
type Row struct {
|
||||
ObjectKey string
|
||||
Bucket string
|
||||
Subject string
|
||||
Hosts []string
|
||||
MinTS time.Time
|
||||
MaxTS time.Time
|
||||
EventCount uint64
|
||||
RawBytes uint64
|
||||
StoredBytes uint64
|
||||
Compression string
|
||||
Cipher string
|
||||
ContainerFormat string
|
||||
KeyName string
|
||||
KeyFingerprint string
|
||||
}
|
||||
|
||||
// Result is one row returned by Search (a subset relevant to retrieval).
|
||||
type Result struct {
|
||||
ObjectKey string
|
||||
Bucket string
|
||||
Subject string
|
||||
Hosts []string
|
||||
MinTS time.Time
|
||||
MaxTS time.Time
|
||||
EventCount uint64
|
||||
RawBytes uint64
|
||||
StoredBytes uint64
|
||||
KeyName string
|
||||
KeyFingerprint string
|
||||
}
|
||||
|
||||
// Index is the archive-index surface.
|
||||
type Index interface {
|
||||
Insert(ctx context.Context, row Row) error
|
||||
Search(ctx context.Context, q SearchQuery) ([]Result, error)
|
||||
InitSchema(ctx context.Context) error
|
||||
Ping(ctx context.Context) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Config configures the ClickHouse client.
|
||||
type Config struct {
|
||||
Address string // host:port (native protocol, 9000)
|
||||
Database string
|
||||
Table string
|
||||
Username string
|
||||
Password string
|
||||
TLS bool
|
||||
}
|
||||
|
||||
// ClickHouse is the ClickHouse-backed Index.
|
||||
type ClickHouse struct {
|
||||
conn driver.Conn
|
||||
database string
|
||||
table string
|
||||
}
|
||||
|
||||
// NewClickHouse connects to ClickHouse.
|
||||
func NewClickHouse(ctx context.Context, cfg Config) (*ClickHouse, error) {
|
||||
opts := &clickhouse.Options{
|
||||
Addr: []string{cfg.Address},
|
||||
Auth: clickhouse.Auth{
|
||||
Database: cfg.Database,
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
},
|
||||
}
|
||||
if cfg.TLS {
|
||||
opts.TLS = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
conn, err := clickhouse.Open(opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open clickhouse: %w", err)
|
||||
}
|
||||
ch := &ClickHouse{conn: conn, database: cfg.Database, table: cfg.Table}
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Ping verifies connectivity.
|
||||
func (c *ClickHouse) Ping(ctx context.Context) error {
|
||||
return c.conn.Ping(ctx)
|
||||
}
|
||||
|
||||
// Close closes the connection.
|
||||
func (c *ClickHouse) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
// InitSchema creates the database and table if absent (idempotent).
|
||||
func (c *ClickHouse) InitSchema(ctx context.Context) error {
|
||||
if err := c.conn.Exec(ctx, CreateDatabaseSQL(c.database)); err != nil {
|
||||
return fmt.Errorf("create database: %w", err)
|
||||
}
|
||||
if err := c.conn.Exec(ctx, CreateTableSQL(c.database, c.table)); err != nil {
|
||||
return fmt.Errorf("create table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Insert writes one row.
|
||||
func (c *ClickHouse) Insert(ctx context.Context, row Row) error {
|
||||
sql := fmt.Sprintf(
|
||||
"INSERT INTO %s.%s (object_key, bucket, subject, hosts, min_ts, max_ts, event_count, raw_bytes, stored_bytes, compression, cipher, container_format, key_name, key_fingerprint) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
c.database, c.table)
|
||||
err := c.conn.Exec(ctx, sql,
|
||||
row.ObjectKey, row.Bucket, row.Subject, row.Hosts,
|
||||
row.MinTS, row.MaxTS, row.EventCount, row.RawBytes, row.StoredBytes,
|
||||
row.Compression, row.Cipher, row.ContainerFormat, row.KeyName, row.KeyFingerprint,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert index row: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search runs the parameterized query built from q.
|
||||
func (c *ClickHouse) Search(ctx context.Context, q SearchQuery) ([]Result, error) {
|
||||
sql, args := buildSearchSQL(c.database, c.table, q)
|
||||
rows, err := c.conn.Query(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search index: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []Result
|
||||
for rows.Next() {
|
||||
var r Result
|
||||
if err := rows.Scan(
|
||||
&r.ObjectKey, &r.Bucket, &r.Subject, &r.Hosts,
|
||||
&r.MinTS, &r.MaxTS, &r.EventCount, &r.RawBytes, &r.StoredBytes,
|
||||
&r.KeyName, &r.KeyFingerprint,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan result: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate results: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SearchQuery describes an index search. Zero-valued fields are omitted.
|
||||
type SearchQuery struct {
|
||||
Subject string // NATS-style glob: '*' = one token, '>' = rest. Empty = any.
|
||||
Host string // exact, or a glob containing '*'. Empty = any.
|
||||
From time.Time // objects whose range overlaps [From,To]
|
||||
To time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
// buildSearchSQL renders q into a parameterized ClickHouse SELECT and its args.
|
||||
// It is pure so it can be unit-tested without a database. Placeholders use the
|
||||
// clickhouse-go positional style (?), matching Query(ctx, sql, args...).
|
||||
func buildSearchSQL(database, table string, q SearchQuery) (string, []any) {
|
||||
var (
|
||||
where []string
|
||||
args []any
|
||||
)
|
||||
if q.Subject != "" {
|
||||
where = append(where, "match(subject, ?)")
|
||||
args = append(args, subjectToRegex(q.Subject))
|
||||
}
|
||||
if q.Host != "" {
|
||||
if strings.Contains(q.Host, "*") {
|
||||
where = append(where, "arrayExists(h -> match(h, ?), hosts)")
|
||||
args = append(args, hostGlobToRegex(q.Host))
|
||||
} else {
|
||||
where = append(where, "has(hosts, ?)")
|
||||
args = append(args, q.Host)
|
||||
}
|
||||
}
|
||||
if !q.From.IsZero() {
|
||||
// object overlaps the window if its max_ts is at/after From.
|
||||
where = append(where, "max_ts >= ?")
|
||||
args = append(args, q.From.UTC())
|
||||
}
|
||||
if !q.To.IsZero() {
|
||||
where = append(where, "min_ts <= ?")
|
||||
args = append(args, q.To.UTC())
|
||||
}
|
||||
|
||||
sql := fmt.Sprintf(
|
||||
"SELECT object_key, bucket, subject, hosts, min_ts, max_ts, event_count, raw_bytes, stored_bytes, key_name, key_fingerprint FROM %s.%s",
|
||||
database, table)
|
||||
if len(where) > 0 {
|
||||
sql += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
sql += " ORDER BY min_ts, object_key"
|
||||
if q.Limit > 0 {
|
||||
sql += " LIMIT ?"
|
||||
args = append(args, q.Limit)
|
||||
}
|
||||
return sql, args
|
||||
}
|
||||
|
||||
// subjectToRegex converts a NATS-style subject glob into an anchored regex for
|
||||
// ClickHouse match(). '*' matches exactly one dot-delimited token; '>' (only
|
||||
// meaningful as the final token) matches one or more trailing tokens. Literal
|
||||
// dots and regex metacharacters are escaped.
|
||||
func subjectToRegex(glob string) string {
|
||||
tokens := strings.Split(glob, ".")
|
||||
var parts []string
|
||||
for i, tok := range tokens {
|
||||
switch tok {
|
||||
case "*":
|
||||
parts = append(parts, `[^.]+`)
|
||||
case ">":
|
||||
// '>' consumes the rest; emit and stop.
|
||||
if i == 0 {
|
||||
return "^.+$"
|
||||
}
|
||||
return "^" + strings.Join(parts[:i], `\.`) + `(\..+)?$`
|
||||
default:
|
||||
parts = append(parts, regexEscape(tok))
|
||||
}
|
||||
}
|
||||
return "^" + strings.Join(parts, `\.`) + "$"
|
||||
}
|
||||
|
||||
// hostGlobToRegex converts a host glob (where '*' matches any run of
|
||||
// characters, including dots in an FQDN) into an anchored regex for match().
|
||||
func hostGlobToRegex(glob string) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('^')
|
||||
for _, seg := range strings.Split(glob, "*") {
|
||||
b.WriteString(regexEscape(seg))
|
||||
b.WriteString(".*")
|
||||
}
|
||||
// Trim the trailing ".*" added after the last segment, then anchor.
|
||||
out := strings.TrimSuffix(b.String(), ".*")
|
||||
return out + "$"
|
||||
}
|
||||
|
||||
func regexEscape(s string) string {
|
||||
const meta = `\.+*?()|[]{}^$`
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if strings.ContainsRune(meta, r) {
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSubjectToRegex(t *testing.T) {
|
||||
cases := []struct {
|
||||
glob string
|
||||
match []string
|
||||
nomatch []string
|
||||
}{
|
||||
{
|
||||
glob: "logs.vm.*",
|
||||
match: []string{"logs.vm.db-1", "logs.vm.web"},
|
||||
nomatch: []string{"logs.vm", "logs.vm.db.1", "logs.k8s.x"},
|
||||
},
|
||||
{
|
||||
glob: "logs.k8s.vault.>",
|
||||
match: []string{"logs.k8s.vault.audit", "logs.k8s.vault.a.b", "logs.k8s.vault"},
|
||||
nomatch: []string{"logs.k8s.shop.web", "logs.vm.x"},
|
||||
},
|
||||
{
|
||||
glob: "logs.vm.db-1",
|
||||
match: []string{"logs.vm.db-1"},
|
||||
nomatch: []string{"logs.vm.db-2", "logs.vm.db-1.x"},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
re := regexp.MustCompile(subjectToRegex(c.glob))
|
||||
for _, s := range c.match {
|
||||
if !re.MatchString(s) {
|
||||
t.Errorf("%q -> %q should match %q", c.glob, re.String(), s)
|
||||
}
|
||||
}
|
||||
for _, s := range c.nomatch {
|
||||
if re.MatchString(s) {
|
||||
t.Errorf("%q -> %q should NOT match %q", c.glob, re.String(), s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSearchSQLFull(t *testing.T) {
|
||||
from := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)
|
||||
to := time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
|
||||
sql, args := buildSearchSQL("logs", "archive_index", SearchQuery{
|
||||
Subject: "logs.k8s.vault.>",
|
||||
Host: "node-1",
|
||||
From: from,
|
||||
To: to,
|
||||
Limit: 50,
|
||||
})
|
||||
if !strings.Contains(sql, "FROM logs.archive_index") {
|
||||
t.Errorf("missing table: %s", sql)
|
||||
}
|
||||
for _, want := range []string{"match(subject, ?)", "has(hosts, ?)", "max_ts >= ?", "min_ts <= ?", "ORDER BY min_ts", "LIMIT ?"} {
|
||||
if !strings.Contains(sql, want) {
|
||||
t.Errorf("sql missing %q: %s", want, sql)
|
||||
}
|
||||
}
|
||||
if len(args) != 5 {
|
||||
t.Fatalf("args = %d, want 5: %v", len(args), args)
|
||||
}
|
||||
if args[1] != "node-1" {
|
||||
t.Errorf("host arg = %v", args[1])
|
||||
}
|
||||
if args[4] != 50 {
|
||||
t.Errorf("limit arg = %v", args[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSearchSQLEmpty(t *testing.T) {
|
||||
sql, args := buildSearchSQL("logs", "archive_index", SearchQuery{})
|
||||
if strings.Contains(sql, "WHERE") {
|
||||
t.Errorf("empty query should have no WHERE: %s", sql)
|
||||
}
|
||||
if len(args) != 0 {
|
||||
t.Errorf("args = %v, want none", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSearchSQLHostGlob(t *testing.T) {
|
||||
sql, args := buildSearchSQL("logs", "archive_index", SearchQuery{Host: "db-*"})
|
||||
if !strings.Contains(sql, "arrayExists(h -> match(h, ?), hosts)") {
|
||||
t.Errorf("host glob should use arrayExists/match: %s", sql)
|
||||
}
|
||||
if len(args) != 1 {
|
||||
t.Fatalf("args = %v", args)
|
||||
}
|
||||
re := regexp.MustCompile(args[0].(string))
|
||||
if !re.MatchString("db-1") || re.MatchString("web-1") {
|
||||
t.Errorf("host glob regex wrong: %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDDLContainsKeyColumns(t *testing.T) {
|
||||
ddl := CreateTableSQL("logs", "archive_index")
|
||||
for _, col := range []string{"object_key", "subject", "hosts", "min_ts", "max_ts", "event_count", "key_fingerprint", "bloom_filter"} {
|
||||
if !strings.Contains(ddl, col) {
|
||||
t.Errorf("DDL missing %q", col)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(ddl, "IF NOT EXISTS") {
|
||||
t.Errorf("DDL should be idempotent")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Package metrics exposes Prometheus metrics for the logarchiver service. The
|
||||
// logging stack ships no metrics convention today, so this is the first
|
||||
// /metrics endpoint there; it is opt-in via config.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
// Metrics holds the collectors.
|
||||
type Metrics struct {
|
||||
objectsStored *prometheus.CounterVec
|
||||
eventsArchived *prometheus.CounterVec
|
||||
rawBytes *prometheus.CounterVec
|
||||
storedBytes *prometheus.CounterVec
|
||||
storeFailures *prometheus.CounterVec
|
||||
indexFailures *prometheus.CounterVec
|
||||
messagesFetched prometheus.Counter
|
||||
acksSent prometheus.Counter
|
||||
batchesFlushed *prometheus.CounterVec
|
||||
pendingEvents prometheus.Gauge
|
||||
}
|
||||
|
||||
// New registers the collectors on reg (use prometheus.DefaultRegisterer for the
|
||||
// default /metrics handler).
|
||||
func New(reg prometheus.Registerer) *Metrics {
|
||||
f := promauto.With(reg)
|
||||
return &Metrics{
|
||||
objectsStored: f.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "logarchiver_objects_stored_total",
|
||||
Help: "Objects successfully sealed, uploaded and indexed.",
|
||||
}, []string{"subject"}),
|
||||
eventsArchived: f.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "logarchiver_events_archived_total",
|
||||
Help: "Log events archived.",
|
||||
}, []string{"subject"}),
|
||||
rawBytes: f.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "logarchiver_raw_bytes_total",
|
||||
Help: "Raw NDJSON bytes archived (pre-compression).",
|
||||
}, []string{"subject"}),
|
||||
storedBytes: f.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "logarchiver_stored_bytes_total",
|
||||
Help: "Stored object bytes written to S3 (post-compression/encryption).",
|
||||
}, []string{"subject"}),
|
||||
storeFailures: f.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "logarchiver_store_failures_total",
|
||||
Help: "Failed store attempts (seal/upload); batch not acked.",
|
||||
}, []string{"subject"}),
|
||||
indexFailures: f.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "logarchiver_index_failures_total",
|
||||
Help: "Failed index writes after a successful S3 upload; batch not acked.",
|
||||
}, []string{"subject"}),
|
||||
messagesFetched: f.NewCounter(prometheus.CounterOpts{
|
||||
Name: "logarchiver_messages_fetched_total",
|
||||
Help: "JetStream messages fetched.",
|
||||
}),
|
||||
acksSent: f.NewCounter(prometheus.CounterOpts{
|
||||
Name: "logarchiver_acks_total",
|
||||
Help: "JetStream acknowledgements sent (after successful persist).",
|
||||
}),
|
||||
batchesFlushed: f.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "logarchiver_batches_flushed_total",
|
||||
Help: "Batches flushed, labelled by trigger (full|age|shutdown).",
|
||||
}, []string{"trigger"}),
|
||||
pendingEvents: f.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "logarchiver_pending_events",
|
||||
Help: "Events currently buffered in open batches (unacked).",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ObjectStored records a successful persist.
|
||||
func (m *Metrics) ObjectStored(subject string, events int, rawBytes, storedBytes int64) {
|
||||
m.objectsStored.WithLabelValues(subject).Inc()
|
||||
m.eventsArchived.WithLabelValues(subject).Add(float64(events))
|
||||
m.rawBytes.WithLabelValues(subject).Add(float64(rawBytes))
|
||||
m.storedBytes.WithLabelValues(subject).Add(float64(storedBytes))
|
||||
}
|
||||
|
||||
// StoreFailed records a seal/upload failure.
|
||||
func (m *Metrics) StoreFailed(subject string) { m.storeFailures.WithLabelValues(subject).Inc() }
|
||||
|
||||
// IndexFailed records an index-write failure.
|
||||
func (m *Metrics) IndexFailed(subject string) { m.indexFailures.WithLabelValues(subject).Inc() }
|
||||
|
||||
// MessagesFetched records fetched messages.
|
||||
func (m *Metrics) MessagesFetched(n int) { m.messagesFetched.Add(float64(n)) }
|
||||
|
||||
// Acked records sent acknowledgements.
|
||||
func (m *Metrics) Acked(n int) { m.acksSent.Add(float64(n)) }
|
||||
|
||||
// BatchFlushed records a flush by trigger.
|
||||
func (m *Metrics) BatchFlushed(trigger string) { m.batchesFlushed.WithLabelValues(trigger).Inc() }
|
||||
|
||||
// SetPending sets the pending-events gauge.
|
||||
func (m *Metrics) SetPending(n int) { m.pendingEvents.Set(float64(n)) }
|
||||
@@ -0,0 +1,140 @@
|
||||
// Package s3store wraps object storage (Ceph RGW via the S3 API) behind an
|
||||
// interface so the archiver and CLI can be tested with a fake. Credentials come
|
||||
// from the standard AWS_* environment (the cephrgw BucketAccess secret
|
||||
// logs-archive-s3); this package only wires the custom endpoint, path-style
|
||||
// addressing, and the internal Vault-PKI CA needed for s3.ceph.unkin.net.
|
||||
package s3store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
)
|
||||
|
||||
// ObjectStore is the minimal object-storage surface logarchiver needs.
|
||||
type ObjectStore interface {
|
||||
Put(ctx context.Context, key string, body io.Reader, size int64) error
|
||||
Get(ctx context.Context, key string) (io.ReadCloser, error)
|
||||
List(ctx context.Context, prefix string) ([]string, error)
|
||||
Bucket() string
|
||||
}
|
||||
|
||||
// Config configures the S3 client.
|
||||
type Config struct {
|
||||
Endpoint string
|
||||
Bucket string
|
||||
Region string
|
||||
PathStyle bool
|
||||
CAFile string
|
||||
}
|
||||
|
||||
// Store is the S3-backed ObjectStore.
|
||||
type Store struct {
|
||||
client *s3.Client
|
||||
bucket string
|
||||
}
|
||||
|
||||
// New builds a Store, trusting CAFile (in addition to the system roots) when set.
|
||||
func New(ctx context.Context, cfg Config) (*Store, error) {
|
||||
httpClient, err := httpClientWithCA(cfg.CAFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
region := cfg.Region
|
||||
if region == "" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
awsCfg, err := awsconfig.LoadDefaultConfig(ctx,
|
||||
awsconfig.WithRegion(region),
|
||||
awsconfig.WithHTTPClient(httpClient),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load aws config: %w", err)
|
||||
}
|
||||
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
||||
if cfg.Endpoint != "" {
|
||||
o.BaseEndpoint = &cfg.Endpoint
|
||||
}
|
||||
o.UsePathStyle = cfg.PathStyle
|
||||
})
|
||||
return &Store{client: client, bucket: cfg.Bucket}, nil
|
||||
}
|
||||
|
||||
// Bucket returns the configured bucket name.
|
||||
func (s *Store) Bucket() string { return s.bucket }
|
||||
|
||||
// Put uploads body of the given size under key.
|
||||
func (s *Store) Put(ctx context.Context, key string, body io.Reader, size int64) error {
|
||||
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &s.bucket,
|
||||
Key: &key,
|
||||
Body: body,
|
||||
ContentLength: &size,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("put s3://%s/%s: %w", s.bucket, key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get streams the object at key.
|
||||
func (s *Store) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &s.bucket,
|
||||
Key: &key,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get s3://%s/%s: %w", s.bucket, key, err)
|
||||
}
|
||||
return out.Body, nil
|
||||
}
|
||||
|
||||
// List returns object keys under prefix (paginated).
|
||||
func (s *Store) List(ctx context.Context, prefix string) ([]string, error) {
|
||||
var keys []string
|
||||
p := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{
|
||||
Bucket: &s.bucket,
|
||||
Prefix: &prefix,
|
||||
})
|
||||
for p.HasMorePages() {
|
||||
page, err := p.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list s3://%s/%s: %w", s.bucket, prefix, err)
|
||||
}
|
||||
for _, obj := range page.Contents {
|
||||
if obj.Key != nil {
|
||||
keys = append(keys, *obj.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func httpClientWithCA(caFile string) (*http.Client, error) {
|
||||
if caFile == "" {
|
||||
return http.DefaultClient, nil
|
||||
}
|
||||
pem, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read s3 ca file %s: %w", caFile, err)
|
||||
}
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil || pool == nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("no certificates parsed from %s", caFile)
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Package vaultgpg is a thin client for Ben's Vault GPG secrets engine
|
||||
// (vault-plugin-secrets-gpg), used two ways:
|
||||
//
|
||||
// - the service reads the ARMORED PUBLIC key (GET <mount>/keys/<name>) to
|
||||
// encrypt objects locally; and
|
||||
// - the CLI decrypts a wrapped DEK (POST <mount>/decrypt/<name>) to retrieve
|
||||
// objects. The engine does whole-payload decrypt only, but logarchiver only
|
||||
// ever sends it the tiny wrapped-DEK blob, so that is a non-issue.
|
||||
//
|
||||
// Auth mirrors passv: ambient VAULT_* env (token or ~/.vault-token) for humans,
|
||||
// or the kubernetes auth method for the in-cluster service.
|
||||
package vaultgpg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
vault "github.com/hashicorp/vault/api"
|
||||
)
|
||||
|
||||
// Config configures the Vault client.
|
||||
type Config struct {
|
||||
Address string
|
||||
Mount string // e.g. "gpg"
|
||||
AuthMethod string // "token" | "kubernetes"
|
||||
K8sRole string
|
||||
K8sMount string // e.g. "k8s/au/syd1"
|
||||
K8sJWTPath string
|
||||
CAFile string
|
||||
}
|
||||
|
||||
// Client wraps the Vault API client for GPG-engine operations.
|
||||
type Client struct {
|
||||
api *vault.Client
|
||||
mount string
|
||||
}
|
||||
|
||||
// New builds a Client and authenticates per cfg.AuthMethod.
|
||||
func New(ctx context.Context, cfg Config) (*Client, error) {
|
||||
vc := vault.DefaultConfig()
|
||||
if err := vc.ReadEnvironment(); err != nil {
|
||||
return nil, fmt.Errorf("read vault env: %w", err)
|
||||
}
|
||||
if cfg.Address != "" {
|
||||
vc.Address = cfg.Address
|
||||
}
|
||||
if cfg.CAFile != "" {
|
||||
if err := vc.ConfigureTLS(&vault.TLSConfig{CACert: cfg.CAFile}); err != nil {
|
||||
return nil, fmt.Errorf("configure vault tls: %w", err)
|
||||
}
|
||||
}
|
||||
api, err := vault.NewClient(vc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new vault client: %w", err)
|
||||
}
|
||||
mount := cfg.Mount
|
||||
if mount == "" {
|
||||
mount = "gpg"
|
||||
}
|
||||
c := &Client{api: api, mount: strings.Trim(mount, "/")}
|
||||
|
||||
switch cfg.AuthMethod {
|
||||
case "", "token":
|
||||
if api.Token() == "" {
|
||||
tok, err := resolveToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
api.SetToken(tok)
|
||||
}
|
||||
case "kubernetes":
|
||||
if err := c.k8sLogin(ctx, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown vault auth_method %q", cfg.AuthMethod)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Client) k8sLogin(ctx context.Context, cfg Config) error {
|
||||
jwtPath := cfg.K8sJWTPath
|
||||
if jwtPath == "" {
|
||||
jwtPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
||||
}
|
||||
jwt, err := os.ReadFile(jwtPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read service account token: %w", err)
|
||||
}
|
||||
mount := cfg.K8sMount
|
||||
if mount == "" {
|
||||
mount = "kubernetes"
|
||||
}
|
||||
path := fmt.Sprintf("auth/%s/login", strings.Trim(mount, "/"))
|
||||
secret, err := c.api.Logical().WriteWithContext(ctx, path, map[string]any{
|
||||
"role": cfg.K8sRole,
|
||||
"jwt": strings.TrimSpace(string(jwt)),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("kubernetes login: %w", err)
|
||||
}
|
||||
if secret == nil || secret.Auth == nil || secret.Auth.ClientToken == "" {
|
||||
return fmt.Errorf("kubernetes login returned no token")
|
||||
}
|
||||
c.api.SetToken(secret.Auth.ClientToken)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveToken mirrors passv: VAULT_TOKEN, else ~/.vault-token.
|
||||
func resolveToken() (string, error) {
|
||||
if t := os.Getenv("VAULT_TOKEN"); t != "" {
|
||||
return t, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no VAULT_TOKEN and cannot find home dir: %w", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(home, ".vault-token"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no VAULT_TOKEN and no ~/.vault-token: %w", err)
|
||||
}
|
||||
tok := strings.TrimSpace(string(data))
|
||||
if tok == "" {
|
||||
return "", fmt.Errorf("~/.vault-token is empty")
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// PublicKey is the result of reading a GPG engine key.
|
||||
type PublicKey struct {
|
||||
Armored string
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
// FetchPublicKey reads <mount>/keys/<name> and returns the latest armored public
|
||||
// key and its fingerprint.
|
||||
func (c *Client) FetchPublicKey(ctx context.Context, name string) (PublicKey, error) {
|
||||
path := fmt.Sprintf("%s/keys/%s", c.mount, name)
|
||||
secret, err := c.api.Logical().ReadWithContext(ctx, path)
|
||||
if err != nil {
|
||||
return PublicKey{}, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
if secret == nil || secret.Data == nil {
|
||||
return PublicKey{}, fmt.Errorf("key %q not found at %s", name, path)
|
||||
}
|
||||
pub, _ := secret.Data["public_key"].(string)
|
||||
if pub == "" {
|
||||
return PublicKey{}, fmt.Errorf("key %q has no public_key field", name)
|
||||
}
|
||||
fpr, _ := secret.Data["fingerprint"].(string)
|
||||
return PublicKey{Armored: pub, Fingerprint: fpr}, nil
|
||||
}
|
||||
|
||||
// Decrypt sends ciphertext (raw binary OpenPGP) to <mount>/decrypt/<name> and
|
||||
// returns the plaintext. The engine auto-detects binary vs armored; we send
|
||||
// base64 of the raw bytes, as passv does.
|
||||
func (c *Client) Decrypt(ctx context.Context, name string, ciphertext []byte) ([]byte, error) {
|
||||
path := fmt.Sprintf("%s/decrypt/%s", c.mount, name)
|
||||
secret, err := c.api.Logical().WriteWithContext(ctx, path, map[string]any{
|
||||
"ciphertext": base64.StdEncoding.EncodeToString(ciphertext),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt via %s: %w", path, err)
|
||||
}
|
||||
if secret == nil || secret.Data == nil {
|
||||
return nil, fmt.Errorf("decrypt returned no data")
|
||||
}
|
||||
b64, _ := secret.Data["plaintext"].(string)
|
||||
if b64 == "" {
|
||||
return nil, fmt.Errorf("decrypt returned no plaintext")
|
||||
}
|
||||
plain, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode plaintext: %w", err)
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
// TokenTTL returns the remaining lease TTL of the current token, for diagnostics.
|
||||
func (c *Client) TokenTTL(ctx context.Context) (time.Duration, error) {
|
||||
secret, err := c.api.Auth().Token().LookupSelfWithContext(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ttl, err := secret.TokenTTL()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return ttl, nil
|
||||
}
|
||||
Reference in New Issue
Block a user