c05ccfcb5d
logarchiver replaces the plain Vector archiver leg of the centralized logging stack (argocd-apps #296) with a Go service that archives raw logs from NATS JetStream to S3 as zstd-compressed, OpenPGP-encrypted, indexed objects, plus an operator CLI to search the index and retrieve/decrypt archived logs. It adds the things that outgrew Vector: zstd compression, encryption keyed from Ben's Vault GPG secrets engine, a searchable ClickHouse index, and sink-conditional acks (a batch is acknowledged to JetStream only after the object is durably in S3 AND indexed). Service (`logarchiver run`): - Durable JetStream pull consumer (stream LOGS, durable archiver, subject filter default logs.k8s.vault.>), explicit acks, independent offsets. - Batch per subject by size/count/time -> NDJSON -> zstd -> encrypt -> S3 PUT -> ClickHouse index row -> ack. On any failure the batch is Nak'd and redelivered, so nothing is lost on a sink outage. - Encryption is a wrapped-DEK envelope (container LARC1): the bulk is AES-256-GCM framed under a random data key, and only that 32-byte key is OpenPGP-encrypted to the engine's public key. This is because the Vault GPG engine does whole-payload decrypt only; retrieval round-trips just the tiny wrapped key regardless of object size. Public key fetched from the engine or a mounted file (configurable); key fingerprint recorded per object; periodic pubkey refresh for rotation. - Prometheus metrics, structured slog, graceful drain on shutdown. CLI: - `search` queries the index (subject/host/time) and lists matching objects. - `fetch` downloads, decrypts via the Vault GPG engine, unzstds and emits NDJSON (optionally re-filtered by host/time). - `init-schema` creates/prints the ClickHouse archive_index DDL. - cobra `completion` subcommands. Config via file+env (k8s-friendly, secrets from env), boundaries (NATS/S3/ ClickHouse/Vault) behind interfaces with unit tests (config, batching, host/subject extraction, crypto roundtrip with a test key, ack-after-persist with fakes, search query building). go build/vet/test -race clean; golangci-lint v2 clean. Woodpecker CI: build/test/pre-commit on PR; on v* tag a container image plus a Gitea binary release + rpm-internal RPM. Docs per subcommand + architecture + retrieval runbook + deployment drop-in. Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
223 lines
6.1 KiB
Go
223 lines
6.1 KiB
Go
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")
|
|
}
|
|
}
|