Skip to content
Draft
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
68 changes: 59 additions & 9 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,10 @@ pub(crate) fn start_and_await_boot(cfg: &BootConfig) -> Result<()> {
if let Some(shift_str) = state.get_nonempty("USERNS_SHIFT") {
if let Ok(shift) = shift_str.parse::<u64>() {
if let Some(conflict) = sdme::userns::check_shift_conflict(name, shift) {
eprintln!(
"warning: UID range {shift} conflicts with running container '{conflict}'"
);
eprintln!("falling back to nspawn pick (first boot may be slow)");
let mut state = state;
state.remove("USERNS_SHIFT");
let _ = state.write_to(&state_path);
if handle_userns_shift_conflict(&mut state, name, shift, &conflict)? {
state.write_to(&state_path)?;
}
}
}
}
Expand Down Expand Up @@ -231,6 +228,23 @@ pub(crate) fn start_and_await_boot(cfg: &BootConfig) -> Result<()> {
}
}

fn handle_userns_shift_conflict(
state: &mut sdme::State,
name: &str,
shift: u64,
conflict: &str,
) -> Result<bool> {
if state.is_yes("USERNS_MANAGED") {
bail!(
"managed UID range {shift} for container '{name}' conflicts with running container '{conflict}'"
);
}
eprintln!("warning: UID range {shift} conflicts with running container '{conflict}'");
eprintln!("falling back to nspawn pick (first boot may be slow)");
state.remove("USERNS_SHIFT");
Ok(true)
}

/// Print recent journal entries for a container whose boot failed.
///
/// Runs `journalctl -u <unit>` and forwards the log lines to stderr. The
Expand Down Expand Up @@ -370,15 +384,21 @@ pub(crate) fn probe_and_prechown(
eliminates this step"
);

let shift = userns::allocate_uid_shift(datadir, name)?;
let state_path = datadir.join("state").join(name);
let mut state = sdme::State::read_from(&state_path)?;
let shift = match state
.get_nonempty("USERNS_SHIFT")
.and_then(|s| s.parse::<u64>().ok())
{
Some(shift) => shift,
None => userns::allocate_uid_shift(datadir, name)?,
};
if verbose {
eprintln!("allocated UID shift: {shift}");
}
userns::prechown_overlayfs(datadir, name, lowerdir, shift)?;

// Store the shift in the state file so to_nspawn_args uses it.
let state_path = datadir.join("state").join(name);
let mut state = sdme::State::read_from(&state_path)?;
state.set("USERNS_SHIFT", shift.to_string());
state.write_to(&state_path)?;
}
Expand Down Expand Up @@ -942,3 +962,33 @@ pub(crate) fn validate_kube_oci_pod_args(datadir: &Path, oci_pod: Option<&str>)

Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_managed_userns_shift_conflict_is_error() {
let mut state = sdme::State::new();
state.set("USERNS_SHIFT", "655360");
state.set("USERNS_MANAGED", "yes");

let err = handle_userns_shift_conflict(&mut state, "owned", 655360, "other").unwrap_err();

assert!(
err.to_string().contains("managed UID range 655360"),
"unexpected error: {err:#}"
);
assert_eq!(state.get("USERNS_SHIFT"), Some("655360"));
}

#[test]
fn test_unmanaged_userns_shift_conflict_clears_shift() {
let mut state = sdme::State::new();
state.set("USERNS_SHIFT", "655360");

handle_userns_shift_conflict(&mut state, "owned", 655360, "other").unwrap();

assert_eq!(state.get("USERNS_SHIFT"), None);
}
}
59 changes: 52 additions & 7 deletions src/containers/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ use std::path::{Component, Path, PathBuf};
use anyhow::{bail, Context, Result};

use crate::{
names, rootfs, systemd, validate_name, BindConfig, EnvConfig, NetworkConfig, ResourceLimits,
SecurityConfig, State,
names, rootfs, systemd, userns, validate_name, BindConfig, EnvConfig, NetworkConfig,
ResourceLimits, SecurityConfig, State,
};

use super::{get_umask, resolve_rootfs, set_dir_permissions, unix_timestamp, volumes_dir};
Expand Down Expand Up @@ -37,6 +37,10 @@ pub struct CreateOptions {
pub envs: EnvConfig,
/// Security hardening configuration.
pub security: SecurityConfig,
/// Host owner for a managed per-container subordinate ID allocation.
pub owner: Option<String>,
/// Pre-allocated managed subordinate ID range.
pub owner_allocation: Option<userns::ManagedSubidAllocation>,
/// OCI volume mount paths from the image.
pub oci_volumes: Vec<String>,
/// OCI environment variables from the image.
Expand Down Expand Up @@ -127,12 +131,46 @@ pub fn create(datadir: &Path, opts: &CreateOptions, verbose: bool) -> Result<Str
eprintln!("claimed state file: {}", state_path.display());
}

