Files
logarchiver/internal/cli/fetch.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

186 lines
5.2 KiB
Go

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:]
}