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
+122
View File
@@ -0,0 +1,122 @@
# Architecture
logarchiver is one Go binary with a service mode (`run`) and an operator CLI
(`search`/`fetch`/`init-schema`). Boundaries to external systems (NATS, S3,
ClickHouse, Vault) sit behind interfaces so they can be faked in tests.
## Packages
| Package | Responsibility |
|---|---|
| `internal/config` | Config struct, YAML+env loading, validation, defaults tuned for the logging stack. |
| `internal/event` | Extract host + timestamp from a raw JSON event (k8s and VM shapes), subject sanitization. |
| `internal/batcher` | Group events per subject into batches; decide flush by size/count/age; carry ack tokens. |
| `internal/crypto` | The LARC1 container: zstd + framed AES-256-GCM + OpenPGP-wrapped data key. Seal/Open. |
| `internal/vaultgpg` | Thin client for `vault-plugin-secrets-gpg`: fetch public key, decrypt wrapped data key. |
| `internal/s3store` | S3/Ceph RGW object storage behind an `ObjectStore` interface. |
| `internal/index` | ClickHouse archive index (`Index` interface), DDL, pure search-SQL builder. |
| `internal/archiver` | Orchestrates seal → S3 PUT → index write; object-key builder; public-key provider/refresh. |
| `internal/consumer` | JetStream binding + the fetch→batch→persist→ack loop (sink-conditional acks). |
| `internal/metrics` | Prometheus collectors and `/metrics`. |
| `internal/cli` | cobra command tree; wires everything for each subcommand. |
## Service data flow
1. **Consume.** A durable JetStream **pull** consumer (`LOGS` stream, durable
`archiver`, subject filter default `logs.k8s.vault.>`) is created/updated at
startup with explicit acks and independent offsets, so logarchiver's replay
position is decoupled from the ClickHouse transform tier. Messages are pulled
in batches.
2. **Route + batch.** Each message's payload is parsed just enough to extract
the source **host** (`.host``.hostname``.kubernetes.pod_node_name`) and
**timestamp** (`.timestamp``.ts`). Events are grouped by NATS **subject**.
A batch is flushed when it hits `max_bytes` (64 MiB raw), `max_events`, or
`max_age` (5 min) — whichever first — or on shutdown.
3. **Seal.** The batch is rendered to NDJSON, compressed with zstd, and sealed
into a **LARC1** object (see below).
4. **Store.** The object is `PUT` to S3 at
`archive/<subject>/YYYY/MM/DD/<UTCstamp>-<rand>.ndjson.zst.larc`.
5. **Index.** One row per object is inserted into ClickHouse `logs.archive_index`.
6. **Ack.** Only now are the batch's JetStream messages acknowledged. If seal,
S3, or index fails, the messages are Nak'd (with a backoff) and JetStream
redelivers them — nothing is lost on a sink outage.
## The LARC1 container format
The Vault GPG engine can only decrypt a **whole** OpenPGP message inline (no
session-key extraction, no streaming — see the retrieval runbook). To keep
objects any size while making retrieval cheap, logarchiver does its own hybrid
encryption instead of PGP-encrypting the whole object:
```
+-----------------------------------------------------------+
| magic "LARC1\n" (6 bytes) |
| header_len uint32 big-endian |
| header JSON { v, key_name, key_fingerprint, |
| wrapped_dek_len, nonce_prefix, |
| frame_size, compression, cipher } |
| wrapped_dek OpenPGP message encrypting the 32-byte DEK | <- only this goes to Vault
| to the engine's public key (~hundreds of B) |
| frames repeated: uint32 ct_len | ciphertext | <- zstd(NDJSON) in AES-256-GCM
| terminated by a zero-length frame | frames; nonce = prefix||counter
+-----------------------------------------------------------+
```
- A fresh random 256-bit **DEK** and 4-byte nonce prefix are generated per
object. Each frame is AES-256-GCM sealed with nonce `prefix||counter` and AAD
= the frame counter (binds frame order; tampering fails the GCM tag).
- The DEK is OpenPGP-encrypted to the engine's public key. This wrapped blob is
a small, standard OpenPGP message — the only thing ever sent to Vault's
decrypt endpoint, regardless of object size.
- Compression order is NDJSON → zstd → encrypt, so decryption streams frames,
GCM-decrypts, and feeds a streaming zstd decoder to emit NDJSON.
Because objects are a logarchiver-specific container (not a bare `gpg` file),
retrieval must go through `logarchiver fetch`. This is the deliberate cost of
supporting arbitrary object sizes against an engine that only whole-payload
decrypts.
## Index schema
`logs.archive_index` (one row per object), `MergeTree`,
`PARTITION BY toYYYYMM(min_ts)`, `ORDER BY (subject, min_ts, object_key)`, with a
`bloom_filter` skip index on `hosts`:
| column | type | notes |
|---|---|---|
| object_key | String | S3 key |
| bucket | LowCardinality(String) | e.g. `logs-archive` |
| subject | LowCardinality(String) | NATS subject |
| hosts | Array(LowCardinality(String)) | distinct source hosts |
| min_ts / max_ts | DateTime64(3) | event time range |
| event_count | UInt64 | |
| raw_bytes / stored_bytes | UInt64 | pre/post compression+encryption |
| compression / cipher / container_format | LowCardinality(String) | `zstd` / `AES-256-GCM` / `LARC1` |
| key_name / key_fingerprint | LowCardinality(String) / String | Vault GPG key + 40-hex fingerprint |
| created_at | DateTime64(3) DEFAULT now64(3) | |
Search overlaps the `[from,to]` window (`max_ts >= from AND min_ts <= to`),
matches the subject glob with an anchored regex (`*` = one token, `>` = rest),
and filters hosts with `has(hosts, …)` (exact) or `arrayExists(… match …)` (glob).
The DDL is owned in-cluster by the argocd bootstrap Job; `logarchiver
init-schema` applies the same statements and `--print` emits them. Keep
`schema/archive_index.sql` and `internal/index/ddl.go` in sync.
## Delivery guarantees
- **At-least-once**, sink-conditional. Acks happen only after S3 + index
success. A failure after S3 but before index leaves an orphan object (still
retrievable by prefix; reaped by bucket lifecycle) and triggers redelivery,
which produces a fresh object — so an event may appear in two objects, never
zero. Downstream consumers of the archive should treat events as at-least-once.
- **Graceful shutdown** drains open batches (bounded timeout) so in-flight
events are persisted and acked before exit.
## Configuration & secrets
Config loads defaults < YAML file < env. Secrets are never in the file:
NATS password from `NATS_CONSUMER_PASSWORD`, S3 creds from the standard `AWS_*`
env (cephrgw `logs-archive-s3` secret), ClickHouse password from
`CLICKHOUSE_PASSWORD`, Vault via `VAULT_ADDR` + k8s auth (service) or ambient
token (CLI). See [deployment.md](deployment.md).
+55
View File
@@ -0,0 +1,55 @@
# Deployment (drop-in fit with the logging stack)
This describes how logarchiver slots into the centralized logging stack
(argocd-apps #296). The actual argocd manifest swap is a **separate later task**;
this documents the wiring logarchiver is built for so that swap is mechanical.
## Where it runs
- Namespace **`logging`**, ServiceAccount **`default`** (reuses the stack's
`VaultAuth` `default`, k8s auth mount `k8s/au/syd1`, role `default`).
- Single-replica Deployment (independent JetStream offsets; one archiver is
enough — scale by subject-sharding into multiple durables if ever needed).
- Pod label **`vector.dev/exclude: "true"`** so the Vector agent does not scrape
logarchiver's own logs (matches the Vector archiver it replaces).
- Startup healthcheck against RGW should be lenient (RGW cred propagation is
slow), like the Vector archiver.
## What it binds to (all already provided by #296)
| Dependency | Wiring |
|---|---|
| **NATS** | `nats://nats.logging.svc.cluster.local:4222`, user `log-consumer`, password from secret **`nats-auth`** key `consumer_password` → env `NATS_CONSUMER_PASSWORD`. Stream `LOGS`, durable `archiver`. |
| **S3 (Ceph RGW)** | Bucket `logs-archive`, endpoint `https://s3.ceph.unkin.net` (path-style, region `us-east-1`), CA `/etc/vault-ca/ca.crt` (mount the `vault-ca-cert` secret). Creds from cephrgw BucketAccess secret **`logs-archive-s3`** via `envFrom` (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`; optional `S3_ENDPOINT`/`BUCKET_NAME`). |
| **ClickHouse** | `clickhouse-logs.logging.svc.cluster.local:9000` (native), database `logs`, table `archive_index`, user `vector`, password from secret **`clickhouse-credentials`** key `password` → env `CLICKHOUSE_PASSWORD`. |
| **Vault GPG engine** | Public key: default `pubkey_source: file` from a mounted armored key (secret/ConfigMap at `/etc/logarchiver/pubkey.asc`) — no Vault dependency on the hot path. Or `pubkey_source: vault` with k8s auth to read `gpg/keys/logarchive`. |
## Secrets to seed (Vault KV, reusing the `logging/default` path)
Nothing new is strictly required if you reuse the existing `nats-auth`,
`logs-archive-s3`, and `clickhouse-credentials` secrets. For the file pubkey,
add an armored public key as a mounted secret (e.g.
`kv/kubernetes/namespace/logging/default/logarchiver-pubkey` → VaultStaticSecret
→ file mount).
## Cross-repo notes
- **No new ServiceAccount** (reuses `default`), so no argocd-apps SA PR is needed
for CI; the Woodpecker steps already use `serviceAccountName: default`.
- **terraform-vault:** only needed if you (a) use `pubkey_source: vault` and the
`logging/default` policy doesn't already permit `read gpg/keys/logarchive`, or
(b) want a dedicated operator policy for `update gpg/decrypt/logarchive`.
Operators today use their own human Vault tokens for `fetch`, so this is
optional — track as a follow-up, not a blocker.
- **ClickHouse table:** created by the argocd bootstrap Job (embed the output of
`logarchiver init-schema --print`), not by the service at runtime.
## Migrating off the Vector archiver
The Vector archiver binds the same `LOGS`/`archiver` durable. To cut over
safely: deploy logarchiver with a **distinct** durable (e.g.
`durable: archiver-canary`) and a narrow `ARCHIVE_SUBJECTS` to validate objects
+ index rows land, then repoint it to the `archiver` durable and scale the
Vector archiver to zero. logarchiver writes a different object prefix
(`archive/…​.ndjson.zst.larc`) than Vector (`raw/…​.log.gz`), so the two never
collide in the bucket.
+41
View File
@@ -0,0 +1,41 @@
# `logarchiver fetch`
Download, decrypt, and decompress archived objects to plain NDJSON.
```sh
logarchiver fetch [object-key ...] [flags]
```
Objects are selected either by explicit object-key arguments, or — when no keys
are given — by the same `--subject/--host/--from/--to` query used by
[`search`](search.md). When `--host/--from/--to` are supplied they ALSO
re-filter the emitted events to just the matching lines.
## Flags
| Flag | Meaning |
|---|---|
| `-o, --output` | `-` for stdout (default), or a directory to write one NDJSON file per object. |
| `--subject`/`--host`/`--from`/`--to`/`--limit` | Object selection (same as `search`) when no keys are given; host/time also re-filter emitted lines. |
## How decryption works
For each object, `fetch` reads the object header to learn which Vault GPG key
wrapped it, sends only the small wrapped data key to the engine's
`gpg/decrypt/<name>` endpoint, recovers the data key, and streams the AES-GCM
frames through local decryption + zstd to emit NDJSON. See the
[retrieval runbook](retrieval-runbook.md) for the full design and required creds
(S3 read, internal CA, ambient `VAULT_TOKEN`/`~/.vault-token`).
## Examples
```sh
# Straight to stdout by key:
logarchiver fetch archive/logs.k8s.vault._/2026/07/27/20260727T101500Z-ab12cd34.ndjson.zst.larc -o -
# From a query, re-filtered to host db-1 in the last 24h, into ./out:
logarchiver fetch --subject 'logs.vm.*' --host db-1 --from -24h -o ./out
```
Exit status is non-zero if any selected object fails; per-object errors are
reported on stderr and the remaining objects are still processed.
+18
View File
@@ -0,0 +1,18 @@
# `logarchiver init-schema`
Create the ClickHouse archive-index database and table (idempotent).
```sh
logarchiver init-schema # execute the DDL against index.address
logarchiver init-schema --print # print the DDL instead of executing it
```
In-cluster the argocd bootstrap Job owns schema creation (a ClickHouse PostSync
hook, mirroring the logging stack's `clickhouse-schema` job). Use
`init-schema --print` to emit the exact `CREATE DATABASE` / `CREATE TABLE`
statements to embed in that Job, and `init-schema` (executing) for local/dev.
The statements are the source-of-truth DDL from `internal/index/ddl.go`, also
kept as [`schema/archive_index.sql`](../schema/archive_index.sql). Database and
table names come from `index.database` / `index.table` (defaults `logs` /
`archive_index`).
+118
View File
@@ -0,0 +1,118 @@
# Retrieval runbook & crypto design
This document is the honest account of how encryption and retrieval work, what
the Vault GPG engine actually supports, and the operational steps to get plain
NDJSON back out of an archived object.
## What the Vault GPG engine supports (and doesn't)
logarchiver encrypts to a key in Ben's `vault-plugin-secrets-gpg` engine
(transit-style OpenPGP, mounted at `gpg/`). Relevant endpoints:
- `GET gpg/keys/<name>` → returns `data.public_key` (armored) and
`data.fingerprint` (40-hex uppercase, no spaces). Used to encrypt locally.
- `POST gpg/decrypt/<name>` with `{ "ciphertext": "<base64 of a whole OpenPGP
message>" }` → returns `{ "plaintext": "<base64>" }`.
**Crucial limitation:** the decrypt endpoint does **whole-payload inline
decryption only**. It reads the entire OpenPGP message and returns the entire
plaintext. There is **no** session-key (PKESK) extraction, **no** chunking, and
**no** streaming. The whole ciphertext must fit in a base64 JSON request body
(Vault's default `max_request_size` is 32 MiB), and the whole plaintext comes
back base64 in the response. So you cannot feed a large archive object straight
to the engine, and you cannot ask it to decrypt only the session key.
## The design logarchiver chose
Rather than bound object sizes to the engine's request limit, logarchiver does
hybrid encryption itself and sends the engine only a tiny key blob:
1. **Encrypt (service, local):** generate a random 256-bit **DEK**; encrypt the
zstd-compressed NDJSON locally with AES-256-GCM in frames; OpenPGP-encrypt
just the 32-byte DEK to the engine's **public** key. The wrapped DEK is a
small standard OpenPGP message. The private key is never present.
2. **Decrypt (CLI, retrieval):** read the object header, send **only the wrapped
DEK** (a few hundred bytes) to `gpg/decrypt/<name>`, get the DEK back, then
stream-decrypt the AES-GCM frames locally and zstd-decompress to NDJSON.
The Vault round-trip is tiny and constant regardless of object size, the engine
needs no changes, and the private key stays in Vault. See
[architecture.md](architecture.md#the-larc1-container-format) for the container
layout.
> **Possible engine enhancement (follow-up, not required):** if the engine ever
> grows a "decrypt session key only / return PKESK plaintext" operation, objects
> could be plain standard OpenPGP messages while retaining tiny Vault
> round-trips. logarchiver's self-managed DEK envelope already achieves the
> operational goal without it, so this is optional.
## One-time key setup
Create the archive key in the engine (if it doesn't already exist) and export
the public key for the service:
```sh
export VAULT_ADDR=https://vault.service.consul:8200
# Create the key (idempotent; rsa-3072 default, or ed25519).
vault write gpg/keys/logarchive identity="logarchiver <ops@unkin.net>" algorithm=ed25519
# Fingerprint the service records per object (sanity check):
vault read -field=fingerprint gpg/keys/logarchive
# Export the armored public key for the service's file-mounted pubkey source:
vault read -field=public_key gpg/keys/logarchive > pubkey.asc
```
In k8s the service can instead read the public key directly from Vault
(`pubkey_source: vault`, k8s auth) — no mounted file needed. The file source is
the default because it removes any Vault dependency from the hot path.
## Retrieve archived logs
The CLI needs: S3 read creds (`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` for the
`logs-archive` bucket), the internal CA (`s3.ca_file`), and a Vault token with
decrypt on the key (ambient `VAULT_TOKEN` or `~/.vault-token`, exactly like
`passv`).
```sh
export VAULT_ADDR=https://vault.service.consul:8200
vault login ... # or have ~/.vault-token
export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=...
# 1. Find objects via the index.
logarchiver search --subject 'logs.k8s.vault.>' --host node-1 \
--from 2026-07-01 --to 2026-07-27
# 2a. Retrieve by the object keys search printed, to stdout:
logarchiver fetch <object-key> [<object-key> ...] -o -
# 2b. Or retrieve straight from a query, re-filtered to just the matching
# host/time events, into a directory (one NDJSON file per object):
logarchiver fetch --subject 'logs.vm.*' --host db-1 --from -24h -o ./out
```
`fetch` downloads each object, reads its header to learn which Vault key wrapped
it (`key_name`), decrypts the wrapped DEK via the engine, streams the frames
through local AES-GCM + zstd, and emits NDJSON. When `--host`/`--from`/`--to`
are supplied they also re-filter the emitted lines (events lacking a parseable
timestamp are kept, never silently dropped on time grounds).
## Failure modes
| Symptom | Likely cause | Action |
|---|---|---|
| `decrypt via gpg/decrypt/<name>: permission denied` | Token lacks the decrypt policy | Grant `update` on `gpg/decrypt/<name>` (and `read` on `gpg/keys/<name>`). |
| `unwrap dek: … no plaintext` | Wrong key name / key rotated below min_decryption_version | Confirm object `key_name`; the engine tries versions ≥ min_decryption_version. |
| `bad magic: not a logarchiver (LARC1) object` | Fetching a non-logarchiver object (e.g. an old Vector `.log.gz`) | Use the right prefix; Vector objects are plain gzip, decrypt them with gzip. |
| `decrypt frame N: cipher: message authentication failed` | Object corruption/tampering | Object integrity is broken; re-archive from JetStream if still within retention. |
| `pubkey fingerprint mismatch` (service) | Armored pubkey doesn't match the engine's reported fingerprint | Re-export the pubkey; the service refuses to encrypt to a mismatched key. |
## Key rotation
Rotating the engine key (`vault write gpg/keys/logarchive/rotate ...`) starts
encrypting new objects to the new version; the service picks up the new public
key within `crypto.refresh_interval` (default 1h) or on restart. Old objects
remain decryptable because the engine tries every key version from
`min_decryption_version` up. Each object records the fingerprint it was sealed
with, so you can always tell which key version applies.
+40
View File
@@ -0,0 +1,40 @@
# `logarchiver run`
Runs the archiver service: binds the JetStream pull consumer and drains it to
S3 + the ClickHouse index, acking only after each object is durably persisted.
```sh
logarchiver run [-c config.yaml]
```
## Behaviour
- Creates/updates the durable consumer (`nats.stream` / `nats.durable`) with the
configured subject filters, explicit acks, and `nats.ack_wait`.
- Batches events per subject and flushes on `batch.max_bytes` / `max_events` /
`max_age`. Each flush seals a LARC1 object, PUTs it to S3, writes one index
row, then acks the batch. On any failure the batch is Nak'd for redelivery.
- Loads the OpenPGP public key from `crypto.pubkey_source` (`file` or `vault`)
and refreshes it every `crypto.refresh_interval`.
- Serves Prometheus metrics and `/healthz` on `metrics.address` (default `:9090`)
when `metrics.enabled`.
- Handles SIGINT/SIGTERM: stops fetching, drains open batches (bounded), exits.
## Key env vars
| Env | Purpose |
|---|---|
| `NATS_CONSUMER_PASSWORD` | NATS `log-consumer` password (nats-auth secret). |
| `ARCHIVE_SUBJECTS` | Space-separated subject filters (overrides `nats.subjects`). |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | S3 creds (cephrgw `logs-archive-s3`). |
| `S3_ENDPOINT` / `BUCKET_NAME` | Optional S3 endpoint/bucket from the cephrgw secret. |
| `CLICKHOUSE_PASSWORD` | ClickHouse `vector` user password. |
| `VAULT_ADDR` | Vault address when `pubkey_source: vault`. |
| `LOGARCHIVER_CONFIG` | Config file path (same as `-c`). |
## Metrics
`logarchiver_objects_stored_total`, `_events_archived_total`,
`_raw_bytes_total`, `_stored_bytes_total`, `_store_failures_total`,
`_index_failures_total`, `_messages_fetched_total`, `_acks_total`,
`_batches_flushed_total{trigger}`, `logarchiver_pending_events`.
+39
View File
@@ -0,0 +1,39 @@
# `logarchiver search`
Query the ClickHouse archive index for objects matching a subject/host/time
window. Prints one row per matching S3 object with event counts and sizes; use
the object keys with [`fetch`](fetch.md).
```sh
logarchiver search [flags]
```
## Flags
| Flag | Meaning |
|---|---|
| `--subject` | NATS-style subject glob: `*` matches one token, `>` matches the rest. e.g. `logs.vm.*`, `logs.k8s.vault.>`. |
| `--host` | Source host to match: exact, or a glob containing `*`. |
| `--from` | Start of window: RFC3339, `YYYY-MM-DD`, or a relative duration like `-24h`. |
| `--to` | End of window (same formats). |
| `--limit` | Max objects (default 100; `0` = no limit). |
| `--json` | Emit results as JSON instead of a table. |
An object matches the time window when its `[min_ts, max_ts]` overlaps
`[from, to]`. Objects are ordered by `min_ts`.
## Examples
```sh
# Vault audit logs from a node in the last day.
logarchiver search --subject 'logs.k8s.vault.>' --host node-1 --from -24h
# All VM logs for a host in July, as JSON.
logarchiver search --subject 'logs.vm.*' --host db-1 \
--from 2026-07-01 --to 2026-08-01 --json
```
## Config
Uses `index.*` (ClickHouse address/database/table/credentials). Requires
`index.enabled: true`.