feat(sidecar): make the sidecar installable — CLI, --print-config, anchored data paths
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 7m52s
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 7m52s
Phase 0.2 of the installer plan (docs/installer/PLAN.md §5). The installer has to drive this binary non-interactively, and today it cannot: the auth token is only readable by scraping the startup log, the config path can only be named through an environment variable, and a relative db path follows the process working directory — which a service manager, not the operator, chooses. - Add a four-flag CLI (cli.rs): --print-config, --config <PATH>, --version, --help. Hand-rolled; an argument-parsing dependency would be larger than the code it replaced. An unrecognized flag exits 2 rather than starting a sidecar that is not the one that was asked for. - --print-config resolves the configuration exactly as a normal start does — including writing a missing config file and generating a blank auth token — and prints it as JSON on stdout: versions, protocol, both bind addresses, ws path, resolved db path, and the token. config_created / token_generated let a re-run tell "read an existing install" from "provisioned a new one". The log subscriber is deliberately not started in this mode, so the document is the whole output. - Anchor a relative [store].path to the config file's directory instead of the CWD, and report resolved absolute paths. A unit pinning UOLINK_CONFIG now keeps its database beside its config rather than in %SystemRoot%\System32 or a VirtualStore redirect. Development is unaffected: under cargo run the two directories are the same. :memory: and file: URIs are left alone. - Hand the db path to sqlx as a filesystem path instead of formatting it into a sqlite:// URL, which percent-decodes it and splits it on '?'. An installed path containing %20 previously opened a different file; verified it now does not. - Create the config's and the database's parent directories when missing, so a service can name /var/lib/runicgateway on a host where nothing made it yet. - 22 unit tests covering argument parsing, path anchoring, token persistence, the generated config template, and the --print-config document. No protocol change: PROTOCOL_VERSION stays 3. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
172
sidecar/src/cli.rs
Normal file
172
sidecar/src/cli.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
//! Command-line surface.
|
||||
//!
|
||||
//! The sidecar is configured by file and environment (see [`crate::config`]); this is deliberately
|
||||
//! not a second configuration mechanism. It exists so the binary can be *driven by an installer*
|
||||
//! rather than only by a human reading its logs:
|
||||
//!
|
||||
//! - `--print-config` resolves the configuration exactly as a normal start would — including
|
||||
//! generating the auth token on first run — and prints it as JSON on stdout. That is the
|
||||
//! supported way to obtain the token for the website's Admin → Shard form. Before this existed,
|
||||
//! the only way to read it back was to scrape the startup log or parse `sidecar.toml`.
|
||||
//! - `--config <PATH>` names the config file without having to export `UOLINK_CONFIG`, so a
|
||||
//! diagnostic run can point at an installed config from any working directory.
|
||||
//!
|
||||
//! Hand-rolled rather than pulled from a crate: four flags, no subcommands, no completions. A
|
||||
//! dependency here would be larger than the code it replaced.
|
||||
|
||||
/// What this invocation should do. Everything except `Run` prints and exits.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// Normal operation: bind the shard listener and the web server.
|
||||
Run,
|
||||
/// Resolve config, print it as JSON, exit.
|
||||
PrintConfig,
|
||||
Help,
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Cli {
|
||||
pub mode: Mode,
|
||||
/// `--config <PATH>`, which outranks `$UOLINK_CONFIG`.
|
||||
pub config: Option<String>,
|
||||
}
|
||||
|
||||
pub const USAGE: &str = "\
|
||||
uo-link sidecar — bridges a ServUO shard to the Runic Gateway website.
|
||||
|
||||
Usage: uo-link-sidecar [OPTIONS]
|
||||
|
||||
Options:
|
||||
--print-config Resolve the configuration, print it as JSON, and exit.
|
||||
Runs first-run setup like a normal start does: if the
|
||||
config file is missing it is written, and a blank auth
|
||||
token is generated and saved. The JSON CONTAINS THE
|
||||
AUTH TOKEN in clear text.
|
||||
--config <PATH> Path to sidecar.toml. Overrides $UOLINK_CONFIG;
|
||||
defaults to ./sidecar.toml.
|
||||
-V, --version Print the sidecar and protocol versions and exit.
|
||||
-h, --help Print this help and exit.
|
||||
|
||||
Configuration lives in sidecar.toml; environment variables override the file:
|
||||
UOLINK_CONFIG, UOLINK_SHARD_BIND, UOLINK_WEB_BIND, UOLINK_WEB_TOKEN,
|
||||
UOLINK_DB_PATH
|
||||
";
|
||||
|
||||
/// Parses arguments **without** the program name.
|
||||
///
|
||||
/// Returns the message to print on stderr when the arguments are unusable; the caller exits `2`.
|
||||
pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
|
||||
let mut mode = Mode::Run;
|
||||
let mut config = None;
|
||||
let mut it = args.into_iter();
|
||||
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"--print-config" => mode = Mode::PrintConfig,
|
||||
"-h" | "--help" => {
|
||||
return Ok(Cli {
|
||||
mode: Mode::Help,
|
||||
config,
|
||||
})
|
||||
}
|
||||
"-V" | "--version" => {
|
||||
return Ok(Cli {
|
||||
mode: Mode::Version,
|
||||
config,
|
||||
})
|
||||
}
|
||||
"--config" => {
|
||||
// `--config` with nothing after it would otherwise silently fall through and start
|
||||
// the sidecar against the default config — the opposite of what was asked for.
|
||||
let path = it
|
||||
.next()
|
||||
.ok_or_else(|| "--config requires a path".to_string())?;
|
||||
config = Some(path);
|
||||
}
|
||||
_ => match arg.strip_prefix("--config=") {
|
||||
Some("") => return Err("--config requires a path".into()),
|
||||
Some(path) => config = Some(path.to_string()),
|
||||
None => return Err(format!("unrecognized argument: {arg}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Cli { mode, config })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_str(args: &[&str]) -> Result<Cli, String> {
|
||||
parse(args.iter().map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_arguments_runs_the_sidecar() {
|
||||
let cli = parse_str(&[]).unwrap();
|
||||
assert_eq!(cli.mode, Mode::Run);
|
||||
assert_eq!(cli.config, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_config_is_recognized() {
|
||||
assert_eq!(
|
||||
parse_str(&["--print-config"]).unwrap().mode,
|
||||
Mode::PrintConfig
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_accepts_both_spellings() {
|
||||
let spaced = parse_str(&["--config", "/etc/runicgateway/sidecar.toml"]).unwrap();
|
||||
let equals = parse_str(&["--config=/etc/runicgateway/sidecar.toml"]).unwrap();
|
||||
assert_eq!(
|
||||
spaced.config.as_deref(),
|
||||
Some("/etc/runicgateway/sidecar.toml")
|
||||
);
|
||||
assert_eq!(spaced, equals);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_combines_with_print_config() {
|
||||
let cli = parse_str(&["--config", "c.toml", "--print-config"]).unwrap();
|
||||
assert_eq!(cli.mode, Mode::PrintConfig);
|
||||
assert_eq!(cli.config.as_deref(), Some("c.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_is_never_swallowed_as_a_flag() {
|
||||
// `--config --print-config` takes the next token as the path, wrong as that path is. The
|
||||
// alternative — treating it as a missing value — guesses at intent.
|
||||
let cli = parse_str(&["--config", "--print-config"]).unwrap();
|
||||
assert_eq!(cli.mode, Mode::Run);
|
||||
assert_eq!(cli.config.as_deref(), Some("--print-config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_without_a_value_is_an_error() {
|
||||
assert!(parse_str(&["--config"]).is_err());
|
||||
assert!(parse_str(&["--config="]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_arguments_are_rejected() {
|
||||
// Silently ignoring a typo'd flag would start a sidecar that is not what was asked for.
|
||||
let err = parse_str(&["--pirnt-config"]).unwrap_err();
|
||||
assert!(err.contains("--pirnt-config"), "{err}");
|
||||
assert!(parse_str(&["/etc/runicgateway/sidecar.toml"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_and_version_win_immediately() {
|
||||
assert_eq!(parse_str(&["--help", "--bogus"]).unwrap().mode, Mode::Help);
|
||||
assert_eq!(parse_str(&["-h"]).unwrap().mode, Mode::Help);
|
||||
assert_eq!(
|
||||
parse_str(&["--version", "--bogus"]).unwrap().mode,
|
||||
Mode::Version
|
||||
);
|
||||
assert_eq!(parse_str(&["-V"]).unwrap().mode, Mode::Version);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user