5ec89b0028
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
186 lines
5.2 KiB
Go
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:]
|
|
}
|