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
2 changes: 1 addition & 1 deletion Cargo.lock

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

6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/mw-check/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 1 addition & 4 deletions crates/mw-check/src/commands/demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions crates/mw-check/src/commands/init.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf>,
}

/// 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 <TICKET> -r",
shown(&report.inventory)
);
} else {
println!(
"Next: copy {} to {} and put your devices in it. Then: mw before <TICKET> -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<Report> {
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,
})
}
5 changes: 3 additions & 2 deletions crates/mw-check/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
12 changes: 7 additions & 5 deletions crates/mw-check/src/inventory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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 {
Expand Down Expand Up @@ -97,7 +98,8 @@ pub struct InventoryError(pub String);
pub fn load_inventory(path: &Path) -> Result<Inventory, InventoryError> {
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()
)));
Expand Down
10 changes: 7 additions & 3 deletions crates/mw-check/src/main.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -22,15 +23,15 @@
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)]
#[command(
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)]
Expand All @@ -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/<TICKET>/Precheck/)
#[command(aliases = ["pre", "precheck"])]
Before(precheck::Args),
Expand All @@ -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),
Expand Down
62 changes: 62 additions & 0 deletions crates/mw-check/tests/init_folder.rs
Original file line number Diff line number Diff line change
@@ -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}");
}
Loading