Initial implementation: NATS->S3 archiver + search/retrieve CLI
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

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:
benvin
2026-07-27 22:11:54 +10:00
committed by Ben Vincent
parent d036d31f12
commit c05ccfcb5d
58 changed files with 5946 additions and 1 deletions
+90
View File
@@ -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])
}
+185
View File
@@ -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:]
}
+123
View File
@@ -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])
}
+116
View File
@@ -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)
}
}
+45
View File
@@ -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
}
+95
View File
@@ -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)
}
+252
View File
@@ -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,
}
}
+67
View File
@@ -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
}