From bc83b93e5f9d9638334012479b9876ccfa3fe4e6 Mon Sep 17 00:00:00 2001 From: Jeremy Cline Date: Thu, 11 Jul 2024 12:05:27 -0400 Subject: [PATCH] Implement a builder-style provisioning API (#91) Implement a single `Provision` interface This provides a unified interface to provision the host with the option to select the tool used for setting the hostname, creating the user, and so on. By default, the library will try all the provisioning methods it knows of until one succeeds. Users of the library can optionally specify a subset to attempt when provisioning. This allows users to decide which tool or tools to use when provisioning. Some feature flags have been added to `azure-init` which enable provisioning with a tool, letting you build binaries for a particular platform relatively easily. --- Cargo.toml | 9 + libazureinit/Cargo.toml | 1 + libazureinit/src/error.rs | 12 ++ libazureinit/src/imds.rs | 9 + libazureinit/src/lib.rs | 10 +- libazureinit/src/provision/hostname.rs | 46 +++++ libazureinit/src/provision/mod.rs | 179 ++++++++++++++++++ libazureinit/src/provision/password.rs | 51 +++++ .../src/{user.rs => provision/ssh.rs} | 13 -- libazureinit/src/provision/user.rs | 145 ++++++++++++++ src/main.rs | 38 ++-- tests/functional_tests.rs | 39 +--- 12 files changed, 488 insertions(+), 64 deletions(-) create mode 100644 libazureinit/src/provision/hostname.rs create mode 100644 libazureinit/src/provision/mod.rs create mode 100644 libazureinit/src/provision/password.rs rename libazureinit/src/{user.rs => provision/ssh.rs} (94%) create mode 100644 libazureinit/src/provision/user.rs diff --git a/Cargo.toml b/Cargo.toml index 2b1a59e..7689cd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,3 +32,12 @@ path = "tests/functional_tests.rs" members = [ "libazureinit", ] + +[features] +passwd = [] +hostnamectl = [] +useradd = [] + +systemd_linux = ["passwd", "hostnamectl", "useradd"] + +default = ["systemd_linux"] diff --git a/libazureinit/Cargo.toml b/libazureinit/Cargo.toml index 59241dd..08d28ed 100644 --- a/libazureinit/Cargo.toml +++ b/libazureinit/Cargo.toml @@ -18,6 +18,7 @@ serde_json = "1.0.96" nix = {version = "0.29.0", features = ["fs", "user"]} block-utils = "0.11.1" tracing = "0.1.40" +strum = { version = "0.26.3", features = ["derive"] } [dev-dependencies] tempfile = "3" diff --git a/libazureinit/src/error.rs b/libazureinit/src/error.rs index f0ebd23..cc96626 100644 --- a/libazureinit/src/error.rs +++ b/libazureinit/src/error.rs @@ -31,4 +31,16 @@ pub enum Error { NonEmptyPassword, #[error("Unable to get list of block devices")] BlockUtils(#[from] block_utils::BlockUtilsError), + #[error( + "Failed to set the hostname; none of the provided backends succeeded" + )] + NoHostnameProvisioner, + #[error( + "Failed to create a user; none of the provided backends succeeded" + )] + NoUserProvisioner, + #[error( + "Failed to set the user password; none of the provided backends succeeded" + )] + NoPasswordProvisioner, } diff --git a/libazureinit/src/imds.rs b/libazureinit/src/imds.rs index 65451d5..69831b5 100644 --- a/libazureinit/src/imds.rs +++ b/libazureinit/src/imds.rs @@ -57,6 +57,15 @@ pub struct PublicKeys { pub path: String, } +impl From<&str> for PublicKeys { + fn from(value: &str) -> Self { + Self { + key_data: value.to_string(), + path: String::new(), + } + } +} + /// Deserializer that handles the string "true" and "false" that the IMDS API returns. fn string_bool<'de, D>(deserializer: D) -> Result where diff --git a/libazureinit/src/lib.rs b/libazureinit/src/lib.rs index 04c0cb9..d5b5b67 100644 --- a/libazureinit/src/lib.rs +++ b/libazureinit/src/lib.rs @@ -1,12 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -pub mod distro; pub mod error; pub mod goalstate; pub mod imds; pub mod media; -pub mod user; + +mod provision; +pub use provision::{ + hostname::Provisioner as HostnameProvisioner, + password::Provisioner as PasswordProvisioner, + user::{Provisioner as UserProvisioner, User}, + Provision, +}; // Re-export as the Client is used in our API. pub use reqwest; diff --git a/libazureinit/src/provision/hostname.rs b/libazureinit/src/provision/hostname.rs new file mode 100644 index 0000000..5f66515 --- /dev/null +++ b/libazureinit/src/provision/hostname.rs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::process::Command; + +use tracing::instrument; + +use crate::error::Error; + +/// Available tools to set the host's hostname. +#[derive(strum::EnumIter, Debug, Clone)] +#[non_exhaustive] +pub enum Provisioner { + /// Use the `hostnamectl` command from `systemd`. + Hostnamectl, + #[cfg(test)] + FakeHostnamectl, +} + +impl Provisioner { + pub(crate) fn set(&self, hostname: impl AsRef) -> Result<(), Error> { + match self { + Self::Hostnamectl => hostnamectl(hostname.as_ref()), + #[cfg(test)] + Self::FakeHostnamectl => Ok(()), + } + } +} + +#[instrument(skip_all)] +fn hostnamectl(hostname: &str) -> Result<(), Error> { + let path_hostnamectl = env!("PATH_HOSTNAMECTL"); + + let status = Command::new(path_hostnamectl) + .arg("set-hostname") + .arg(hostname) + .status()?; + if status.success() { + Ok(()) + } else { + Err(Error::SubprocessFailed { + command: path_hostnamectl.to_string(), + status, + }) + } +} diff --git a/libazureinit/src/provision/mod.rs b/libazureinit/src/provision/mod.rs new file mode 100644 index 0000000..d51ca0c --- /dev/null +++ b/libazureinit/src/provision/mod.rs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +pub mod hostname; +pub mod password; +pub(crate) mod ssh; +pub mod user; + +use strum::IntoEnumIterator; +use tracing::instrument; + +use crate::error::Error; +use crate::User; + +/// The interface for applying the desired configuration to the host. +/// +/// By default, all known tools for provisioning a particular resource are tried +/// until one succeeds. Particular tools can be selected via the +/// `*_provisioners()` methods ([`Provision::hostname_provisioners`], +/// [`Provision::user_provisioners`], etc). +/// +/// To actually apply the configuration, use [`Provision::provision`]. +#[derive(Clone)] +pub struct Provision { + hostname: String, + user: User, + hostname_backends: Option>, + user_backends: Option>, + password_backends: Option>, +} + +impl Provision { + pub fn new(hostname: impl Into, user: User) -> Self { + Self { + hostname: hostname.into(), + user, + hostname_backends: None, + user_backends: None, + password_backends: None, + } + } + + /// Specify the ways to set the virtual machine's hostname. + /// + /// By default, all known methods will be attempted. Use this function to + /// restrict which methods are attempted. These will be attempted in the + /// order provided until one succeeds. + pub fn hostname_provisioners( + mut self, + backends: impl Into>, + ) -> Self { + self.hostname_backends = Some(backends.into()); + self + } + + /// Specify the ways to create a user in the virtual machine + /// + /// By default, all known methods will be attempted. Use this function to + /// restrict which methods are attempted. These will be attempted in the + /// order provided until one succeeds. + pub fn user_provisioners( + mut self, + backends: impl Into>, + ) -> Self { + self.user_backends = Some(backends.into()); + self + } + + /// Specify the ways to set a users password. + /// + /// By default, all known methods will be attempted. Use this function to + /// restrict which methods are attempted. These will be attempted in the + /// order provided until one succeeds. Only relevant if a password has been + /// provided with the [`User`]. + pub fn password_provisioners( + mut self, + backend: impl Into>, + ) -> Self { + self.password_backends = Some(backend.into()); + self + } + + /// Provision the host. + /// + /// Provisioning can fail if the host lacks the necessary tools. For example, + /// if there is no `useradd` command on the system's `PATH`, or if the command + /// returns an error, this will return an error. It does not attempt to undo + /// partial provisioning. + #[instrument(skip_all)] + pub fn provision(self) -> Result<(), Error> { + self.user_backends + .unwrap_or_else(|| user::Provisioner::iter().collect()) + .iter() + .find_map(|backend| { + backend + .create(&self.user) + .map_err(|e| { + tracing::info!( + error=?e, + backend=?backend, + resource="user", + "Provisioning did not succeed" + ); + e + }) + .ok() + }) + .ok_or(Error::NoUserProvisioner)?; + + self.password_backends + .unwrap_or_else(|| password::Provisioner::iter().collect()) + .iter() + .find_map(|backend| { + backend + .set(&self.user) + .map_err(|e| { + tracing::info!( + error=?e, + backend=?backend, + resource="password", + "Provisioning did not succeed" + ); + e + }) + .ok() + }) + .ok_or(Error::NoPasswordProvisioner)?; + + if !self.user.ssh_keys.is_empty() { + let user = nix::unistd::User::from_name(&self.user.name)?.ok_or( + Error::UserMissing { + user: self.user.name, + }, + )?; + ssh::provision_ssh(&user, &self.user.ssh_keys)?; + } + + self.hostname_backends + .unwrap_or_else(|| hostname::Provisioner::iter().collect()) + .iter() + .find_map(|backend| { + backend + .set(&self.hostname) + .map_err(|e| { + tracing::info!( + error=?e, + backend=?backend, + resource="hostname", + "Provisioning did not succeed" + ); + e + }) + .ok() + }) + .ok_or(Error::NoHostnameProvisioner)?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + + use crate::User; + + use super::{hostname, password, user, Provision}; + + #[test] + fn test_successful_provision() { + let _p = Provision::new( + "my-hostname".to_string(), + User::new("azureuser", vec![]), + ) + .hostname_provisioners([hostname::Provisioner::FakeHostnamectl]) + .user_provisioners([user::Provisioner::FakeUseradd]) + .password_provisioners([password::Provisioner::FakePasswd]) + .provision() + .unwrap(); + } +} diff --git a/libazureinit/src/provision/password.rs b/libazureinit/src/provision/password.rs new file mode 100644 index 0000000..3be8f3f --- /dev/null +++ b/libazureinit/src/provision/password.rs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::process::Command; + +use tracing::instrument; + +use crate::{error::Error, User}; + +/// Available tools to set the user's password (if a password is provided). +#[derive(strum::EnumIter, Debug, Clone)] +#[non_exhaustive] +pub enum Provisioner { + /// Use the `passwd` command from `shadow-utils`. + Passwd, + #[cfg(test)] + FakePasswd, +} + +impl Provisioner { + pub(crate) fn set(&self, user: &User) -> Result<(), Error> { + match self { + Self::Passwd => passwd(user), + #[cfg(test)] + Self::FakePasswd => Ok(()), + } + } +} + +#[instrument(skip_all)] +fn passwd(user: &User) -> Result<(), Error> { + let path_passwd = env!("PATH_PASSWD"); + + if user.password.is_none() { + let status = Command::new(path_passwd) + .arg("-d") + .arg(&user.name) + .status()?; + if !status.success() { + return Err(Error::SubprocessFailed { + command: path_passwd.to_string(), + status, + }); + } + } else { + // creating user with a non-empty password is not allowed. + return Err(Error::NonEmptyPassword); + } + + Ok(()) +} diff --git a/libazureinit/src/user.rs b/libazureinit/src/provision/ssh.rs similarity index 94% rename from libazureinit/src/user.rs rename to libazureinit/src/provision/ssh.rs index 31c541e..e31c937 100644 --- a/libazureinit/src/user.rs +++ b/libazureinit/src/provision/ssh.rs @@ -12,19 +12,6 @@ use tracing::instrument; use crate::error::Error; use crate::imds::PublicKeys; -pub fn set_ssh_keys( - keys: Vec, - username: impl AsRef, -) -> Result<(), Error> { - let user = - nix::unistd::User::from_name(username.as_ref())?.ok_or_else(|| { - Error::UserMissing { - user: username.as_ref().to_string(), - } - })?; - provision_ssh(&user, &keys) -} - #[instrument(skip_all, name = "ssh")] pub(crate) fn provision_ssh( user: &nix::unistd::User, diff --git a/libazureinit/src/provision/user.rs b/libazureinit/src/provision/user.rs new file mode 100644 index 0000000..4b9ee2b --- /dev/null +++ b/libazureinit/src/provision/user.rs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::process::Command; + +use tracing::instrument; + +use crate::{error::Error, imds::PublicKeys}; + +/// The user and its related configuration to create on the host. +/// +/// A bare minimum user includes a name and a set of SSH public keys to allow the user to +/// log into the host. Additional configuration includes a set of supplementary groups to +/// add the user to, and a password to set for the user. +/// +/// By default, the user is included in the `wheel` group which is often used to +/// grant administrator privileges via the `sudo` command. This can be changed with the +/// [`User::with_groups`] method. +/// +/// # Example +/// +/// ``` +/// # use libazureinit::User; +/// let user = User::new("azure-user", ["ssh-ed25519 NOTAREALKEY".into()]) +/// .with_groups(["wheel".to_string(), "dialout".to_string()]); +/// ``` +#[derive(Clone)] +pub struct User { + pub(crate) name: String, + pub(crate) groups: Vec, + pub(crate) ssh_keys: Vec, + pub(crate) password: Option, +} + +impl core::fmt::Debug for User { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // This is manually implemented to avoid printing the password if it's set + f.debug_struct("User") + .field("name", &self.name) + .field("groups", &self.groups) + .field("ssh_keys", &self.ssh_keys) + .field("password", &self.password.is_some()) + .finish() + } +} + +impl User { + /// Configure the user being provisioned on the host. + /// + /// What constitutes a valid username depends on the host configuration and + /// no validation will occur prior to provisioning the host. + pub fn new( + name: impl Into, + ssh_keys: impl Into>, + ) -> Self { + Self { + name: name.into(), + groups: vec!["wheel".into()], + ssh_keys: ssh_keys.into(), + password: None, + } + } + + /// Set a password for the user; this is optional. + pub fn with_password(mut self, password: impl Into) -> Self { + self.password = Some(password.into()); + self + } + + /// A list of supplemental group names to add the user to. + /// + /// If any of the groups do not exist on the host, provisioning will fail. + pub fn with_groups(mut self, groups: impl Into>) -> Self { + self.groups = groups.into(); + self + } +} + +/// Available tools to create the user. +#[derive(strum::EnumIter, Debug, Clone)] +#[non_exhaustive] +pub enum Provisioner { + /// Use the `useradd` command from `shadow-utils`. + Useradd, + #[cfg(test)] + FakeUseradd, +} + +impl Provisioner { + pub(crate) fn create(&self, user: &User) -> Result<(), Error> { + match self { + Self::Useradd => useradd(user), + #[cfg(test)] + Self::FakeUseradd => Ok(()), + } + } +} + +#[instrument(skip_all)] +fn useradd(user: &User) -> Result<(), Error> { + let path_useradd = env!("PATH_USERADD"); + let home_path = format!("/home/{}", user.name); + + let status = Command::new(path_useradd) + .arg(&user.name) + .arg("--comment") + .arg( + "Provisioning agent created this user based on username provided in IMDS", + ) + .arg("--groups") + .arg(user.groups.join(",")) + .arg("-d") + .arg(home_path) + .arg("-m") + .status()?; + if !status.success() { + return Err(Error::SubprocessFailed { + command: path_useradd.to_string(), + status, + }); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::User; + + #[test] + fn password_skipped_in_debug() { + let user_with_password = + User::new("azureuser", []).with_password("hunter2"); + let user_without_password = User::new("azureuser", []); + + assert_eq!( + "User { name: \"azureuser\", groups: [\"wheel\"], ssh_keys: [], password: true }", + format!("{:?}", user_with_password) + ); + assert_eq!( + "User { name: \"azureuser\", groups: [\"wheel\"], ssh_keys: [], password: false }", + format!("{:?}", user_without_password) + ); + } +} diff --git a/src/main.rs b/src/main.rs index 66bb4f6..3eb1a77 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,13 +6,13 @@ use std::process::ExitCode; use anyhow::Context; use libazureinit::imds::InstanceMetadata; +use libazureinit::User; use libazureinit::{ - distro, error::Error as LibError, goalstate, imds, media, media::Environment, reqwest::{header, Client}, - user, + HostnameProvisioner, PasswordProvisioner, Provision, UserProvisioner, }; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -88,24 +88,22 @@ async fn provision() -> Result<(), anyhow::Error> { let instance_metadata = imds::query(&client).await?; let username = get_username(&instance_metadata, &get_environment()?)?; - - let mut file_path = "/home/".to_string(); - file_path.push_str(username.as_str()); - - // always pass an empty password - distro::create_user_with_useradd(username.as_str()) - .with_context(|| format!("Unabled to create user '{username}'"))?; - distro::set_password_with_passwd(username.as_str(), "").with_context( - || format!("Unabled to set an empty password for user '{username}'"), - )?; - - user::set_ssh_keys(instance_metadata.compute.public_keys, &username) - .with_context(|| "Failed to write ssh public keys.")?; - - distro::set_hostname_with_hostnamectl( - instance_metadata.compute.os_profile.computer_name.as_str(), - ) - .with_context(|| "Failed to set hostname.")?; + let user = User::new(username, instance_metadata.compute.public_keys); + + Provision::new(instance_metadata.compute.os_profile.computer_name, user) + .hostname_provisioners([ + #[cfg(feature = "hostnamectl")] + HostnameProvisioner::Hostnamectl, + ]) + .user_provisioners([ + #[cfg(feature = "useradd")] + UserProvisioner::Useradd, + ]) + .password_provisioners([ + #[cfg(feature = "passwd")] + PasswordProvisioner::Passwd, + ]) + .provision()?; let vm_goalstate = goalstate::get_goalstate(&client) .await diff --git a/tests/functional_tests.rs b/tests/functional_tests.rs index 23511be..f49256a 100644 --- a/tests/functional_tests.rs +++ b/tests/functional_tests.rs @@ -2,10 +2,11 @@ // Licensed under the MIT License. use libazureinit::imds::PublicKeys; +use libazureinit::User; use libazureinit::{ - distro, goalstate, + goalstate, reqwest::{header, Client}, - user, + HostnameProvisioner, PasswordProvisioner, Provision, UserProvisioner, }; use std::env; @@ -51,22 +52,6 @@ async fn main() { let username = &cli_args[1]; - let mut file_path = "/home/".to_string(); - file_path.push_str(username.as_str()); - - println!(); - println!( - "Attempting to create user {} without password", - username.as_str() - ); - - distro::create_user_with_useradd(username.as_str()) - .expect("Failed to create user for user '{username}'"); - distro::set_password_with_passwd(username.as_str(), "") - .expect("Unabled to set an empty passord for user '{username}'"); - - println!("User {} was successfully created", username.as_str()); - let keys: Vec = vec![ PublicKeys { path: "/path/to/.ssh/keys/".to_owned(), @@ -82,18 +67,14 @@ async fn main() { }, ]; - println!(); - println!("Attempting to create user's SSH authorized_keys"); - - user::set_ssh_keys(keys, username).unwrap(); - - println!(); - println!("Attempting to set the VM hostname"); - - distro::set_hostname_with_hostnamectl("test-hostname-set") - .expect("Failed to set hostname"); + Provision::new("my-hostname".to_string(), User::new(username, keys)) + .hostname_provisioners([HostnameProvisioner::Hostnamectl]) + .user_provisioners([UserProvisioner::Useradd]) + .password_provisioners([PasswordProvisioner::Passwd]) + .provision() + .expect("Failed to provision host"); - println!("VM hostname successfully set"); + println!("VM successfully provisioned"); println!(); println!("**********************************");