Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Copilot instructions for `kimspect`

## Build, test, and lint

- Use the pinned Rust toolchain from `rust-toolchain.toml` (`1.90.0` with `rustfmt` and `clippy`).
- Build locally with `cargo build` or `cargo build --release`.
- CI lint commands are:
- `cargo clippy -- -D warnings`
- `cargo fmt --all -- --check --color always`
- The pre-commit config also runs `cargo check` and `cargo clippy --fix --no-deps --allow-dirty --allow-staged`.
- Run the full test suite with `cargo test`.
- Run a single test by name with `cargo test test_process_pod`.
- Run a single integration-test target and test with `cargo test --test cli_tests test_cli_parse_get_images_default`.
- `tests/integration_tests.rs` uses a real Kubernetes client. CI starts a Kind cluster before `cargo test`, so cluster-dependent tests expect working kube access rather than mocks.

## High-level architecture

- `src/main.rs` is the binary entrypoint. It parses Clap args, initializes tracing, creates `K8sClient`, and dispatches the selected subcommand.
- `src/lib.rs` re-exports the main CLI types, Kubernetes logic, and display helpers so the binary and integration tests use the same public API.
- CLI definitions are split across `src/cli/args.rs`, `src/cli/commands.rs`, and `src/cli/formats.rs`:
- `Args` owns global flags like verbosity, log format, and request timeout.
- `Commands` / `GetImages` define the `get images` and `get registries` subcommands and their conflict rules.
- `OutputFormat` controls whether rendering stays in `normal` mode or expands to `wide`.
- `src/k8s/mod.rs` is the core domain layer:
- `K8sClient` creates the `kube` client, checks cluster accessibility, validates namespaces, and fetches Kubernetes resources.
- `get_pod_images` lists pods, applies node/pod/registry filters, converts pods into `PodImage` records, then does a second pass over Node status to enrich image sizes from digests.
- `get_unique_registries` does **not** scan pods; it scans Deployment container specs and returns sorted unique registries.
- Image parsing lives in `extract_registry`, `split_image`, and `process_pod`.
- `src/utils/mod.rs` is the presentation layer. It renders `PodImage` rows with `prettytable`; `wide` output is where registry, size, digest, and node columns are added.
- `src/utils/logging.rs` owns tracing setup and maps `-v` counts to log levels.

## Key conventions

- Reuse the existing image-parsing helpers in `src/k8s/mod.rs` instead of reimplementing registry/tag/digest parsing. `tests/k8s_tests.rs` covers many edge cases, including registries with ports and digest-only image refs.
- `PodImage` is the shared shape between Kubernetes collection and CLI rendering. If you add or rename image metadata, update both `process_pod` / `get_pod_images` and the table rendering in `src/utils/mod.rs`.
- Preserve the current command/data-source split:
- `get images` is pod-based.
- `get registries` is deployment-based.
Changes that assume both commands read the same Kubernetes resource will be wrong.
- Namespace handling is explicit. `K8sClient` checks namespace existence unless `--all-namespaces` is set, and empty results are often turned into `K8sError::ResourceNotFound` rather than silently returning an empty success.
- Image size enrichment is best-effort. If listing Nodes fails, `get_pod_images` still returns image rows, just without size data.
- CLI argument behavior is enforced in tests. When changing flags, keep the conflict/compatibility rules aligned with `tests/cli_tests.rs`:
- `--namespace` conflicts with `--all-namespaces`
- `--registry` conflicts with `--exclude-registry`
- `-o wide` enables the extra table columns
- Logging follows the existing `tracing` + `anyhow::Context` + `#[instrument]` pattern. Match that style when adding new async Kubernetes operations.
- The CLI currently parses `--kubeconfig`, but `K8sClient::new` still resolves kubeconfig from `KUBECONFIG` or `~/.kube/config`. If you work on kubeconfig handling, update both the CLI surface and the client wiring.
- Formatting is intentionally opinionated: `rustfmt.toml` sets `max_width = 100` and related layout rules, and CI treats formatting drift as a failure.
13 changes: 13 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ prettytable-rs = "0.10"
tracing = "0.1"
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter"] }
thiserror = "2.0.12"
hyper-timeout = "0.5.2"
hyper-util = { version = "0.1.17", features = ["client-legacy", "client-proxy", "http1", "tokio"] }
tower = "0.5.2"
http = "1.3.1"

[dev-dependencies]
tokio-test = "0.4"
Expand Down
51 changes: 51 additions & 0 deletions src/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::cli::Commands;
use crate::cli::formats::LogFormat;
use clap::Parser;
use std::path::PathBuf;
use std::time::Duration;

/// Command line arguments for the Kimspect application
#[derive(Parser, Debug)]
Expand Down Expand Up @@ -33,6 +34,10 @@ pub struct Args {
)]
pub log_format: LogFormat,

/// Timeout for Kubernetes API requests (e.g. 60s, 1m, 1m5s). Default: 30s
#[arg(long = "request-timeout", global = true, default_value = "30s", value_parser = parse_duration)]
pub request_timeout: Duration,

/// The command to execute
#[command(subcommand)]
pub command: Commands,
Expand All @@ -50,3 +55,49 @@ impl Args {
.or_else(|| std::env::var("KUBECONFIG").ok().map(PathBuf::from))
}
}

/// Parse a human-readable duration string into a [`Duration`].
///
/// Supported formats: `60s`, `1m`, `1m5s` (minutes and/or seconds).
fn parse_duration(s: &str) -> Result<Duration, String> {
let s = s.trim();
if s.is_empty() {
return Err("duration must not be empty".into());
}

let mut remaining = s;
let mut total_secs: u64 = 0;
let mut parsed_any = false;

// Optional minutes component: <digits>m
if let Some(m_pos) = remaining.find('m') {
let minutes_str = &remaining[..m_pos];
let minutes: u64 = minutes_str
.parse()
.map_err(|_| format!("invalid minutes value in '{s}'"))?;
total_secs += minutes * 60;
remaining = &remaining[m_pos + 1..];
parsed_any = true;
}

// Optional seconds component: <digits>s
if let Some(s_pos) = remaining.find('s') {
let seconds_str = &remaining[..s_pos];
let seconds: u64 = seconds_str
.parse()
.map_err(|_| format!("invalid seconds value in '{s}'"))?;
total_secs += seconds;
remaining = &remaining[s_pos + 1..];
parsed_any = true;
}

if !remaining.is_empty() {
return Err(format!("unrecognised suffix '{remaining}' in '{s}' — expected format: 60s, 1m, 1m5s"));
}

if !parsed_any {
return Err(format!("'{s}' is not a valid duration — expected format: 60s, 1m, 1m5s"));
}

Ok(Duration::from_secs(total_secs))
}
Loading