diff --git a/CHANGELOG.md b/CHANGELOG.md index d51ec38..f90f384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - OpenTelemetry log export (opt-in, push) via `[server.telemetry.logs]`: bridges the server's log events to an OTLP/gRPC collector in addition to the console. Each telemetry signal (traces, metrics, logs) is independently toggled; all three degrade gracefully when the collector is unreachable - `network.mode: host` is now accepted on the Podman runtime, not just Docker: Podman shares the Docker-compatible lifecycle and honours `NetworkMode: host`, so the container binds the host's network stack directly (Cloud Hypervisor and Firecracker still reject host mode) - `RING_LOG_FORMAT=json` switches the console logger to structured JSON (one object per line) for log shippers and structured ingestion; the default stays human-readable text. Independent of OTLP log export +- `ring completions ` prints a shell completion script, so `` completes commands, subcommands and flags. Generated from Ring's own command tree, so it never drifts from the CLI it ships with; regenerate after upgrading to pick up new commands ### Changed - `ring server start` shuts down gracefully on `SIGTERM` / `Ctrl-C`: it stops accepting new API connections, drains in-flight HTTP requests, tears down the scheduler, and exits on its own instead of being killed abruptly. Managed workloads keep running and are reconciled again on restart diff --git a/Cargo.lock b/Cargo.lock index 55a90d8..1598dea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -466,6 +466,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.1" @@ -2578,6 +2587,7 @@ dependencies = [ "caps", "chrono", "clap", + "clap_complete", "cli-table", "containerd-client", "docker_credential", diff --git a/Cargo.toml b/Cargo.toml index eaad6d3..ea85f44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,9 @@ futures = "0.3.1" tokio = { version = "1.20", features = ["full"] } bollard = "0.20.2" clap = { version = "4.5.1", features = ["derive"] } +# Generates the shell completion scripts emitted by `ring completions ` +# from the same `Command` tree the CLI is parsed with, so the two can't drift. +clap_complete = "4" chrono = "0.4" uuid = { version = "1.4.1", features = ["v4"] } sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "migrate"] } diff --git a/documentation/reference/cli.md b/documentation/reference/cli.md index fcaaff8..33a7b51 100644 --- a/documentation/reference/cli.md +++ b/documentation/reference/cli.md @@ -71,6 +71,34 @@ ring doctor Use this as the first step when something doesn't work as expected. Exits non-zero if any check fails. +### `ring completions` + +Print a shell completion script on stdout, for `bash`, `zsh`, or `fish`. Once installed, `` completes command names, subcommands, and flags. + +```bash +ring completions zsh +``` + +Where to put the script depends on the shell: + +```bash +# Bash +ring completions bash | sudo tee /etc/bash_completion.d/ring > /dev/null + +# Zsh (the target directory must be on your $fpath) +mkdir -p ~/.zsh/completions +ring completions zsh > ~/.zsh/completions/_ring + +# Fish +ring completions fish > ~/.config/fish/completions/ring.fish +``` + +Start a new shell to pick up the completions. + +The script is generated from Ring's own command tree, so it always matches the version of `ring` that produced it. Regenerate it after upgrading to pick up new commands and flags. + +> Completion covers command names and flags, not values: `ring deployment logs ` completes the flags, not your deployment IDs. Resolving those would mean calling the API from inside the shell's completion hook. + ## Server ### `ring server start` diff --git a/src/commands/apply.rs b/src/commands/apply.rs index dc444ad..241b612 100644 --- a/src/commands/apply.rs +++ b/src/commands/apply.rs @@ -333,7 +333,7 @@ impl Deployment { self.name = env_resolver(&self.name, env_vars); self.image = env_resolver(&self.image, env_vars); - for (_, value) in self.environment.iter_mut() { + for value in self.environment.values_mut() { if let EnvValue::Plain(s) = value { *s = env_resolver(s, env_vars); } diff --git a/src/commands/completions.rs b/src/commands/completions.rs new file mode 100644 index 0000000..e369c21 --- /dev/null +++ b/src/commands/completions.rs @@ -0,0 +1,126 @@ +//! `ring completions ` — print a shell completion script on stdout. +//! +//! The script is generated from the very same `Command` tree the CLI is parsed +//! with (see `crate::build_cli`), so completions can never drift from the real +//! commands and flags: adding a subcommand anywhere makes it completable with +//! no extra work here. +//! +//! Completion covers command names and flags only. Dynamic values (deployment +//! ids, namespaces, …) would require querying the API from inside the shell's +//! completion hook, which is deliberately out of scope. + +use clap::{Arg, ArgMatches, Command}; +use clap_complete::{Shell, generate}; +use std::io; + +pub(crate) fn command_config() -> Command { + Command::new("completions") + .about("Print a shell completion script") + .long_about( + "Print a shell completion script on stdout.\n\n\ + Bash:\n \ + ring completions bash > /etc/bash_completion.d/ring\n\n\ + Zsh (the directory must be on your $fpath):\n \ + ring completions zsh > ~/.zsh/completions/_ring\n\n\ + Fish:\n \ + ring completions fish > ~/.config/fish/completions/ring.fish", + ) + .arg( + Arg::new("shell") + .required(true) + .value_parser(SUPPORTED_SHELLS) + .help("Shell to generate the script for"), + ) +} + +/// Shells we generate for. Narrower than clap_complete's own `Shell` enum (it +/// also knows elvish and powershell): these three are the ones Ring documents +/// and can reasonably support on the Linux/macOS hosts it targets. Listing them +/// explicitly keeps `--help`, the error message and the docs in agreement. +const SUPPORTED_SHELLS: [&str; 3] = ["bash", "zsh", "fish"]; + +/// Write the completion script for the requested shell to stdout. +/// +/// Takes the CLI tree by value because `generate` needs `&mut Command` (clap +/// builds the help/version args lazily on first use). +pub(crate) fn execute(sub_matches: &ArgMatches, mut cli: Command) { + // `shell` is `required(true)` and constrained to `SUPPORTED_SHELLS`, so + // clap has already rejected a missing or unknown value before we get here. + let shell = sub_matches + .get_one::("shell") + .and_then(|name| name.parse::().ok()) + .expect("shell is required and validated against SUPPORTED_SHELLS"); + + generate(shell, &mut cli, "ring", &mut io::stdout()); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Generate into a buffer rather than stdout, so tests can assert on the + /// script's content. + fn script_for(shell: Shell) -> String { + let mut cli = crate::build_cli(); + let mut buf = Vec::new(); + generate(shell, &mut cli, "ring", &mut buf); + String::from_utf8(buf).expect("completion scripts are valid UTF-8") + } + + #[test] + fn every_supported_shell_generates_a_non_empty_script() { + for shell in [Shell::Bash, Shell::Zsh, Shell::Fish] { + assert!( + !script_for(shell).trim().is_empty(), + "{shell} script is empty" + ); + } + } + + /// The whole point of generating from `build_cli`: top-level commands are + /// completable. Guards against the tree being rebuilt or trimmed by mistake. + #[test] + fn scripts_mention_top_level_subcommands() { + for shell in [Shell::Bash, Shell::Zsh, Shell::Fish] { + let script = script_for(shell); + for command in ["deployment", "namespace", "secret", "server", "completions"] { + assert!( + script.contains(command), + "{shell} script is missing the `{command}` command" + ); + } + } + } + + /// Nested subcommands and their flags must be completable too, not just the + /// top level. + #[test] + fn scripts_cover_nested_subcommands_and_flags() { + let script = script_for(Shell::Bash); + assert!(script.contains("health-checks")); + assert!(script.contains("--follow")); + } + + /// Every advertised shell must actually parse into a `Shell`, otherwise + /// `execute` would panic on a value clap had accepted. + #[test] + fn supported_shells_all_parse() { + for name in SUPPORTED_SHELLS { + assert!( + name.parse::().is_ok(), + "`{name}` is advertised but not a valid clap_complete shell" + ); + } + } + + /// Values outside the advertised list are rejected by clap rather than + /// reaching `execute`. `powershell` is a real `Shell` variant we choose not + /// to support, so it guards the narrowing itself, not just a typo. + #[test] + fn unsupported_shells_are_rejected() { + for name in ["powershell", "elvish", "nushell"] { + let result = command_config().try_get_matches_from(["completions", name]); + assert!(result.is_err(), "`{name}` should have been rejected"); + } + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index ca3d4e3..5dd29c0 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod apply; +pub(crate) mod completions; pub(crate) mod context; pub(crate) mod dashboard; pub(crate) mod deployment; diff --git a/src/main.rs b/src/main.rs index 6a5f2c6..5c2c944 100644 --- a/src/main.rs +++ b/src/main.rs @@ -156,9 +156,13 @@ fn init_tracing( Some(guard) } -#[tokio::main] -async fn main() { - let app = Command::new("ring") +/// Build the CLI tree. +/// +/// Kept as its own function (rather than inlined in `main`) because +/// `ring completions` generates its scripts from this exact tree — one +/// definition, so the completions can never drift from the real commands. +fn build_cli() -> Command { + Command::new("ring") .version(env!("CARGO_PKG_VERSION")) .author("Mlanawo Mbechezi ") .about("The ring to rule them all") @@ -259,9 +263,23 @@ async fn main() { .subcommand(commands::webhook::create::command_config()) .subcommand(commands::webhook::delete::command_config()) .subcommand(commands::webhook::inspect::command_config()), - ); + ) + .subcommand(commands::completions::command_config()) +} + +#[tokio::main] +async fn main() { + let app = build_cli(); + + // `completions` only needs the CLI tree, never the config or a client, so + // it is dispatched before `load_config`: generating a script must work on a + // machine that has never run `ring init`. + let matches = app.clone().get_matches(); + if let Some(("completions", sub_matches)) = matches.subcommand() { + commands::completions::execute(sub_matches, app); + return; + } - let matches = app.get_matches(); let context = matches .get_one::("context") .map(|s| s.as_str()) diff --git a/src/runtime/docker/container.rs b/src/runtime/docker/container.rs index e8358f2..8f1d337 100644 --- a/src/runtime/docker/container.rs +++ b/src/runtime/docker/container.rs @@ -443,7 +443,7 @@ pub(crate) async fn create_container( let temporary_id = tiny_id(); let container_name = format!( "{}_{}_{}", - &deployment.namespace, &deployment.name, temporary_id + deployment.namespace, deployment.name, temporary_id ); let mut labels = HashMap::new();