diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1c0720..f33d919 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,22 @@ jobs: - uses: DeterminateSystems/magic-nix-cache-action@v8 - run: nix build --print-build-logs + build-linux-aarch64: + name: build (linux-aarch64) + if: github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + - uses: DeterminateSystems/nix-installer-action@v16 + with: + extra-conf: | + extra-platforms = aarch64-linux + - uses: DeterminateSystems/magic-nix-cache-action@v8 + - run: nix build --system aarch64-linux --print-build-logs + build-windows: name: build (windows-x86_64) if: github.event_name == 'push' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 116e571..10cee94 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,6 +71,35 @@ jobs: path: graylog-cli-macos-aarch64 if-no-files-found: error + build-linux-aarch64: + runs-on: ubuntu-latest + needs: verify-main + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - uses: DeterminateSystems/nix-installer-action@v16 + with: + extra-conf: | + extra-platforms = aarch64-linux + + - uses: DeterminateSystems/magic-nix-cache-action@v8 + + - name: Build Linux aarch64 binary + run: nix build --system aarch64-linux + + - name: Prepare artifact + run: cp result/bin/graylog-cli graylog-cli-linux-aarch64 + + - uses: actions/upload-artifact@v4 + with: + name: graylog-cli-linux-aarch64 + path: graylog-cli-linux-aarch64 + if-no-files-found: error + build-windows: runs-on: ubuntu-latest needs: verify-main @@ -98,6 +127,7 @@ jobs: needs: - verify-main - build-linux + - build-linux-aarch64 - build-mac - build-windows steps: @@ -105,11 +135,19 @@ jobs: with: path: dist + - name: Generate checksums + run: | + cd dist + find . -type f -not -name checksums.txt | sort | xargs sha256sum | sed 's|./[^/]*/||' > checksums.txt + cat checksums.txt + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.verify-main.outputs.tag }} files: | dist/graylog-cli-linux-x86_64/graylog-cli-linux-x86_64 + dist/graylog-cli-linux-aarch64/graylog-cli-linux-aarch64 dist/graylog-cli-macos-aarch64/graylog-cli-macos-aarch64 dist/graylog-cli-windows-x86_64.exe/graylog-cli-windows-x86_64.exe + dist/checksums.txt diff --git a/Cargo.lock b/Cargo.lock index 39a2c1e..4ea9870 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,6 +130,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytes" version = "1.11.1" @@ -434,6 +440,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -525,11 +537,14 @@ dependencies = [ "semver", "serde", "serde_json", + "tabled", "tempfile", "thiserror 2.0.18", "time", "tokio", "toml", + "tracing", + "tracing-subscriber", "url", ] @@ -892,6 +907,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.185" @@ -931,6 +952,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.0" @@ -948,6 +978,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -993,6 +1032,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "papergrid" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1" +dependencies = [ + "bytecount", + "fnv", + "unicode-width", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1057,6 +1107,28 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1497,6 +1569,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1574,6 +1655,30 @@ dependencies = [ "syn", ] +[[package]] +name = "tabled" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d" +dependencies = [ + "papergrid", + "tabled_derive", + "testing_table", +] + +[[package]] +name = "tabled_derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea5d1b13ca6cff1f9231ffd62f15eefd72543dab5e468735f1a456728a02846" +dependencies = [ + "heck", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1587,6 +1692,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "testing_table" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc" +dependencies = [ + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1627,6 +1741,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.47" @@ -1820,9 +1943,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1830,6 +1965,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1844,6 +2009,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "untrusted" version = "0.9.0" @@ -1875,6 +2046,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 9ec6d59..ed7a6fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,9 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread"] } toml = "1" time = { version = "0.3", features = ["parsing", "formatting"] } url = { version = "2", features = ["serde"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +tabled = "0.20.0" [dev-dependencies] criterion = "0.5" diff --git a/skills/graylog-cli/SKILL.md b/skills/graylog-cli/SKILL.md index ad302ef..e45fee7 100644 --- a/skills/graylog-cli/SKILL.md +++ b/skills/graylog-cli/SKILL.md @@ -93,28 +93,30 @@ Graylog stores `level` as a **numeric** field (0–7): Search Graylog messages with optional grouping and automatic pagination. ```bash -graylog-cli search [--time-range 15m] [--field message] [--field source] \ +graylog-cli search [--time-range 15m] [--since 1h] [--field message] [--field source] \ [--limit 50] [--offset 0] [--sort timestamp] [--sort-direction desc] [--stream-id ] \ - [--group-by ] [--all-pages] [--all-fields] + [--group-by ] [--all-pages] [--all-fields] [--format json|table] ``` -| Flag | Values | Notes | -| ------------------ | ---------------------------- | ------------------------------------------------------------------ | -| `--time-range` | `Ns`, `Nm`, `Nh`, `Nd`, `Nw` | Relative range. Mutually exclusive with `--from`/`--to` | -| `--from` / `--to` | ISO 8601 timestamps | Absolute range. Both required together | -| `--field` | repeatable | Restrict returned fields | -| `--all-fields` | flag (no value) | Fetch all indexed fields (cached on disk with TTL). Ignored when `--field` is set | -| `--limit` | 1-1000 | Per-page limit (ignored when `--all-pages` is set) | -| `--offset` | non-negative integer | Pagination offset (ignored when `--all-pages` is set) | -| `--sort` | field name | Default: `timestamp` | -| `--sort-direction` | `asc`, `desc` | Default: `desc` | -| `--stream-id` | repeatable | Scope search to specific streams | -| `--group-by` | any indexed field name | Group results by a field. Adds `grouped_by` and `groups` to output | -| `--all-pages` | flag (no value) | Fetch all results beyond the 500-per-page API limit | - -When `--group-by` is set, the output includes a `groups` array where each group has `key` (field value), `count` (number of messages), and `duration_ms` (time span from first to last message in the group). Use `--sort-direction asc` with `--group-by` for chronological grouping. - -When `--all-pages` is set, the CLI automatically paginates through all results. Useful for queries that match more than 500 events. +| Flag | Values | Notes | +| ------------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--time-range` | `Ns`, `Nm`, `Nh`, `Nd`, `Nw` | Relative range. Mutually exclusive with `--from`/`--to` and `--since` | +| `--from` / `--to` | ISO 8601 timestamps | Absolute range. Both required together. `--from` must be earlier than `--to` | +| `--since` | humantime duration | Shorthand absolute range ending now: `--since 1h` expands to `--from --to `. Mutually exclusive with `--time-range` and `--from`/`--to` | +| `--field` | repeatable | Restrict returned fields | +| `--all-fields` | flag (no value) | Fetch all indexed fields (cached on disk with TTL). Ignored when `--field` is set | +| `--limit` | 1-1000 | Per-page limit (ignored when `--all-pages` is set) | +| `--offset` | non-negative integer | Pagination offset (ignored when `--all-pages` is set) | +| `--sort` | field name | Default: `timestamp` | +| `--sort-direction` | `asc`, `desc` | Default: `desc` | +| `--stream-id` | repeatable | Scope search to specific streams | +| `--group-by` | any indexed field name | Group results by a field. Adds `grouped_by` and `groups` to output | +| `--all-pages` | flag (no value) | Fetch all results beyond the 500-per-page API limit. See caveat below | +| `--format` | `json` (default), `table` | Output format. `table` renders an ASCII table of messages directly to stdout | + +When `--group-by` is set, the output includes a `groups` array where each group has `key` (field value), `count` (number of messages), and `duration_ms` (time span from first to last message in the group). Use `--sort-direction asc` with `--group-by` for chronological grouping. The `--group-by` field is automatically added to the fetched fields, so you do not need to specify it explicitly with `--field`. + +When `--all-pages` is set, the CLI automatically paginates through all results. Useful for queries that match more than 500 events. **Caveat:** pagination stops at 10,000 messages. When this limit is reached, a warning is printed to stderr and `metadata.truncated` is set to `true` in the JSON output. If you need more than 10,000 messages, narrow your query or time range. When `--all-fields` is set and no `--field` flags are provided, the CLI fetches the full list of indexed fields from an on-disk cache (with a configurable TTL) and uses them as the field list. This avoids having to specify `--field` for every field manually. If any `--field` flags are set, `--all-fields` is ignored. @@ -147,7 +149,7 @@ Run an aggregation query. ```bash graylog-cli aggregate --aggregation-type --field \ - [--size 10] [--interval 1h] [--time-range 1d] + [--size 10] [--interval 1h] [--time-range 1d] [--since 1h] [--format json|table] ``` | `--aggregation-type` | Notes | @@ -165,10 +167,10 @@ graylog-cli aggregate --aggregation-type --field \ Count messages grouped by log level. Equivalent to a `terms` aggregation on the `level` field. ```bash -graylog-cli count-by-level --time-range 1h +graylog-cli count-by-level --time-range 1h [--since 1h] [--format json|table] ``` -Accepts `--time-range` / `--from`/`--to`. +Accepts `--time-range` / `--from`/`--to` / `--since`. Use `--format table` for a quick human-readable view. ### streams @@ -199,10 +201,16 @@ graylog-cli system info List all indexed fields available for querying and filtering. ```bash -graylog-cli fields +graylog-cli fields [--refresh] ``` -Returns every field name that Graylog has indexed across all messages. Use this to discover what fields you can pass to `--field`, use in queries (`field:value`), or aggregate on. No flags required. +Returns every field name that Graylog has indexed across all messages. Use this to discover what fields you can pass to `--field`, use in queries (`field:value`), or aggregate on. + +| Flag | Notes | +| ----------- | -------------------------------------------------------------------------------------------------- | +| `--refresh` | Bypass the on-disk cache and fetch fresh fields from Graylog, then update the cache with the result | + +Without `--refresh`, results may be served from an on-disk cache to avoid a round-trip on every query. Use `--refresh` when newly indexed fields are not appearing in results. ### ping diff --git a/src/application/service.rs b/src/application/service.rs index ae14acb..3a9e75f 100644 --- a/src/application/service.rs +++ b/src/application/service.rs @@ -284,18 +284,64 @@ impl ApplicationService { }) } - pub async fn fields(&self) -> exn::Result { - let client = self.graylog_gateway().await?; - let result = client.list_fields().await.or_raise(|| { - CliError::Http(HttpError::Unavailable { - message: "failed to list fields".to_string(), - }) - })?; + pub async fn fields(&self, refresh: bool) -> exn::Result { + let config = self.require_config().await?; + let cache_key = "fields".to_string(); + let ttl = config.graylog.fields_cache_ttl_seconds; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let fetched_fields = if !refresh { + if let Some(cached) = self + .fields_cache_store + .get_serialized(&cache_key) + .await + .ok() + .flatten() + .and_then(|s| serde_json::from_str::(&s).ok()) + { + if now.saturating_sub(cached.fetched_at) < ttl { + Some(cached.fields) + } else { + None + } + } else { + None + } + } else { + None + }; + + let fields = match fetched_fields { + Some(fields) => fields, + None => { + let client = self.graylog_gateway_with_config(config.graylog)?; + let result = client.list_fields().await.or_raise(|| { + CliError::Http(HttpError::Unavailable { + message: "failed to list fields".to_string(), + }) + })?; + let cache_data = CachedFields { + fields: result.fields.clone(), + fetched_at: now, + }; + if let Ok(serialized) = serde_json::to_string(&cache_data) { + let _ = self + .fields_cache_store + .save_serialized(cache_key, serialized) + .await; + } + result.fields + } + }; + Ok(FieldsStatus { ok: true, command: "fields", - fields: result.fields.clone(), - total: result.fields.len(), + total: fields.len(), + fields, }) } @@ -408,6 +454,7 @@ impl ApplicationService { let mut all_messages = Vec::new(); let mut metadata = serde_json::Map::new(); let mut total_results = None; + let mut truncated = false; request.limit = 500; request.offset = 0; @@ -431,6 +478,10 @@ impl ApplicationService { all_messages.extend(result.messages); if all_messages.len() >= MAX_ALL_PAGES_MESSAGES { + truncated = true; + eprintln!( + "warning: --all-pages reached the {MAX_ALL_PAGES_MESSAGES}-message limit; results are truncated" + ); break; } @@ -457,6 +508,9 @@ impl ApplicationService { if let Some(total_results) = total_results { metadata.insert("total_results".to_string(), json!(total_results)); } + if truncated { + metadata.insert("truncated".to_string(), json!(true)); + } Ok(MessageSearchStatus { ok: true, @@ -1894,7 +1948,7 @@ mod tests { FakeCacheStore::default(), gateway, ); - let status = service.fields().await.expect("fields should succeed"); + let status = service.fields(false).await.expect("fields should succeed"); assert!(status.ok); assert_eq!(status.fields, vec!["message", "source", "level"]); assert_eq!(status.total, 3); diff --git a/src/application/updater_service.rs b/src/application/updater_service.rs index 513af23..774616a 100644 --- a/src/application/updater_service.rs +++ b/src/application/updater_service.rs @@ -293,6 +293,7 @@ pub fn current_asset_name() -> Result<&'static str, UpdaterError> { match (std::env::consts::OS, std::env::consts::ARCH) { ("macos", "aarch64") => Ok("graylog-cli-macos-aarch64"), ("linux", "x86_64") => Ok("graylog-cli-linux-x86_64"), + ("linux", "aarch64") => Ok("graylog-cli-linux-aarch64"), ("windows", "x86_64") => Ok("graylog-cli-windows-x86_64.exe"), (os, arch) => Err(UpdaterError::UnsupportedPlatform(format!("{os}/{arch}"))), } diff --git a/src/domain/timerange.rs b/src/domain/timerange.rs index 6bd0f23..f2247c1 100644 --- a/src/domain/timerange.rs +++ b/src/domain/timerange.rs @@ -1,3 +1,6 @@ +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + use crate::domain::error::ValidationError; #[derive(Debug, Clone, PartialEq, Eq)] @@ -68,6 +71,16 @@ impl AbsoluteTimerange { }); } + if let (Ok(from_dt), Ok(to_dt)) = ( + OffsetDateTime::parse(&from, &Rfc3339), + OffsetDateTime::parse(&to, &Rfc3339), + ) && from_dt > to_dt + { + return Err(ValidationError::InvalidTimerange { + message: "`from` must be earlier than `to`".to_string(), + }); + } + Ok(Self { from, to }) } @@ -171,6 +184,19 @@ mod tests { assert!(matches!(error, ValidationError::InvalidTimerange { .. })); } + #[test] + fn absolute_timerange_rejects_reversed_from_to() { + let error = AbsoluteTimerange::new("2026-01-01T01:00:00Z", "2026-01-01T00:00:00Z") + .expect_err("reversed absolute bounds should fail"); + + assert!(matches!(error, ValidationError::InvalidTimerange { .. })); + assert!( + error + .to_string() + .contains("`from` must be earlier than `to`") + ); + } + #[test] fn absolute_timerange_accepts_different_from_to() { let timerange = AbsoluteTimerange::new("2026-01-01T00:00:00Z", "2026-01-01T01:00:00Z") diff --git a/src/infrastructure/graylog_client.rs b/src/infrastructure/graylog_client.rs index 3c46b09..0bb1b65 100644 --- a/src/infrastructure/graylog_client.rs +++ b/src/infrastructure/graylog_client.rs @@ -101,6 +101,9 @@ impl GraylogClient { { Ok(response) => response, Err(error) if should_retry_aggregate_with_legacy_grouping(request, &error) => { + tracing::debug!( + "aggregate query returned HTTP 400; retrying with legacy grouping format" + ); let fallback_payload = self.aggregate_search_payload(request, true)?; self.send_json(Method::POST, SEARCH_AGGREGATE_PATH, Some(fallback_payload)) .await? diff --git a/src/infrastructure/updater.rs b/src/infrastructure/updater.rs index e1229a4..2f594a4 100644 --- a/src/infrastructure/updater.rs +++ b/src/infrastructure/updater.rs @@ -1,7 +1,7 @@ use std::time::Duration; use async_trait::async_trait; -use reqwest::{Client, StatusCode, header}; +use reqwest::{Client, header}; use serde::Deserialize; use crate::application::ports::updater::{ReleaseInfo, UpdaterError, UpdaterGateway}; @@ -94,11 +94,6 @@ impl UpdaterGateway for GitHubUpdaterGateway { .map_err(|error| UpdaterError::Download(error.to_string()))?; let status = response.status(); - if status == StatusCode::FOUND || status == StatusCode::MOVED_PERMANENTLY { - return Err(UpdaterError::Download(format!( - "unexpected redirect {status} while downloading asset" - ))); - } if !status.is_success() { return Err(UpdaterError::Download(format!( "asset download returned HTTP {status}" diff --git a/src/main.rs b/src/main.rs index f5180a8..e8dafd1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,8 @@ use std::path::PathBuf; use std::process::{Command, Stdio}; use std::sync::Arc; +use tracing_subscriber::EnvFilter; + use clap::Parser; use graylog_cli::application::ports::config_store::ConfigStore; use graylog_cli::application::ports::updater::UpdaterError; @@ -12,9 +14,11 @@ use graylog_cli::domain::error::ValidationError; use graylog_cli::infrastructure::config_store::FileConfigStore; use graylog_cli::infrastructure::graylog_client::ReqwestGraylogGatewayFactory; use graylog_cli::infrastructure::updater::GitHubUpdaterGateway; -use graylog_cli::presentation::cli::{Cli, Commands, StreamsCommands, SystemCommands}; +use graylog_cli::presentation::cli::{ + Cli, Commands, FieldsArgs, OutputFormat, StreamsCommands, SystemCommands, +}; use graylog_cli::presentation::output::{ - ErrorEnvelope, exit_code_for_cli_error, print_error_json, print_json, + ErrorEnvelope, exit_code_for_cli_error, print_error_json, print_json, print_table, }; use secrecy::SecretString; use url::Url; @@ -25,6 +29,11 @@ const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); #[tokio::main] async fn main() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .with_writer(std::io::stderr) + .init(); + let cli = match parse_cli() { Ok(cli) => cli, Err(exit_code) => std::process::exit(exit_code), @@ -93,25 +102,37 @@ async fn run( emit_json_success(&status); } Commands::Search(args) => { - emit_json_success( - &service - .search(args.to_input().map_err(CliError::from)?) - .await?, - ); + let format = args.format; + let status = service + .search(args.to_input().map_err(CliError::from)?) + .await?; + if format == OutputFormat::Table { + emit_table_success(&status.messages); + } else { + emit_json_success(&status); + } } Commands::Aggregate(args) => { - emit_json_success( - &service - .aggregate(args.to_input().map_err(CliError::from)?) - .await?, - ); + let format = args.format; + let status = service + .aggregate(args.to_input().map_err(CliError::from)?) + .await?; + if format == OutputFormat::Table { + emit_table_success(&status.rows); + } else { + emit_json_success(&status); + } } Commands::CountByLevel(args) => { - emit_json_success( - &service - .count_by_level(args.to_input().map_err(CliError::from)?) - .await?, - ); + let format = args.format; + let status = service + .count_by_level(args.to_input().map_err(CliError::from)?) + .await?; + if format == OutputFormat::Table { + emit_table_success(&status.rows); + } else { + emit_json_success(&status); + } } Commands::Streams { command: streams_command, @@ -148,8 +169,8 @@ async fn run( emit_json_success(&service.system_info().await?); } }, - Commands::Fields => { - emit_json_success(&service.fields().await?); + Commands::Fields(FieldsArgs { refresh }) => { + emit_json_success(&service.fields(refresh).await?); } Commands::Ping => { emit_json_success(&service.ping().await?); @@ -221,6 +242,13 @@ where } } +fn emit_table_success(rows: &[serde_json::Map]) { + if let Err(error) = print_table(rows) { + let _ = print_error_json(&ErrorEnvelope::from_message(1, error.to_string())); + std::process::exit(1); + } +} + fn emit_cli_error(error: &exn::Exn) -> ! { let cli_error: &CliError = error; let exit_code = exit_code_for_cli_error(cli_error); diff --git a/src/presentation/cli.rs b/src/presentation/cli.rs index 49f9ac8..664bf75 100644 --- a/src/presentation/cli.rs +++ b/src/presentation/cli.rs @@ -1,4 +1,8 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + use clap::{Args, Parser, Subcommand, ValueEnum}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; use crate::domain::error::{CliError, ValidationError}; use crate::domain::models::{ @@ -47,7 +51,7 @@ pub enum Commands { /// Check that Graylog is reachable. Ping, /// List all indexed fields. - Fields, + Fields(FieldsArgs), /// Upgrade graylog-cli to the latest released version. Upgrade, /// Internal: background worker that checks for updates and stages a newer binary. @@ -70,12 +74,19 @@ impl Commands { } Self::Streams { command } => command.validate(), Self::System { .. } => Ok(()), - Self::Fields => Ok(()), + Self::Fields(_) => Ok(()), Self::Upgrade | Self::SelfUpdateWorker => Ok(()), } } } +#[derive(Debug, Args)] +pub struct FieldsArgs { + /// Bypass the local cache and fetch fresh fields from Graylog. + #[arg(long = "refresh")] + pub refresh: bool, +} + #[derive(Debug, Args)] pub struct AuthArgs { /// Graylog base URL. @@ -110,6 +121,8 @@ pub struct SearchArgs { pub all_fields: bool, #[arg(long = "stream-id")] pub stream_id: Vec, + #[arg(long = "format", value_enum, default_value_t = OutputFormat::Json)] + pub format: OutputFormat, } impl SearchArgs { @@ -144,6 +157,8 @@ pub struct AggregateArgs { pub interval: Option, #[command(flatten)] pub timerange: TimerangeArgs, + #[arg(long = "format", value_enum, default_value_t = OutputFormat::Json)] + pub format: OutputFormat, } impl AggregateArgs { @@ -184,6 +199,8 @@ impl AggregateArgs { pub struct CountByLevelArgs { #[command(flatten)] pub timerange: TimerangeArgs, + #[arg(long = "format", value_enum, default_value_t = OutputFormat::Json)] + pub format: OutputFormat, } impl CountByLevelArgs { @@ -302,10 +319,60 @@ pub struct TimerangeArgs { pub from: Option, #[arg(long = "to")] pub to: Option, + /// Shorthand for an absolute time range ending now; accepts humantime durations like 1h, 30m, 7d. + #[arg(long = "since", conflicts_with_all = ["time_range", "from", "to"])] + pub since: Option, } impl TimerangeArgs { pub fn try_into_timerange(&self) -> Result, ValidationError> { + if let Some(since) = &self.since { + let duration = humantime::parse_duration(since.trim()).map_err(|_| { + ValidationError::InvalidTimerange { + message: format!( + "`--since` value `{since}` must be a positive duration like 15m, 1h, 5d, or 1w" + ), + } + })?; + if duration.as_secs() == 0 { + return Err(ValidationError::InvalidTimerange { + message: "`--since` duration must be at least one second".to_string(), + }); + } + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let to_secs = now.as_secs() as i64; + let from_secs = to_secs.saturating_sub(duration.as_secs() as i64); + let to_dt = OffsetDateTime::from_unix_timestamp(to_secs).map_err(|_| { + ValidationError::InvalidTimerange { + message: "could not compute `--to` from current time".to_string(), + } + })?; + let from_dt = OffsetDateTime::from_unix_timestamp(from_secs).map_err(|_| { + ValidationError::InvalidTimerange { + message: "could not compute `--from` from current time".to_string(), + } + })?; + let from_str = + from_dt + .format(&Rfc3339) + .map_err(|_| ValidationError::InvalidTimerange { + message: "could not format `--from` timestamp".to_string(), + })?; + let to_str = to_dt + .format(&Rfc3339) + .map_err(|_| ValidationError::InvalidTimerange { + message: "could not format `--to` timestamp".to_string(), + })?; + return CommandTimerange::from_input(TimerangeInput { + relative: None, + from: Some(from_str), + to: Some(to_str), + }) + .map(Some); + } + if self.time_range.is_none() && self.from.is_none() && self.to.is_none() { return Ok(None); } @@ -319,6 +386,15 @@ impl TimerangeArgs { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)] +pub enum OutputFormat { + /// JSON output (default). + #[default] + Json, + /// ASCII table output. + Table, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum AggregationTypeArg { Terms, @@ -610,7 +686,17 @@ mod tests { #[test] fn fields_needs_no_args() { let cli = parse(&["graylog-cli", "fields"]).expect("fields should parse"); - assert!(matches!(cli.command, Commands::Fields)); + assert!(matches!(cli.command, Commands::Fields(_))); + } + + #[test] + fn fields_refresh_flag() { + let cli = + parse(&["graylog-cli", "fields", "--refresh"]).expect("fields --refresh should parse"); + assert!(matches!( + cli.command, + Commands::Fields(FieldsArgs { refresh: true }) + )); } // --- Limit validation tests --- @@ -703,4 +789,64 @@ mod tests { ]); assert!(result.is_err(), "invalid sort-direction should be rejected"); } + + // --- --since tests --- + + #[test] + fn since_flag_is_accepted() { + let cli = parse(&["graylog-cli", "search", "test", "--since", "1h"]) + .expect("--since 1h should parse"); + + match cli.command { + Commands::Search(args) => { + assert_eq!(args.timerange.since.as_deref(), Some("1h")); + } + _ => panic!("expected Search command"), + } + } + + #[test] + fn since_conflicts_with_from() { + let result = parse(&[ + "graylog-cli", + "search", + "test", + "--since", + "1h", + "--from", + "2026-01-01T00:00:00Z", + ]); + assert!(result.is_err(), "--since and --from should conflict"); + } + + #[test] + fn since_conflicts_with_time_range() { + let result = parse(&[ + "graylog-cli", + "search", + "test", + "--since", + "1h", + "--time-range", + "1h", + ]); + assert!(result.is_err(), "--since and --time-range should conflict"); + } + + #[test] + fn since_produces_absolute_timerange() { + let args = TimerangeArgs { + time_range: None, + from: None, + to: None, + since: Some("1h".to_string()), + }; + let result = args + .try_into_timerange() + .expect("should produce a timerange"); + assert!( + matches!(result, Some(CommandTimerange::Absolute(_))), + "expected absolute timerange from --since" + ); + } } diff --git a/src/presentation/output.rs b/src/presentation/output.rs index 6cb0ca8..f5ead41 100644 --- a/src/presentation/output.rs +++ b/src/presentation/output.rs @@ -1,6 +1,9 @@ use std::io::{self, Write}; use serde::Serialize; +use serde_json::Value; +use tabled::builder::Builder; +use tabled::settings::Style; use crate::domain::error::{CliError, HttpError}; @@ -53,6 +56,50 @@ where handle.write_all(b"\n") } +/// Render a slice of JSON objects as an ASCII table to stdout. +/// +/// Columns are the union of all keys across all rows, in insertion order of +/// the first row that introduces each key. String values are unquoted; +/// nulls and missing fields are shown as `-`. +pub fn print_table(rows: &[serde_json::Map]) -> io::Result<()> { + if rows.is_empty() { + let stdout = io::stdout(); + let mut handle = stdout.lock(); + writeln!(handle, "(no rows)")?; + return Ok(()); + } + + // Collect ordered, deduplicated column names. + let mut columns: Vec = Vec::new(); + for row in rows { + for key in row.keys() { + if !columns.contains(key) { + columns.push(key.clone()); + } + } + } + + let cell_value = |row: &serde_json::Map, col: &str| -> String { + match row.get(col) { + None | Some(Value::Null) => "-".to_string(), + Some(Value::String(s)) => s.clone(), + Some(v) => v.to_string(), + } + }; + + let mut builder = Builder::default(); + builder.push_record(columns.iter().map(|c| c.as_str())); + for row in rows { + builder.push_record(columns.iter().map(|col| cell_value(row, col))); + } + + let table = builder.build().with(Style::ascii()).to_string(); + + let stdout = io::stdout(); + let mut handle = stdout.lock(); + writeln!(handle, "{table}") +} + pub fn exit_code_for_cli_error(error: &CliError) -> i32 { match error { CliError::Validation(_) | CliError::Config(_) | CliError::Cache(_) => 2, diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 2fa9935..f8e8173 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -1,12 +1,12 @@ use std::process::Command; fn graylog_cli() -> Command { - Command::new("cargo") + Command::new(env!("CARGO_BIN_EXE_graylog-cli")) } fn run(args: &[&str]) -> std::process::Output { let mut cmd = graylog_cli(); - cmd.args(["run", "--"]).args(args); + cmd.args(args); cmd.output().expect("failed to run graylog-cli") }