match do_create(datadir, &name, &rootfs, opts, &opaque_dirs, verbose) {
let mut security = opts.security.clone();
let owner_allocation = match (&opts.owner_allocation, &opts.owner) {
(Some(allocation), _) => {
security.userns = true;
security.userns_shift = Some(allocation.start);
Some(allocation.clone())
}
(None, Some(owner)) => {
let allocation = match userns::allocate_managed_subids(datadir, &name, owner) {
Ok(allocation) => allocation,
Err(e) => {
let _ = fs::remove_file(&state_path);
return Err(e);
}
};
security.userns = true;
security.userns_shift = Some(allocation.start);
Some(allocation)
}
(None, None) => None,
};

match do_create(
datadir,
&name,
&rootfs,
opts,
&opaque_dirs,
&security,
owner_allocation.as_ref(),
verbose,
) {
Ok(()) => Ok(name),
Err(e) => {
let container_dir = datadir.join("containers").join(&name);
let _ = fs::remove_dir_all(&container_dir);
let _ = fs::remove_file(&state_path);
if owner_allocation.is_some() {
let _ = userns::release_managed_subids(datadir, &name);
}
Err(e)
}
}
Expand All @@ -144,6 +182,8 @@ fn do_create(
rootfs: &Path,
opts: &CreateOptions,
opaque_dirs: &[String],
security: &SecurityConfig,
owner_allocation: Option<&userns::ManagedSubidAllocation>,
verbose: bool,
) -> Result<()> {
let container_dir = datadir.join("containers").join(name);
Expand Down Expand Up @@ -489,15 +529,14 @@ fn do_create(
// drop-in to adjust the bounding set. Without this, the inner service
// claims capabilities the container doesn't have, which causes boot
// failures on distros where systemd enforces the mismatch (e.g. SUSE).
if !opts.security.drop_caps.is_empty() {
if !security.drop_caps.is_empty() {
let app_names = crate::oci::rootfs::detect_all_oci_app_names(rootfs);
if !app_names.is_empty() {
use std::collections::HashSet;

use crate::security::OCI_DEFAULT_CAPS;

let drop_set: HashSet<&str> =
opts.security.drop_caps.iter().map(|s| s.as_str()).collect();
let drop_set: HashSet<&str> = security.drop_caps.iter().map(|s| s.as_str()).collect();
let caps: Vec<&str> = OCI_DEFAULT_CAPS
.iter()
.copied()
Expand Down Expand Up @@ -530,7 +569,13 @@ fn do_create(
}
}

opts.security.write_to_state(&mut state);
security.write_to_state(&mut state);
if let Some(allocation) = owner_allocation {
state.set("OWNER", allocation.owner.as_str());
state.set("USERNS_OWNER", allocation.owner.as_str());
state.set("USERNS_RANGE", allocation.count.to_string());
state.set("USERNS_MANAGED", "yes");
}
if !opts.masked_services.is_empty() {
state.set("MASKED_SERVICES", opts.masked_services.join(","));
}
Expand Down
65 changes: 50 additions & 15 deletions src/containers/manage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,37 @@
use std::fs;
use std::path::Path;

use anyhow::{Context, Result};
use anyhow::{bail, Context, Result};

use crate::{systemd, ResourceLimits, State};
use crate::{systemd, userns, ResourceLimits, State};

use super::{ensure_exists, volumes_dir};

/// Stop a container if running, then delete its state file and overlayfs directories.
pub fn remove(datadir: &Path, name: &str, verbose: bool) -> Result<()> {
ensure_exists(datadir, name)?;
let state_file = datadir.join("state").join(name);
if !state_file.exists() {
bail!("container does not exist: {name}");
}

// Acquire exclusive lock to prevent removal while a build is reading from this container.
let _lock = crate::lock::lock_exclusive(datadir, "containers", name)
.with_context(|| format!("cannot remove container '{name}': in use"))?;

// Read state before removal to check for OCI volumes and enabled state.
let state_file = datadir.join("state").join(name);
let (has_oci_volumes, is_enabled) = if state_file.exists() {
State::read_from(&state_file)
.ok()
.map(|s| {
let oci = s.get("OCI_VOLUMES").map(|v| !v.is_empty()).unwrap_or(false);
let enabled = s.is_yes("ENABLED");
(oci, enabled)
})
.unwrap_or((false, false))
let state = if state_file.exists() {
State::read_from(&state_file).ok()
} else {
(false, false)
None
};
let (has_oci_volumes, is_enabled) = state
.as_ref()
.map(|s| {
let oci = s.get("OCI_VOLUMES").map(|v| !v.is_empty()).unwrap_or(false);
let enabled = s.is_yes("ENABLED");
(oci, enabled)
})
.unwrap_or((false, false));

// Disable the unit if it was enabled (best-effort).
if is_enabled {
Expand All @@ -55,7 +58,9 @@ pub fn remove(datadir: &Path, name: &str, verbose: bool) -> Result<()> {
}
}

if state_file.exists() {
if let Some(state) = &state {
remove_state_file_after_release(datadir, name, &state_file, state, verbose)?;
} else if state_file.exists() {
fs::remove_file(&state_file)
.with_context(|| format!("failed to remove {}", state_file.display()))?;
if verbose {
Expand All @@ -75,6 +80,36 @@ pub fn remove(datadir: &Path, name: &str, verbose: bool) -> Result<()> {
Ok(())
}

pub(super) fn remove_state_file_after_release(
datadir: &Path,
name: &str,
state_file: &Path,
state: &State,
verbose: bool,
) -> Result<()> {
release_managed_subids_for_state(datadir, name, state)?;
if state_file.exists() {
fs::remove_file(state_file)
.with_context(|| format!("failed to remove {}", state_file.display()))?;
if verbose {
eprintln!("removed {}", state_file.display());
}
}
Ok(())
}

/// Release a container's managed subordinate ID allocation if its state uses one.
pub(super) fn release_managed_subids_for_state(
datadir: &Path,
name: &str,
state: &State,
) -> Result<()> {
if state.is_yes("USERNS_MANAGED") {
userns::release_managed_subids(datadir, name)?;
}
Ok(())
}

/// Update resource limits on an existing container.
///
/// Reads the current state file, merges the new limits, writes it back,
Expand Down
Loading