Skip to content

Commit

Permalink
Define a User type for holding user provisioning details
Browse files Browse the repository at this point in the history
The list of supplementary groups isn't portable and needs to be exposed
in the public API somehow. Rather than expand the Provision API to
include it directly, add a User type that holds the username, password,
ssh keys, and supplementary group information and accept that in the
provision interface.

To start with I left the default group list at "wheel" since it covers
most distributions and if the user is in wheel they can add themselves
to the other groups if they need it later.
  • Loading branch information
jeremycline committed Jul 9, 2024
1 parent 966ae29 commit 624ce3e
Show file tree
Hide file tree
Showing 7 changed files with 147 additions and 80 deletions.
1 change: 0 additions & 1 deletion libazureinit/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,5 @@ fn main() {
println!("cargo:rustc-env=PATH_HOSTNAMECTL=hostnamectl");
println!("cargo:rustc-env=PATH_USERADD=useradd");
// The list of supplementary groups to add a provisioned user to.
println!("cargo:rustc-env=USERADD_GROUPS=adm,audio,cdrom,dialout,dip,floppy,lxd,netdev,plugdev,sudo,video");
println!("cargo:rustc-env=PATH_PASSWD=passwd");
}
3 changes: 2 additions & 1 deletion libazureinit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ mod provision;
pub use provision::{
hostname::Provisioner as HostnameProvisioner,
password::Provisioner as PasswordProvisioner,
user::Provisioner as UserProvisioner, Provision,
user::{Provisioner as UserProvisioner, User},
Provision,
};

// Re-export as the Client is used in our API.
Expand Down
52 changes: 18 additions & 34 deletions libazureinit/src/provision/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ pub mod user;
use strum::IntoEnumIterator;
use tracing::instrument;

use crate::{error::Error, imds::PublicKeys};
use crate::error::Error;

/// The interface for applying the desired configuration to the host.
///
Expand All @@ -23,24 +23,17 @@ use crate::{error::Error, imds::PublicKeys};
#[derive(Default, Clone)]
pub struct Provision {
hostname: String,
username: String,
keys: Vec<PublicKeys>,
password: Option<String>,
user: user::User,
hostname_backends: Option<Vec<hostname::Provisioner>>,
user_backends: Option<Vec<user::Provisioner>>,
password_backends: Option<Vec<password::Provisioner>>,
}

