c05ccfcb5d
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
380 lines
12 KiB
Go
380 lines
12 KiB
Go
// Package config defines logarchiver's configuration and loads it from a YAML
|
|
// file with environment-variable overrides, so the same binary is
|
|
// k8s-friendly (env/secret-driven) and laptop-friendly (a config file).
|
|
//
|
|
// Precedence: built-in defaults < YAML file < environment variables.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Config is the full logarchiver configuration.
|
|
type Config struct {
|
|
NATS NATSConfig `yaml:"nats"`
|
|
Batch BatchConfig `yaml:"batch"`
|
|
S3 S3Config `yaml:"s3"`
|
|
Crypto CryptoConfig `yaml:"crypto"`
|
|
Index IndexConfig `yaml:"index"`
|
|
Metrics MetricsConfig `yaml:"metrics"`
|
|
Log LogConfig `yaml:"log"`
|
|
}
|
|
|
|
// NATSConfig configures the JetStream pull consumer that logarchiver binds. It
|
|
// mirrors the logging stack's `LOGS` stream / `archiver` durable / `log-consumer`
|
|
// user conventions (argocd-apps #296).
|
|
type NATSConfig struct {
|
|
URL string `yaml:"url"`
|
|
Stream string `yaml:"stream"`
|
|
Durable string `yaml:"durable"`
|
|
Subjects []string `yaml:"subjects"`
|
|
User string `yaml:"user"`
|
|
// Password is the NATS user password. In-cluster it comes from the
|
|
// nats-auth secret via NATS_CONSUMER_PASSWORD (see PasswordEnv).
|
|
Password string `yaml:"password"`
|
|
// PasswordEnv names the env var holding the password when Password is empty.
|
|
PasswordEnv string `yaml:"password_env"`
|
|
// CAFile trusts a custom CA for TLS to NATS (usually unset; in-cluster is plaintext).
|
|
CAFile string `yaml:"ca_file"`
|
|
// FetchBatch is the max messages pulled per Fetch call.
|
|
FetchBatch int `yaml:"fetch_batch"`
|
|
// AckWait is the JetStream redelivery timeout; must exceed a worst-case
|
|
// batch flush (compress+encrypt+S3 PUT+index write).
|
|
AckWait time.Duration `yaml:"ack_wait"`
|
|
}
|
|
|
|
// BatchConfig bounds a single archived object. A per-subject batch is flushed
|
|
// when any bound is hit. Keep MaxBytes well under the crypto/engine ceiling so
|
|
// even a whole-object decrypt path stays viable; the wrapped-DEK envelope means
|
|
// object size is not limited by Vault, but smaller objects retrieve faster.
|
|
type BatchConfig struct {
|
|
MaxBytes int64 `yaml:"max_bytes"`
|
|
MaxEvents int `yaml:"max_events"`
|
|
MaxAge time.Duration `yaml:"max_age"`
|
|
}
|
|
|
|
// S3Config targets the Ceph RGW bucket. Credentials are read from the standard
|
|
// AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env vars (cephrgw BucketAccess
|
|
// secret logs-archive-s3), so they are intentionally absent here.
|
|
type S3Config struct {
|
|
Endpoint string `yaml:"endpoint"`
|
|
Bucket string `yaml:"bucket"`
|
|
Region string `yaml:"region"`
|
|
PathStyle bool `yaml:"path_style"`
|
|
// KeyPrefix is a text/template with {{.Subject}} {{.Year}} {{.Month}} {{.Day}}.
|
|
KeyPrefix string `yaml:"key_prefix"`
|
|
// CAFile trusts the internal Vault-PKI CA for https://s3.ceph.unkin.net.
|
|
CAFile string `yaml:"ca_file"`
|
|
// EndpointEnv / BucketEnv let the cephrgw secret (S3_ENDPOINT / BUCKET_NAME)
|
|
// override endpoint/bucket without a config edit.
|
|
EndpointEnv string `yaml:"endpoint_env"`
|
|
BucketEnv string `yaml:"bucket_env"`
|
|
}
|
|
|
|
// PubkeySource selects where the OpenPGP public key is fetched from.
|
|
type PubkeySource string
|
|
|
|
const (
|
|
PubkeyVault PubkeySource = "vault" // read gpg/keys/<name> from the Vault GPG engine
|
|
PubkeyFile PubkeySource = "file" // read an armored public key from a mounted file
|
|
)
|
|
|
|
// CryptoConfig controls encryption. The service only ever needs the PUBLIC key;
|
|
// decryption (CLI fetch) always goes through the Vault GPG engine.
|
|
type CryptoConfig struct {
|
|
KeyName string `yaml:"key_name"`
|
|
Source PubkeySource `yaml:"pubkey_source"`
|
|
// PubkeyFile is the armored public key path when Source==file.
|
|
PubkeyFile string `yaml:"pubkey_file"`
|
|
// RefreshInterval re-fetches the public key periodically (rotation aware).
|
|
RefreshInterval time.Duration `yaml:"refresh_interval"`
|
|
// FrameSize is the AES-GCM frame plaintext size in bytes (streaming decrypt).
|
|
FrameSize int `yaml:"frame_size"`
|
|
Vault VaultConfig `yaml:"vault"`
|
|
}
|
|
|
|
// VaultConfig configures access to the Vault GPG secrets engine. For the
|
|
// service (pubkey fetch) k8s auth is used in-cluster; the CLI relies on the
|
|
// operator's ambient VAULT_TOKEN (~/.vault-token), like passv.
|
|
type VaultConfig struct {
|
|
Address string `yaml:"address"`
|
|
// Mount is the GPG engine mount path (e.g. "gpg").
|
|
Mount string `yaml:"mount"`
|
|
// AuthMethod is "token" or "kubernetes".
|
|
AuthMethod string `yaml:"auth_method"`
|
|
// K8sRole / K8sMount / K8sJWTPath configure kubernetes auth.
|
|
K8sRole string `yaml:"k8s_role"`
|
|
K8sMount string `yaml:"k8s_mount"`
|
|
K8sJWTPath string `yaml:"k8s_jwt_path"`
|
|
CAFile string `yaml:"ca_file"`
|
|
}
|
|
|
|
// IndexConfig targets the ClickHouse archive index. Credentials come from the
|
|
// clickhouse-credentials secret via env by default.
|
|
type IndexConfig struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
Address string `yaml:"address"` // host:port for the native protocol (9000)
|
|
Database string `yaml:"database"`
|
|
Table string `yaml:"table"`
|
|
Username string `yaml:"username"`
|
|
Password string `yaml:"password"`
|
|
PasswordEnv string `yaml:"password_env"`
|
|
TLS bool `yaml:"tls"`
|
|
}
|
|
|
|
// MetricsConfig configures the Prometheus /metrics listener.
|
|
type MetricsConfig struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
Address string `yaml:"address"`
|
|
}
|
|
|
|
// LogConfig configures structured logging.
|
|
type LogConfig struct {
|
|
Level string `yaml:"level"` // debug|info|warn|error
|
|
Format string `yaml:"format"` // json|text
|
|
}
|
|
|
|
// Default returns a Config pre-populated with the logging-stack conventions so
|
|
// an in-cluster deployment needs only secrets (creds) supplied via env.
|
|
func Default() Config {
|
|
return Config{
|
|
NATS: NATSConfig{
|
|
URL: "nats://nats.logging.svc.cluster.local:4222",
|
|
Stream: "LOGS",
|
|
Durable: "archiver",
|
|
Subjects: []string{"logs.k8s.vault.>"},
|
|
User: "log-consumer",
|
|
PasswordEnv: "NATS_CONSUMER_PASSWORD",
|
|
FetchBatch: 512,
|
|
AckWait: 2 * time.Minute,
|
|
},
|
|
Batch: BatchConfig{
|
|
MaxBytes: 64 * 1024 * 1024, // 64 MiB raw NDJSON per object
|
|
MaxEvents: 200000,
|
|
MaxAge: 5 * time.Minute,
|
|
},
|
|
S3: S3Config{
|
|
Endpoint: "https://s3.ceph.unkin.net",
|
|
Bucket: "logs-archive",
|
|
Region: "us-east-1",
|
|
PathStyle: true,
|
|
KeyPrefix: "archive/{{.Subject}}/{{.Year}}/{{.Month}}/{{.Day}}/",
|
|
CAFile: "/etc/vault-ca/ca.crt",
|
|
EndpointEnv: "S3_ENDPOINT",
|
|
BucketEnv: "BUCKET_NAME",
|
|
},
|
|
Crypto: CryptoConfig{
|
|
KeyName: "logarchive",
|
|
Source: PubkeyFile,
|
|
PubkeyFile: "/etc/logarchiver/pubkey.asc",
|
|
RefreshInterval: time.Hour,
|
|
FrameSize: 1 << 20, // 1 MiB frames
|
|
Vault: VaultConfig{
|
|
Mount: "gpg",
|
|
AuthMethod: "kubernetes",
|
|
K8sMount: "k8s/au/syd1",
|
|
K8sRole: "default",
|
|
K8sJWTPath: "/var/run/secrets/kubernetes.io/serviceaccount/token",
|
|
},
|
|
},
|
|
Index: IndexConfig{
|
|
Enabled: true,
|
|
Address: "clickhouse-logs.logging.svc.cluster.local:9000",
|
|
Database: "logs",
|
|
Table: "archive_index",
|
|
Username: "vector",
|
|
PasswordEnv: "CLICKHOUSE_PASSWORD",
|
|
TLS: false,
|
|
},
|
|
Metrics: MetricsConfig{Enabled: true, Address: ":9090"},
|
|
Log: LogConfig{Level: "info", Format: "json"},
|
|
}
|
|
}
|
|
|
|
// Load reads defaults, overlays the YAML file at path (if non-empty), then
|
|
// applies environment overrides, and validates the result.
|
|
func Load(path string) (Config, error) {
|
|
cfg := Default()
|
|
if path != "" {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("read config %s: %w", path, err)
|
|
}
|
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
return Config{}, fmt.Errorf("parse config %s: %w", path, err)
|
|
}
|
|
}
|
|
cfg.applyEnv()
|
|
cfg.resolveSecretEnvs()
|
|
if err := cfg.Validate(); err != nil {
|
|
return Config{}, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// applyEnv overlays scalar overrides from the environment. Only the knobs an
|
|
// operator commonly flips are wired; secrets are handled by resolveSecretEnvs.
|
|
func (c *Config) applyEnv() {
|
|
if v := os.Getenv("LOGARCHIVER_NATS_URL"); v != "" {
|
|
c.NATS.URL = v
|
|
}
|
|
if v := os.Getenv("LOGARCHIVER_NATS_DURABLE"); v != "" {
|
|
c.NATS.Durable = v
|
|
}
|
|
if v := os.Getenv("ARCHIVE_SUBJECTS"); v != "" {
|
|
c.NATS.Subjects = splitFields(v)
|
|
}
|
|
if v := os.Getenv("LOGARCHIVER_S3_ENDPOINT"); v != "" {
|
|
c.S3.Endpoint = v
|
|
}
|
|
if v := os.Getenv("LOGARCHIVER_S3_BUCKET"); v != "" {
|
|
c.S3.Bucket = v
|
|
}
|
|
if v := os.Getenv("LOGARCHIVER_KEY_NAME"); v != "" {
|
|
c.Crypto.KeyName = v
|
|
}
|
|
if v := os.Getenv("LOGARCHIVER_PUBKEY_SOURCE"); v != "" {
|
|
c.Crypto.Source = PubkeySource(v)
|
|
}
|
|
if v := os.Getenv("LOGARCHIVER_PUBKEY_FILE"); v != "" {
|
|
c.Crypto.PubkeyFile = v
|
|
}
|
|
if v := os.Getenv("VAULT_ADDR"); v != "" && c.Crypto.Vault.Address == "" {
|
|
c.Crypto.Vault.Address = v
|
|
}
|
|
if v := os.Getenv("LOGARCHIVER_VAULT_MOUNT"); v != "" {
|
|
c.Crypto.Vault.Mount = v
|
|
}
|
|
if v := os.Getenv("LOGARCHIVER_CLICKHOUSE_ADDR"); v != "" {
|
|
c.Index.Address = v
|
|
}
|
|
if v := os.Getenv("CLICKHOUSE_USER"); v != "" {
|
|
c.Index.Username = v
|
|
}
|
|
if v := os.Getenv("LOGARCHIVER_METRICS_ADDR"); v != "" {
|
|
c.Metrics.Address = v
|
|
}
|
|
if v := os.Getenv("LOGARCHIVER_LOG_LEVEL"); v != "" {
|
|
c.Log.Level = v
|
|
}
|
|
if v := os.Getenv("LOGARCHIVER_LOG_FORMAT"); v != "" {
|
|
c.Log.Format = v
|
|
}
|
|
// Endpoint/bucket sourced from the cephrgw secret, if present.
|
|
if c.S3.EndpointEnv != "" {
|
|
if v := os.Getenv(c.S3.EndpointEnv); v != "" {
|
|
c.S3.Endpoint = v
|
|
}
|
|
}
|
|
if c.S3.BucketEnv != "" {
|
|
if v := os.Getenv(c.S3.BucketEnv); v != "" {
|
|
c.S3.Bucket = v
|
|
}
|
|
}
|
|
}
|
|
|
|
// resolveSecretEnvs pulls passwords from their named env vars when not set inline.
|
|
func (c *Config) resolveSecretEnvs() {
|
|
if c.NATS.Password == "" && c.NATS.PasswordEnv != "" {
|
|
c.NATS.Password = os.Getenv(c.NATS.PasswordEnv)
|
|
}
|
|
if c.Index.Password == "" && c.Index.PasswordEnv != "" {
|
|
c.Index.Password = os.Getenv(c.Index.PasswordEnv)
|
|
}
|
|
}
|
|
|
|
// Validate checks required fields and coherence.
|
|
func (c *Config) Validate() error {
|
|
if c.NATS.URL == "" {
|
|
return fmt.Errorf("nats.url is required")
|
|
}
|
|
if c.NATS.Stream == "" {
|
|
return fmt.Errorf("nats.stream is required")
|
|
}
|
|
if c.NATS.Durable == "" {
|
|
return fmt.Errorf("nats.durable is required")
|
|
}
|
|
if len(c.NATS.Subjects) == 0 {
|
|
return fmt.Errorf("nats.subjects must list at least one filter subject")
|
|
}
|
|
if c.S3.Bucket == "" {
|
|
return fmt.Errorf("s3.bucket is required")
|
|
}
|
|
if c.S3.Endpoint == "" {
|
|
return fmt.Errorf("s3.endpoint is required")
|
|
}
|
|
if c.Crypto.KeyName == "" {
|
|
return fmt.Errorf("crypto.key_name is required")
|
|
}
|
|
switch c.Crypto.Source {
|
|
case PubkeyVault:
|
|
if c.Crypto.Vault.Address == "" {
|
|
return fmt.Errorf("crypto.vault.address is required when pubkey_source=vault")
|
|
}
|
|
if c.Crypto.Vault.Mount == "" {
|
|
return fmt.Errorf("crypto.vault.mount is required when pubkey_source=vault")
|
|
}
|
|
case PubkeyFile:
|
|
if c.Crypto.PubkeyFile == "" {
|
|
return fmt.Errorf("crypto.pubkey_file is required when pubkey_source=file")
|
|
}
|
|
default:
|
|
return fmt.Errorf("crypto.pubkey_source must be 'vault' or 'file', got %q", c.Crypto.Source)
|
|
}
|
|
if c.Crypto.FrameSize <= 0 {
|
|
return fmt.Errorf("crypto.frame_size must be positive")
|
|
}
|
|
if c.Batch.MaxBytes <= 0 && c.Batch.MaxEvents <= 0 && c.Batch.MaxAge <= 0 {
|
|
return fmt.Errorf("batch must set at least one of max_bytes/max_events/max_age")
|
|
}
|
|
if c.Index.Enabled && c.Index.Address == "" {
|
|
return fmt.Errorf("index.address is required when index.enabled")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func splitFields(s string) []string {
|
|
var out []string
|
|
for _, f := range strings.Fields(s) {
|
|
if f != "" {
|
|
out = append(out, f)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ParseSize parses a byte size like "64Mi", "128MB", "1024". It is a helper for
|
|
// CLI flags; the YAML fields are plain integers.
|
|
func ParseSize(s string) (int64, error) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return 0, fmt.Errorf("empty size")
|
|
}
|
|
mult := int64(1)
|
|
switch {
|
|
case strings.HasSuffix(s, "Gi"):
|
|
mult, s = 1<<30, strings.TrimSuffix(s, "Gi")
|
|
case strings.HasSuffix(s, "Mi"):
|
|
mult, s = 1<<20, strings.TrimSuffix(s, "Mi")
|
|
case strings.HasSuffix(s, "Ki"):
|
|
mult, s = 1<<10, strings.TrimSuffix(s, "Ki")
|
|
case strings.HasSuffix(s, "GB"):
|
|
mult, s = 1e9, strings.TrimSuffix(s, "GB")
|
|
case strings.HasSuffix(s, "MB"):
|
|
mult, s = 1e6, strings.TrimSuffix(s, "MB")
|
|
case strings.HasSuffix(s, "KB"):
|
|
mult, s = 1e3, strings.TrimSuffix(s, "KB")
|
|
}
|
|
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("parse size %q: %w", s, err)
|
|
}
|
|
return n * mult, nil
|
|
}
|