From 2baa6b5b7c664f11975baaca9ea1c5ab5fb12caf Mon Sep 17 00:00:00 2001 From: Zachary Rogers Date: Wed, 7 Oct 2026 16:46:39 -0500 Subject: [PATCH] feat(mw): add init for a first-run folder A downloaded binary had no way to get the example inventory: the not-found message pointed at a file in the source tree. mw init writes the bundled example to inventory/devices.example.yml, creates reports/, and says what to do next. Idempotent, never touches a real devices.yml. The not-found message now points at mw init and MW_HOME. mw-check 0.3.0. --- Cargo.lock | 2 +- README.md | 6 +- crates/mw-check/Cargo.toml | 2 +- crates/mw-check/src/commands/demo.rs | 5 +- crates/mw-check/src/commands/init.rs | 96 ++++++++++++++++++++++++++++ crates/mw-check/src/commands/mod.rs | 5 +- crates/mw-check/src/inventory.rs | 12 ++-- crates/mw-check/src/main.rs | 10 ++- crates/mw-check/tests/init_folder.rs | 62 ++++++++++++++++++ 9 files changed, 181 insertions(+), 19 deletions(-) create mode 100644 crates/mw-check/src/commands/init.rs create mode 100644 crates/mw-check/tests/init_folder.rs diff --git a/Cargo.lock b/Cargo.lock index 68f3159..f6e6c3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1096,7 +1096,7 @@ dependencies = [ [[package]] name = "mw-check" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "chrono", diff --git a/README.md b/README.md index 7cdffef..9734749 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,9 @@ so it reports "No meaningful changes detected." ## Run it against your network -Put your devices in `inventory/devices.yml` (copy -[`devices.example.yml`](crates/mw-check/fixtures/devices.example.yml) to -start), then from that directory: +Pick a folder for your captures and run `mw init` in it. That writes +`inventory/devices.example.yml` and makes `reports/`. Copy the example to +`inventory/devices.yml`, put your devices in it, then from that folder: ```text mw before NET-123 -r diff --git a/crates/mw-check/Cargo.toml b/crates/mw-check/Cargo.toml index 2c5fb8e..8ed5dfd 100644 --- a/crates/mw-check/Cargo.toml +++ b/crates/mw-check/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mw-check" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/mw-check/src/commands/demo.rs b/crates/mw-check/src/commands/demo.rs index 1fd0980..6342910 100644 --- a/crates/mw-check/src/commands/demo.rs +++ b/crates/mw-check/src/commands/demo.rs @@ -102,10 +102,7 @@ const EMBEDDED_FIXTURES: &[(&str, &str)] = &[ "NET-DEMO/SCENARIO.md", include_str!("../../fixtures/NET-DEMO/SCENARIO.md"), ), - ( - "devices.example.yml", - include_str!("../../fixtures/devices.example.yml"), - ), + ("devices.example.yml", crate::inventory::EXAMPLE_INVENTORY_TEXT), ]; /// The folder holding `NET-DEMO/`. When it's a copy unpacked from the diff --git a/crates/mw-check/src/commands/init.rs b/crates/mw-check/src/commands/init.rs new file mode 100644 index 0000000..288c1a5 --- /dev/null +++ b/crates/mw-check/src/commands/init.rs @@ -0,0 +1,96 @@ +//! `mw init` - make a folder ready for captures. +//! +//! A downloaded `mw.exe` has no source tree around it, so the example +//! inventory has to come out of the binary. This writes it to +//! `inventory/devices.example.yml` under the working root, makes +//! `reports/`, and says what to do next. +//! +//! Idempotent: a second run changes nothing. An existing +//! `inventory/devices.yml` is never touched, and an example file that +//! someone has edited is left as it is. + +use std::path::{Path, PathBuf}; + +use crate::inventory::EXAMPLE_INVENTORY_TEXT; +use crate::layout; + +#[derive(Clone, Debug, clap::Args)] +pub struct Args { + /// Folder to prepare (default: $MW_HOME or the current directory) + #[arg(short = 'H', long, value_name = "DIR")] + pub home: Option, +} + +/// What `init` found and did, for the console and for tests. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Report { + pub root: PathBuf, + pub example: PathBuf, + pub inventory: PathBuf, + pub reports: PathBuf, + /// The example was written on this run (false: it was already there). + pub example_written: bool, + /// `inventory/devices.yml` already exists, so there is nothing to copy. + pub inventory_exists: bool, +} + +pub fn run(args: Args) -> anyhow::Result<()> { + let root = args.home.clone().unwrap_or_else(layout::root); + let report = run_in(&root)?; + let shown = |path: &Path| { + path.strip_prefix(&report.root) + .map(|rel| rel.display().to_string()) + .unwrap_or_else(|_| path.display().to_string()) + }; + + println!("Working folder: {}", report.root.display()); + if report.example_written { + println!("Wrote {}", shown(&report.example)); + } else { + println!("{} is already there (left as it is)", shown(&report.example)); + } + println!("Reports will go under {}", shown(&report.reports)); + + if report.inventory_exists { + println!( + "{} already exists (left as it is). You're ready: mw before -r", + shown(&report.inventory) + ); + } else { + println!( + "Next: copy {} to {} and put your devices in it. Then: mw before -r", + shown(&report.example), + shown(&report.inventory) + ); + } + println!("To run mw from any folder, set MW_HOME to {}", report.root.display()); + + Ok(()) +} + +/// `init` under an explicit root. Nothing here reads the environment. +pub fn run_in(root: &Path) -> anyhow::Result { + let inventory_dir = root.join("inventory"); + let reports = root.join("reports"); + std::fs::create_dir_all(&inventory_dir)?; + std::fs::create_dir_all(&reports)?; + + let example = inventory_dir.join("devices.example.yml"); + let example_written = if example.exists() { + false + } else { + std::fs::write(&example, EXAMPLE_INVENTORY_TEXT)?; + true + }; + + let inventory = layout::default_inventory_in(root); + + Ok(Report { + root: root.to_path_buf(), + inventory_exists: inventory.exists(), + example, + inventory, + reports, + example_written, + }) +} diff --git a/crates/mw-check/src/commands/mod.rs b/crates/mw-check/src/commands/mod.rs index c89d195..ad28f62 100644 --- a/crates/mw-check/src/commands/mod.rs +++ b/crates/mw-check/src/commands/mod.rs @@ -1,8 +1,9 @@ -//! The five subcommands: before, after, report, notes, demo. Each has a -//! clap `Args` struct and a `run`. +//! The six subcommands: init, before, after, report, notes, demo. Each +//! has a clap `Args` struct and a `run`. pub mod compare; pub mod demo; +pub mod init; pub mod notes; pub mod postcheck; pub mod precheck; diff --git a/crates/mw-check/src/inventory.rs b/crates/mw-check/src/inventory.rs index 45cf2b0..91b4d77 100644 --- a/crates/mw-check/src/inventory.rs +++ b/crates/mw-check/src/inventory.rs @@ -6,8 +6,8 @@ //! explicitly (otherwise they are inferred from hostnames). //! //! The file is the one the Python tool reads (`inventory/devices.yml`, -//! gitignored there because it names real hosts). Copy -//! `fixtures/devices.example.yml` and edit: adding a device or a +//! gitignored there because it names real hosts). `mw init` writes the +//! bundled example next to it; copy and edit: adding a device or a //! command never means touching code. use std::io::{BufRead, Write}; @@ -16,8 +16,9 @@ use std::path::Path; use netshell::Secret; use serde_yaml::Value; -/// The bundled example, relative to the crate source tree. -pub const EXAMPLE_INVENTORY: &str = "fixtures/devices.example.yml"; +/// The bundled example inventory, compiled into the binary so `mw init` +/// can write it out with no source tree around. +pub const EXAMPLE_INVENTORY_TEXT: &str = include_str!("../fixtures/devices.example.yml"); #[derive(Clone, Debug, PartialEq, Eq)] pub struct Platform { @@ -97,7 +98,8 @@ pub struct InventoryError(pub String); pub fn load_inventory(path: &Path) -> Result { if !path.exists() { return Err(InventoryError(format!( - "Inventory not found: {}\nCopy {EXAMPLE_INVENTORY} (bundled with the mw source) to {} and fill in your devices.", + "Inventory not found: {}\nRun `mw init` in the folder you want to work from. It writes inventory/devices.example.yml; \ + copy that to {} and put your devices in it. To run mw from any folder, set MW_HOME to that folder.", path.display(), crate::layout::default_inventory().display() ))); diff --git a/crates/mw-check/src/main.rs b/crates/mw-check/src/main.rs index 65775e2..7c22ac7 100644 --- a/crates/mw-check/src/main.rs +++ b/crates/mw-check/src/main.rs @@ -1,6 +1,7 @@ -//! One binary, four short subcommands: +//! One binary, short subcommands: //! //! ```text +//! mw init [-H DIR] make a folder ready: inventory/ + reports/ //! mw before NET-123 [-u USER] [-i FILE] [-r] capture before the change //! mw after NET-123 [-u USER] [-i FILE] [-r] capture after the change //! (both: --known-hosts FILE, --accept-new-host-key HOST, @@ -22,7 +23,7 @@ use std::process::ExitCode; use clap::{Parser, Subcommand}; -use mw_check::commands::{compare, demo, notes, postcheck, precheck}; +use mw_check::commands::{compare, demo, init, notes, postcheck, precheck}; use mw_check::layout::TicketError; #[derive(Parser)] @@ -30,7 +31,7 @@ use mw_check::layout::TicketError; name = "mw", version, about = "Maintenance window check: capture device state before and after a change, report what changed", - after_help = "Example:\n mw before NET-123 -r capture before the change, secrets stripped\n mw after NET-123 -r capture after, quick text diff\n mw report NET-123 interpreted HTML report" + after_help = "Example:\n mw init make this folder ready (example inventory, reports/)\n mw before NET-123 -r capture before the change, secrets stripped\n mw after NET-123 -r capture after, quick text diff\n mw report NET-123 interpreted HTML report" )] struct Cli { #[command(subcommand)] @@ -39,6 +40,8 @@ struct Cli { #[derive(Subcommand)] enum Command { + /// Make this folder ready for captures: inventory/devices.example.yml and reports/ + Init(init::Args), /// Capture device state before the change (reports//Precheck/) #[command(aliases = ["pre", "precheck"])] Before(precheck::Args), @@ -59,6 +62,7 @@ fn main() -> ExitCode { // A capture leaves with 0, 1, or 2: all, some, or none of the // devices captured. The other commands leave with 0. let result = match cli.command { + Command::Init(args) => init::run(args).map(|()| 0), Command::Before(args) => precheck::run(args), Command::After(args) => postcheck::run(args), Command::Report(args) => compare::run(args).map(|()| 0), diff --git a/crates/mw-check/tests/init_folder.rs b/crates/mw-check/tests/init_folder.rs new file mode 100644 index 0000000..be6e009 --- /dev/null +++ b/crates/mw-check/tests/init_folder.rs @@ -0,0 +1,62 @@ +//! `mw init` makes a folder ready for captures without a source tree. +//! +//! A downloaded binary is all a new user has, so the example inventory +//! comes out of the binary. Two behaviours matter: a second run must +//! change nothing, and a real `inventory/devices.yml` must never be +//! touched. + +use std::fs; + +use mw_check::commands::init; +use mw_check::inventory::{EXAMPLE_INVENTORY_TEXT, load_inventory}; + +#[test] +fn init_writes_the_example_and_the_folders() { + let root = tempfile::tempdir().unwrap(); + let report = init::run_in(root.path()).unwrap(); + + assert!(report.example_written); + assert!(!report.inventory_exists); + assert!(report.reports.is_dir()); + assert_eq!(fs::read_to_string(&report.example).unwrap(), EXAMPLE_INVENTORY_TEXT); + // The example the binary writes is a valid inventory as it stands. + load_inventory(&report.example).unwrap(); +} + +#[test] +fn a_second_run_changes_nothing() { + let root = tempfile::tempdir().unwrap(); + init::run_in(root.path()).unwrap(); + let example = root.path().join("inventory/devices.example.yml"); + fs::write(&example, "# edited by hand\n").unwrap(); + + let report = init::run_in(root.path()).unwrap(); + + assert!(!report.example_written); + assert_eq!(fs::read_to_string(&example).unwrap(), "# edited by hand\n"); +} + +#[test] +fn a_real_inventory_is_never_touched() { + let root = tempfile::tempdir().unwrap(); + let inventory = root.path().join("inventory/devices.yml"); + fs::create_dir_all(inventory.parent().unwrap()).unwrap(); + fs::write(&inventory, "platforms: []\n").unwrap(); + + let report = init::run_in(root.path()).unwrap(); + + assert!(report.inventory_exists); + assert_eq!(fs::read_to_string(&inventory).unwrap(), "platforms: []\n"); +} + +#[test] +fn a_missing_inventory_points_at_init() { + let root = tempfile::tempdir().unwrap(); + let message = load_inventory(&root.path().join("inventory/devices.yml")) + .unwrap_err() + .to_string(); + + assert!(message.contains("mw init"), "{message}"); + assert!(message.contains("devices.example.yml"), "{message}"); + assert!(!message.contains("mw source"), "{message}"); +}