Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <bash|zsh|fish>` prints a shell completion script, so `<TAB>` 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
Expand Down
10 changes: 10 additions & 0 deletions Cargo.lock

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

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <shell>`
# 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"] }
Expand Down
28 changes: 28 additions & 0 deletions documentation/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<TAB>` 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 <TAB>` 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`
Expand Down
2 changes: 1 addition & 1 deletion src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
126 changes: 126 additions & 0 deletions src/commands/completions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//! `ring completions <shell>` — 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::<String>("shell")
.and_then(|name| name.parse::<Shell>().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::<Shell>().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");
}
}
}
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub(crate) mod apply;
pub(crate) mod completions;
pub(crate) mod context;
pub(crate) mod dashboard;
pub(crate) mod deployment;
Expand Down
28 changes: 23 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mlanawo.mbechezi@kemeter.io>")
.about("The ring to rule them all")
Expand Down Expand Up @@ -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::<String>("context")
.map(|s| s.as_str())
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/docker/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down