diff --git a/Cargo.lock b/Cargo.lock index ca2ef94c8ac..a2b0e899973 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,6 +142,20 @@ dependencies = [ "term", ] +[[package]] +name = "assert_cmd" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9834fcc22e0874394a010230586367d4a3e9f11b560f469262678547e1d2575e" +dependencies = [ + "bstr 1.1.0", + "doc-comment", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "assert_matches" version = "1.5.0" @@ -503,6 +517,18 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "bstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45ea9b00a7b3f2988e9a65ad3917e62123c38dba709b666506207be96d1790b" +dependencies = [ + "memchr", + "once_cell", + "regex-automata", + "serde", +] + [[package]] name = "buf-list" version = "0.1.3" @@ -2133,7 +2159,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a1e17342619edbc21a964c2afbeb6c820c6a2560032872f397bb97ea127bd0a" dependencies = [ "aho-corasick", - "bstr", + "bstr 0.2.17", "fnv", "log", "regex", @@ -2357,7 +2383,7 @@ version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f25cfb6def593d43fae1ead24861f217e93bc70768a45cc149a69b5f049df4" dependencies = [ - "bstr", + "bstr 0.2.17", "bytes", "crossbeam-channel", "form_urlencoded", @@ -5598,6 +5624,12 @@ dependencies = [ "keccak", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "shlex" version = "1.1.0" @@ -7104,6 +7136,8 @@ name = "wicket" version = "0.1.0" dependencies = [ "anyhow", + "assert_cmd", + "camino", "clap 4.0.32", "crossterm", "futures", @@ -7113,8 +7147,10 @@ dependencies = [ "serde", "serde_json", "sha3", + "shell-words", "slog", "slog-async", + "slog-envlogger", "slog-term", "snafu", "tar", diff --git a/Cargo.toml b/Cargo.toml index 518a39502c7..98882ce81a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,7 @@ resolver = "2" anyhow = "1.0" api_identity = { path = "api_identity" } assert_matches = "1.5.0" +assert_cmd = "2.0.8" async-bb8-diesel = { git = "https://github.com/oxidecomputer/async-bb8-diesel", rev = "7944dafc8a36dc6e20a1405eca59d04662de2bb7" } async-trait = "0.1.60" authz-macros = { path = "nexus/authz-macros" } @@ -205,6 +206,7 @@ serde_urlencoded = "0.7.1" serde_with = "2.2.0" serial_test = "0.10" sha3 = "0.10.6" +shell-words = "1.1.0" signal-hook = "0.3" signal-hook-tokio = { version = "0.3", features = [ "futures-v0_3" ] } sled = "0.34" diff --git a/wicket/Cargo.toml b/wicket/Cargo.toml index 0f314569415..c344ac83abf 100644 --- a/wicket/Cargo.toml +++ b/wicket/Cargo.toml @@ -8,6 +8,7 @@ default-run = "wicket" [dependencies] anyhow.workspace = true +camino.workspace = true clap.workspace = true crossterm = { version = "0.25.0", features = ["event-stream"] } futures.workspace = true @@ -17,8 +18,10 @@ semver = { version = "1.0.16", features = ["std", "serde"] } serde.workspace = true serde_json.workspace = true sha3.workspace = true +shell-words.workspace = true slog.workspace = true slog-async.workspace = true +slog-envlogger.workspace = true slog-term.workspace = true snafu.workspace = true tar.workspace = true @@ -29,6 +32,7 @@ tui = "0.19.0" wicketd-client.workspace = true [dev-dependencies] +assert_cmd.workspace = true tempfile.workspace = true [[bin]] diff --git a/wicket/README.md b/wicket/README.md index d9ffb8019e8..667cd8bd88d 100644 --- a/wicket/README.md +++ b/wicket/README.md @@ -122,3 +122,21 @@ functionality implemented. All the inventory and power data shown in the and RSS. Lastly, we don't have a way to take rack updates and install them, or initialize the rack (including trust quorum). This is a lot of functionality that will be implemented incrementally. + +# Testing wicket as a login shell + +Wicket is meant to be used as a login shell. To test the login shell on a local Unix machine: + +1. Make the `wicket` available globally, at e.g. `/usr/local/bin/wicket`: + * If your build directory is globally readable, create a symlink to `wicket` in a well-known location. From omicron's root, run: `sudo ln -s $(readlink -f target/debug/wicket) /usr/local/bin/wicket` + * If it isn't globally accessible, run `sudo cp target/debug/wicket /usr/local/bin`. (You'll have to copy `wicket` each time you build it.) +2. Add a new user to test against, for example `wicket-test`: + 1. Add a group for the new user: `groupadd wicket-test`. + 2. Add the user: `sudo useradd -m -g wicket-test -s /usr/local/bin/wicket wicket-test` + +At this point, you can use `sudo -u wicket-test -i` (Linux) or `pfexec su - wicket-test` (illumos) to test wicket as a login shell. + +* A plain `sudo -u wicket-test -i` will show the TUI. +* `sudo -u wicket-test -i upload ...` will let you upload an artifact over stdin. + +If you'd like to test connections over ssh, add your ssh key to the new user's `.ssh/authorized_keys`, then run `ssh wicket-test@localhost [upload ...]`. diff --git a/wicket/src/bin/wicket.rs b/wicket/src/bin/wicket.rs index 5e536f24e9d..fe7877ecb09 100644 --- a/wicket/src/bin/wicket.rs +++ b/wicket/src/bin/wicket.rs @@ -2,12 +2,11 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -use std::error::Error; -use wicket::Wizard; +use anyhow::Result; +use clap::Parser; +use wicket::WicketApp; -fn main() -> Result<(), Box> { - let mut wizard = Wizard::new(); - wizard.run()?; - - Ok(()) +fn main() -> Result<()> { + let app: WicketApp = Parser::parse(); + app.exec() } diff --git a/wicket/src/dispatch.rs b/wicket/src/dispatch.rs new file mode 100644 index 00000000000..e9ad4d395bd --- /dev/null +++ b/wicket/src/dispatch.rs @@ -0,0 +1,123 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Code that manages command dispatch for wicket. + +use std::net::SocketAddrV6; + +use anyhow::{bail, Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use clap::Parser; +use slog::Drain; + +use crate::{upload::UploadArgs, wizard::Wizard}; + +#[derive(Debug, Parser)] +#[command(version, author = "Oxide Computer Company")] +pub struct WicketApp { + /// Login shell arguments. + /// + /// Wicket is designed to be a login shell for use over ssh. If no arguments are specified, + /// wicket behaves like a TUI. However, if arguments are specified with "-c" (as in other login + /// shells e.g. bash -c), wicketd accepts an upload command. + /// + /// Login shell arguments are provided in a quoted form, so we expect a single String here. + /// This string is split using shell quoting logic to get the actual arguments. + #[arg(short = 'c', allow_hyphen_values = true)] + shell_args: Option, +} + +#[derive(Debug, Parser)] +enum ShellCommand { + /// Upload an artifact to wicketd. + Upload(UploadArgs), +} + +impl WicketApp { + /// Executes the command. + pub fn exec(self) -> Result<()> { + // TODO: make this configurable? + let wicketd_addr: SocketAddrV6 = "[::1]:8000".parse().unwrap(); + + match self.shell_args { + Some(shell_args) => { + let args = + shell_words::split(&shell_args).with_context(|| { + format!("could not parse shell arguments from input {shell_args}") + })?; + let log = setup_log(&log_path()?, WithStderr::Yes)?; + // parse_from uses the the first argument as the command name. Insert "wicket" as + // the command name. + let args = ShellCommand::parse_from( + std::iter::once("wicket".to_owned()).chain(args), + ); + match args { + ShellCommand::Upload(args) => args.exec(log, wicketd_addr), + } + } + None => { + // Do not expose standard error since it'll be on top of the TUI. + let log = setup_log(&log_path()?, WithStderr::No)?; + // Not invoked with "-c" -- run the TUI wizard. + Wizard::new(log, wicketd_addr).run() + } + } + } +} + +fn setup_log( + path: &Utf8Path, + with_stderr: WithStderr, +) -> anyhow::Result { + let file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path) + .with_context(|| format!("error opening log file {path}"))?; + + let decorator = slog_term::PlainDecorator::new(file); + let drain = slog_term::FullFormat::new(decorator).build().fuse(); + + let drain = match with_stderr { + WithStderr::Yes => { + let stderr_drain = stderr_env_drain("RUST_LOG"); + let drain = slog::Duplicate::new(drain, stderr_drain).fuse(); + slog_async::Async::new(drain).build().fuse() + } + WithStderr::No => slog_async::Async::new(drain).build().fuse(), + }; + + Ok(slog::Logger::root(drain, slog::o!())) +} + +#[derive(Copy, Clone, Debug)] +enum WithStderr { + Yes, + No, +} + +fn log_path() -> Result { + match std::env::var("WICKET_LOG_PATH") { + Ok(path) => Ok(path.into()), + Err(std::env::VarError::NotPresent) => Ok("/tmp/wicket.log".into()), + Err(std::env::VarError::NotUnicode(_)) => { + bail!("WICKET_LOG_PATH is not valid unicode"); + } + } +} + +fn stderr_env_drain(env_var: &str) -> impl Drain { + let stderr_decorator = slog_term::TermDecorator::new().build(); + let stderr_drain = + slog_term::FullFormat::new(stderr_decorator).build().fuse(); + let mut builder = slog_envlogger::LogBuilder::new(stderr_drain); + if let Ok(s) = std::env::var(env_var) { + builder = builder.parse(&s); + } else { + // Log at the info level by default. + builder = builder.filter(None, slog::FilterLevel::Info); + } + builder.build() +} diff --git a/wicket/src/lib.rs b/wicket/src/lib.rs index 2da59d13367..f3ccea9c7c4 100644 --- a/wicket/src/lib.rs +++ b/wicket/src/lib.rs @@ -10,11 +10,14 @@ //! in an intuitive manner. pub(crate) mod defaults; +mod dispatch; pub(crate) mod inventory; mod screens; pub mod update; +mod upload; mod wicketd; mod widgets; mod wizard; +pub use crate::dispatch::*; pub use crate::wizard::*; diff --git a/wicket/src/upload.rs b/wicket/src/upload.rs new file mode 100644 index 00000000000..9114b8a9140 --- /dev/null +++ b/wicket/src/upload.rs @@ -0,0 +1,96 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Support for uploading artifacts to wicketd. + +use std::net::SocketAddrV6; + +use anyhow::{Context, Result}; +use clap::Args; +use tokio::io::AsyncReadExt; + +use crate::wicketd::create_wicketd_client; + +#[derive(Debug, Args)] +pub(crate) struct UploadArgs { + /// Artifact name to upload + name: String, + + /// Artifact version to upload + version: String, + + /// Do not perform the upload to wicketd. + #[clap(long)] + no_upload: bool, +} + +impl UploadArgs { + pub(crate) fn exec( + self, + log: slog::Logger, + wicketd_addr: SocketAddrV6, + ) -> Result<()> { + let runtime = + tokio::runtime::Runtime::new().context("creating tokio runtime")?; + runtime.block_on(self.do_upload(log, wicketd_addr)) + } + + async fn do_upload( + &self, + log: slog::Logger, + wicketd_addr: SocketAddrV6, + ) -> Result<()> { + // Read the entire artifact from stdin into memory. + let mut artifact_bytes = Vec::new(); + tokio::io::stdin() + .read_to_end(&mut artifact_bytes) + .await + .with_context(|| { + format!( + "error reading artifact {}:{} from stdin", + self.name, self.version + ) + })?; + + let artifact_bytes_len = artifact_bytes.len(); + + slog::info!( + log, + "read artifact {}:{} ({artifact_bytes_len} bytes) from stdin", + self.name, + self.version, + ); + + // TODO: perform validation on the artifact + + if self.no_upload { + slog::info!( + log, + "not uploading artifact to wicketd (--no-upload passed in)" + ); + } else { + slog::info!(log, "uploading artifact to wicketd"); + let wicketd_client = create_wicketd_client(&log, wicketd_addr); + + wicketd_client + .put_artifact(&self.name, &self.version, artifact_bytes) + .await + .with_context(|| { + format!( + "error uploading artifact {}:{} to wicketd", + self.name, self.version, + ) + })?; + + slog::info!( + log, + "successfully uploaded {}:{} ({artifact_bytes_len} bytes) to wicketd", + self.name, + self.version, + ); + } + + Ok(()) + } +} diff --git a/wicket/src/wicketd.rs b/wicket/src/wicketd.rs index 51db0ab741f..2b8630e7d8a 100644 --- a/wicket/src/wicketd.rs +++ b/wicket/src/wicketd.rs @@ -50,22 +50,7 @@ impl WicketdManager { ) -> (WicketdHandle, WicketdManager) { let log = log.new(o!("component" => "WicketdManager")); let (tx, rx) = tokio::sync::mpsc::channel(CHANNEL_CAPACITY); - let endpoint = - format!("http://[{}]:{}", wicketd_addr.ip(), wicketd_addr.port()); - - let timeout = - std::time::Duration::from_millis(WICKETD_TIMEOUT_MS.into()); - let client = reqwest::ClientBuilder::new() - .connect_timeout(timeout) - .timeout(timeout) - .build() - .unwrap(); - - let inventory_client = wicketd_client::Client::new_with_client( - &endpoint, - client, - log.clone(), - ); + let inventory_client = create_wicketd_client(&log, wicketd_addr); let inventory = RackV1Inventory { sps: vec![] }; let handle = WicketdHandle { tx }; let manager = @@ -95,6 +80,22 @@ impl WicketdManager { } } +pub(crate) fn create_wicketd_client( + log: &Logger, + wicketd_addr: SocketAddrV6, +) -> wicketd_client::Client { + let endpoint = + format!("http://[{}]:{}", wicketd_addr.ip(), wicketd_addr.port()); + let timeout = std::time::Duration::from_millis(WICKETD_TIMEOUT_MS.into()); + let client = reqwest::ClientBuilder::new() + .connect_timeout(timeout) + .timeout(timeout) + .build() + .unwrap(); + + wicketd_client::Client::new_with_client(&endpoint, client, log.clone()) +} + async fn poll_inventory( log: &Logger, client: wicketd_client::Client, diff --git a/wicket/src/wizard.rs b/wicket/src/wizard.rs index 91025c08ac7..dec8fe1eddb 100644 --- a/wicket/src/wizard.rs +++ b/wicket/src/wizard.rs @@ -13,7 +13,7 @@ use crossterm::terminal::{ LeaveAlternateScreen, }; use futures::StreamExt; -use slog::{error, info, Drain}; +use slog::{error, info}; use std::io::{stdout, Stdout}; use std::net::SocketAddrV6; use std::sync::mpsc::{channel, Receiver, Sender}; @@ -91,10 +91,7 @@ pub struct Wizard { #[allow(clippy::new_without_default)] impl Wizard { - pub fn new() -> Wizard { - // TODO: make this configurable? - let wicketd_addr: SocketAddrV6 = "[::1]:8000".parse().unwrap(); - let log = Self::setup_log("/tmp/wicket.log").unwrap(); + pub fn new(log: slog::Logger, wicketd_addr: SocketAddrV6) -> Wizard { let screens = Screens::new(&log); let (events_tx, events_rx) = channel(); let state = State::new(); @@ -120,20 +117,6 @@ impl Wizard { } } - pub fn setup_log(path: &str) -> anyhow::Result { - let file = std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(path)?; - - let decorator = slog_term::PlainDecorator::new(file); - let drain = slog_term::FullFormat::new(decorator).build().fuse(); - let drain = slog_async::Async::new(drain).build().fuse(); - - Ok(slog::Logger::root(drain, slog::o!())) - } - pub fn run(&mut self) -> anyhow::Result<()> { self.start_tokio_runtime(); enable_raw_mode()?; diff --git a/wicket/tests/integration_tests/command_tests.rs b/wicket/tests/integration_tests/command_tests.rs new file mode 100644 index 00000000000..3535e95f1ed --- /dev/null +++ b/wicket/tests/integration_tests/command_tests.rs @@ -0,0 +1,32 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use std::path::Path; + +use assert_cmd::Command; + +#[test] +fn test_wicket_shell_like() { + let tempdir = tempfile::tempdir().unwrap(); + + let mut cmd = make_cmd(tempdir.path()); + cmd.args(["-c", "help"]); + cmd.assert().success(); + + let mut cmd = make_cmd(tempdir.path()); + cmd.args(["-c", "--help"]); + cmd.assert().success(); + + let mut cmd = make_cmd(tempdir.path()); + cmd.args(["-c", "upload foo 0.1.0 --no-upload"]).write_stdin("upload-test"); + cmd.assert().success(); +} + +fn make_cmd(tempdir: &Path) -> Command { + let mut cmd = Command::cargo_bin("wicket").unwrap(); + // Set the log path to the temp dir, because the default is to log to + // /tmp/wicket.log (which might be owned by a different user). + cmd.env("WICKET_LOG_PATH", tempdir.join("wicket.log")); + cmd +} diff --git a/wicket/tests/integration_tests/mod.rs b/wicket/tests/integration_tests/mod.rs new file mode 100644 index 00000000000..fdf11b8cd75 --- /dev/null +++ b/wicket/tests/integration_tests/mod.rs @@ -0,0 +1,5 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +mod command_tests; diff --git a/wicket/tests/mod.rs b/wicket/tests/mod.rs new file mode 100644 index 00000000000..42343f04f9e --- /dev/null +++ b/wicket/tests/mod.rs @@ -0,0 +1,17 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Integration tests for the wicket client. +//! +//! Why use this weird layer of indirection, you might ask? Cargo chooses to +//! compile *each file* within the "tests/" subdirectory as a separate crate. +//! This means that doing "file-granularity" conditional compilation is +//! difficult, since a file like "test_for_illumos_only.rs" would get compiled +//! and tested regardless of the contents of "mod.rs". +//! +//! However, by lumping all tests into a submodule, all integration tests are +//! joined into a single crate, which itself can filter individual files +//! by (for example) choice of target OS. + +mod integration_tests;