Files
logarchiver/internal/cli/run.go
T
benvin c05ccfcb5d
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
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
2026-07-27 23:22:40 +10:00

253 lines
6.3 KiB
Go

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,
}
}