impl Provision {
pub fn new(
hostname: impl Into<String>,
username: impl Into<String>,
ssh_keys: impl Into<Vec<PublicKeys>>,
) -> Self {
pub fn new(hostname: impl Into<String>, user: user::User) -> Self {
Self {
hostname: hostname.into(),
username: username.into(),
keys: ssh_keys.into(),
user,
..Default::default()
}
}
Expand Down Expand Up @@ -85,12 +78,6 @@ impl Provision {
self
}

/// Set the given password for the provisioned user.
pub fn password(mut self, password: String) -> Self {
self.password = Some(password);
self
}

/// Provision the host.
#[instrument(skip_all)]
pub fn provision(self) -> Result<(), Error> {
Expand All @@ -99,7 +86,7 @@ impl Provision {
.iter()
.find_map(|backend| {
backend
.create(&self.username)
.create(&self.user)
.map_err(|e| {
tracing::info!(
error=?e,
Expand All @@ -118,7 +105,7 @@ impl Provision {
.iter()
.find_map(|backend| {
backend
.set(&self.username, self.password.as_deref().unwrap_or(""))
.set(&self.user)
.map_err(|e| {
tracing::info!(
error=?e,
Expand All @@ -132,13 +119,13 @@ impl Provision {
})
.ok_or(Error::NoPasswordProvisioner)?;

if !self.keys.is_empty() {
let user = nix::unistd::User::from_name(&self.username)?.ok_or(
if !self.user.ssh_keys.is_empty() {
let user = nix::unistd::User::from_name(&self.user.name)?.ok_or(
Error::UserMissing {
user: self.username,
user: self.user.name,
},
)?;
ssh::provision_ssh(&user, &self.keys)?;
ssh::provision_ssh(&user, &self.user.ssh_keys)?;
}

self.hostname_backends
Expand Down Expand Up @@ -167,20 +154,17 @@ impl Provision {
#[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(),
"my-user".to_string(),
vec![],
)
.hostname_provisioners([hostname::Provisioner::FakeHostnamectl])
.user_provisioners([user::Provisioner::FakeUseradd])
.password("password".to_string())
.password_provisioners([password::Provisioner::FakePasswd])
.provision()
.unwrap();
let _p = Provision::new("my-hostname".to_string(), User::default())
.hostname_provisioners([hostname::Provisioner::FakeHostnamectl])
.user_provisioners([user::Provisioner::FakeUseradd])
.password_provisioners([password::Provisioner::FakePasswd])
.provision()
.unwrap();
}
}
20 changes: 9 additions & 11 deletions libazureinit/src/provision/password.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::process::Command;

use tracing::instrument;

use crate::error::Error;
use crate::{error::Error, User};

#[derive(strum::EnumIter, Debug, Clone)]
#[non_exhaustive]
Expand All @@ -16,26 +16,24 @@ pub enum Provisioner {
}

impl Provisioner {
pub(crate) fn set(
&self,
username: impl AsRef<str>,
password: impl AsRef<str>,
) -> Result<(), Error> {
pub(crate) fn set(&self, user: &User) -> Result<(), Error> {
match self {
Self::Passwd => passwd(username.as_ref(), password.as_ref()),
Self::Passwd => passwd(user),
#[cfg(test)]
Self::FakePasswd => Ok(()),
}
}
}

#[instrument(skip_all)]
fn passwd(username: &str, password: &str) -> Result<(), Error> {
fn passwd(user: &User) -> Result<(), Error> {
let path_passwd = env!("PATH_PASSWD");

if password.is_empty() {
let status =
Command::new(path_passwd).arg("-d").arg(username).status()?;
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(),
Expand Down
98 changes: 91 additions & 7 deletions libazureinit/src/provision/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,69 @@ use std::process::Command;

use tracing::instrument;

use crate::error::Error;
use crate::{error::Error, imds::PublicKeys};

/// Configuration for the provisioned user.
#[derive(Clone)]
pub struct User {
pub(crate) name: String,
pub(crate) groups: Vec<String>,
pub(crate) ssh_keys: Vec<PublicKeys>,
pub(crate) password: Option<String>,
}

impl Default for User {
fn default() -> Self {
Self {
name: "azureuser".into(),
password: Default::default(),
groups: vec!["wheel".into()],
ssh_keys: vec![],
}
}
}

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.
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
..Default::default()
}
}

/// A list of SSH public keys to add to the user's authorized_keys file.
pub fn ssh_keys(mut self, ssh_keys: impl Into<Vec<PublicKeys>>) -> Self {
self.ssh_keys = ssh_keys.into();
self
}

/// Set a password for the user; this is optional.
pub fn with_password(mut self, password: impl Into<String>) -> 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<Vec<String>>) -> Self {
self.groups = groups.into();
self
}
}

#[derive(strum::EnumIter, Debug, Clone)]
#[non_exhaustive]
Expand All @@ -16,28 +78,28 @@ pub enum Provisioner {
}

impl Provisioner {
pub(crate) fn create(&self, name: impl AsRef<str>) -> Result<(), Error> {
pub(crate) fn create(&self, user: &User) -> Result<(), Error> {
match self {
Self::Useradd => useradd(name.as_ref()),
Self::Useradd => useradd(user),
#[cfg(test)]
Self::FakeUseradd => Ok(()),
}
}
}

#[instrument(skip_all)]
fn useradd(name: &str) -> Result<(), Error> {
fn useradd(user: &User) -> Result<(), Error> {
let path_useradd = env!("PATH_USERADD");
let home_path = format!("/home/{name}");
let home_path = format!("/home/{}", user.name);

let status = Command::new(path_useradd)
.arg(name)
.arg(&user.name)
.arg("--comment")
.arg(
"Provisioning agent created this user based on username provided in IMDS",
)
.arg("--groups")
.arg(env!("USERADD_GROUPS"))
.arg(user.groups.join(","))
.arg("-d")
.arg(home_path)
.arg("-m")
Expand All @@ -51,3 +113,25 @@ fn useradd(name: &str) -> Result<(), Error> {

Ok(())
}

#[cfg(test)]
mod tests {
use crate::imds::PublicKeys;

use super::User;

#[test]
fn test_default_user_with_ssh() {
let keys = vec![
PublicKeys {
key_data: "not-a-real-key abc123".to_string(),
path: "unused".to_string(),
},
PublicKeys {
key_data: "not-a-real-key xyz987".to_string(),
path: "unused".to_string(),
},
];
let _user = User::default().ssh_keys(keys);
}
}
36 changes: 17 additions & 19 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::process::ExitCode;
use anyhow::Context;

use libazureinit::imds::InstanceMetadata;
use libazureinit::User;
use libazureinit::{
error::Error as LibError,
goalstate, imds, media,
Expand Down Expand Up @@ -87,26 +88,23 @@ async fn provision() -> Result<(), anyhow::Error> {

let instance_metadata = imds::query(&client).await?;
let username = get_username(&instance_metadata, &get_environment()?)?;
let user =
User::new(username).ssh_keys(instance_metadata.compute.public_keys);

Provision::new(
instance_metadata.compute.os_profile.computer_name,
username,
instance_metadata.compute.public_keys,
)
.hostname_provisioners([
#[cfg(feature = "hostnamectl")]
HostnameProvisioner::Hostnamectl,
])
.user_provisioners([
#[cfg(feature = "useradd")]
UserProvisioner::Useradd,
])
.password("".to_string())
.password_provisioners([
#[cfg(feature = "passwd")]
PasswordProvisioner::Passwd,
])
.provision()?;
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
Expand Down
17 changes: 10 additions & 7 deletions tests/functional_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

use libazureinit::imds::PublicKeys;
use libazureinit::User;
use libazureinit::{
goalstate,
reqwest::{header, Client},
Expand Down Expand Up @@ -66,13 +67,15 @@ async fn main() {
},
];

Provision::new("my-hostname".to_string(), username.to_string(), keys)
.hostname_provisioners([HostnameProvisioner::Hostnamectl])
.user_provisioners([UserProvisioner::Useradd])
.password("".to_string())
.password_provisioners([PasswordProvisioner::Passwd])
.provision()
.expect("Failed to provision host");
Provision::new(
"my-hostname".to_string(),
User::new(username).ssh_keys(keys),
)
.hostname_provisioners([HostnameProvisioner::Hostnamectl])
.user_provisioners([UserProvisioner::Useradd])
.password_provisioners([PasswordProvisioner::Passwd])
.provision()
.expect("Failed to provision host");

println!("VM successfully provisioned");
println!();
Expand Down

0 comments on commit 624ce3e

Please sign in to comment.