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,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
|
||||
}
|
||||
Reference in New Issue
Block a user