Watch
0
0
Fork
You've already forked hyperhive
0

read privateUsers from the closure record at start, and move the data owner option under hive-controller

This commit is contained in:
atlas 2026-11-01 16:18:34 +01:00 • committed by mara
commit f5a609911c
4 changed files with 208 additions and 97 deletions

View file

@ -26,6 +26,7 @@ pub(crate) fn handle_write_nspawn_flags(
isolation: &NetworkIsolation,
load_credentials: &[CredentialMount],
hive_facts: &HiveFacts,
private_users: Option<bool>,
) -> Result<(String, String)> {
let agent = validate_container_system_name(container)?;
for bind in binds {
@ -52,7 +53,7 @@ pub(crate) fn handle_write_nspawn_flags(
});
// After the salvage, so the mount points land in the root the start will
// snapshot.
let userns = crate::userns::activate(container, agent, binds)?;
let userns = crate::userns::activate(container, agent, binds, private_users)?;
write_nspawn_flags(
container,
binds,
@ -77,6 +78,7 @@ pub(crate) async fn handle_write_nspawn_flags_blocking(
isolation: NetworkIsolation,
load_credentials: Vec<CredentialMount>,
hive_facts: HiveFacts,
private_users: Option<bool>,
) -> Result<(String, String)> {
tokio::task::spawn_blocking(move || {
handle_write_nspawn_flags(
@ -85,6 +87,7 @@ pub(crate) async fn handle_write_nspawn_flags_blocking(
&isolation,
&load_credentials,
&hive_facts,
private_users,
)
})
.await
@ -1163,7 +1166,7 @@ mod tests {
let facts = HiveFacts::default();
for name in ["hive-forge", "hive-matrix"] {
let errs = [
handle_write_nspawn_flags(name, &[], &isolation, &[], &facts).unwrap_err(),
handle_write_nspawn_flags(name, &[], &isolation, &[], &facts, None).unwrap_err(),
write_resource_limits(name, "4G", "200%", None, None).unwrap_err(),
remove_service_dropin(name).unwrap_err(),
];

View file

@ -153,11 +153,14 @@ pub(crate) fn remove_legacy_tmpfiles() -> Result<()> {
}
/// `EnsureAgentSocketDir` — create `/run/hive-agent/<name>` if missing.
pub(crate) fn ensure_agent_socket_dir(name: &str) -> Result<(String, String)> {
pub(crate) fn ensure_agent_socket_dir(
name: &str,
private_users: Option<bool>,
) -> Result<(String, String)> {
ensure_socket_dir_in(Path::new(SOCKET_DIR_ROOT), name)?;
// A namespaced agent's activation cannot chown the dir through the id
// mapping, so the data owner, which is the agent user inside, gets it here.
if let Some(owner) = crate::userns::owner_for_agent(name)? {
if let Some(owner) = crate::userns::owner_for_agent(name, private_users)? {
let path = Path::new(SOCKET_DIR_ROOT).join(name);
std::os::unix::fs::lchown(&path, Some(owner), Some(owner))
.with_context(|| format!("chown {}", path.display()))?;

View file

@ -391,18 +391,23 @@ async fn exec(
isolation,
load_credentials,
hive_facts,
private_users,
} => {
container_config::handle_write_nspawn_flags_blocking(
container,
binds,
isolation,
load_credentials,
hive_facts,
*hive_facts,
private_users,
)
.await
}
PrivRequest::EnsureAgentSocketDir { ref name } => files::ensure_agent_socket_dir(name),
PrivRequest::EnsureAgentSocketDir {
ref name,
private_users,
} => files::ensure_agent_socket_dir(name, private_users),
PrivRequest::WriteResourceLimits {
ref container,

View file

@ -1,11 +1,12 @@
//! Agents that run in their own user namespace.
//!
//! The operator lists them in `services.hyperhive.userns.agents` and names the
//! one host uid:gid that owns their data on disk (`dataOwner`); the unit's
//! environment carries both, so only root decides who is namespaced. A listed
//! agent starts with `--private-users=pick`, a root idmapped rather than
//! chowned, and every bind mounted `owneridmap`: the owner of the bind's
//! target inside the container, the agent user, is the data owner on disk.
//! An agent's config sets `services.hyperhive.agent.user.privateUsers`; the
//! swarm builder records it, hive-c0re reads the record and sends the flag with
//! each start. The host's data owner (`dataOwner`, one uid:gid) comes from this
//! unit's environment. A namespaced agent starts with `--private-users=pick`, a
//! root idmapped rather than chowned, and every bind mounted `owneridmap`: the
//! owner of the bind's target inside the container, the agent user, is the data
//! owner on disk.
//!
//! Getting there moves the agent's data once. [`activate`] chowns its `state`,
//! `harness`, `work` and `claude` dirs to the data owner, never touching
@ -28,7 +29,6 @@ use rustix::io::Errno;
use crate::files::SOCKET_DIR_ROOT;
const AGENTS_ENV: &str = "HIVE_PRIV_USERNS_AGENTS";
const DATA_OWNER_ENV: &str = "HIVE_PRIV_USERNS_DATA_OWNER";
/// One `<owner> <uid> <gid>` file per agent: the data owner its data was moved
@ -49,7 +49,7 @@ pub(crate) struct Env<'a> {
pub(crate) socket_dir: &'a Path,
pub(crate) marker_root: &'a Path,
pub(crate) rootfs: &'a Path,
/// The data owner when the agent is listed, else `None`.
/// The data owner when the agent is to be namespaced, else `None`.
pub(crate) owner: Option<u32>,
pub(crate) stopped: bool,
/// Lowest uid taken for an agent's own: below it a dir belongs to root or
@ -78,22 +78,26 @@ fn chown_at(dir: BorrowedFd<'_>, name: &CStr, uid: u32, gid: u32) -> std::io::Re
)?)
}
/// The data owner for `agent` under the unit's environment: `None` when the
/// agent is not listed.
/// The data owner when `agent` is to run in a user namespace, else `None`.
///
/// `requested` is what hive-c0re sent from the closure record. `None` there
/// means it could not say, and the agent stays as it is: namespaced if
/// hive-priv already `moved` its data.
///
/// # Errors
/// The agent is listed and the data owner is missing or not a plain uid.
/// The agent is to be namespaced and the data owner is missing or not a plain
/// uid.
pub(crate) fn owner_for(
agents: Option<&str>,
requested: Option<bool>,
moved: bool,
owner: Option<&str>,
agent: &str,
) -> Result<Option<u32>> {
if !agents.is_some_and(|list| list.split_whitespace().any(|a| a == agent)) {
if !requested.unwrap_or(moved) {
return Ok(None);
}
let owner = owner.with_context(|| {
format!("{agent} is listed in {AGENTS_ENV}, but {DATA_OWNER_ENV} is not set")
})?;
let owner = owner
.with_context(|| format!("{agent} sets privateUsers, but {DATA_OWNER_ENV} is not set"))?;
let owner = owner
.trim()
.parse()
@ -101,30 +105,37 @@ pub(crate) fn owner_for(
Ok(Some(owner))
}
/// [`owner_for`] under the unit's environment.
/// [`owner_for`] under the unit's environment and the agent's marker.
///
/// # Errors
/// As [`owner_for`].
pub(crate) fn owner_for_agent(agent: &str) -> Result<Option<u32>> {
/// As [`owner_for`], or `agent` is not an agent name.
pub(crate) fn owner_for_agent(agent: &str, requested: Option<bool>) -> Result<Option<u32>> {
crate::shared::validate_agent_name(agent)?;
owner_for(
std::env::var(AGENTS_ENV).ok().as_deref(),
requested,
Path::new(MARKER_ROOT).join(agent).exists(),
std::env::var(DATA_OWNER_ENV).ok().as_deref(),
agent,
)
}
/// Make `agent`'s next start of `container` namespaced when it is listed, and
/// say whether it is. `binds` are the ones the start will mount.
/// Make `agent`'s next start of `container` namespaced when its config asks
/// for it, and say whether it is. `binds` are the ones the start will mount.
///
/// A container that is running, and whose data has not been moved yet, keeps
/// its current start: the move waits for a stopped container.
///
/// # Errors
/// The environment is inconsistent, the data owner changed since the data
/// moved, the agent was dropped from the list after it moved, or a chown or
/// mkdir fails.
pub(crate) fn activate(container: &str, agent: &str, binds: &[BindMount]) -> Result<bool> {
let owner = owner_for_agent(agent)?;
/// moved, the agent stopped asking for a namespace after it moved, or a chown
/// or mkdir fails.
pub(crate) fn activate(
container: &str,
agent: &str,
binds: &[BindMount],
requested: Option<bool>,
) -> Result<bool> {
let owner = owner_for_agent(agent, requested)?;
let rootfs = crate::container_config::container_rootfs(container);
let stopped = crate::rootfs::unit_is_stopped(container)?;
activate_in(
@ -153,8 +164,8 @@ pub(crate) fn activate_in(env: &Env<'_>, agent: &str, binds: &[BindMount]) -> Re
let Some(owner) = env.owner else {
if moved.is_some() {
bail!(
"{agent}: its data was moved to the userns data owner and it is no longer \
listed; list it again, or chown its dirs back and remove {}",
"{agent}: its data was moved to the userns data owner and its config no longer \
sets privateUsers; set it again, or chown its dirs back and remove {}",
marker.display()
);
}
@ -470,14 +481,25 @@ mod tests {
}
}
/// The flag decides; with no word from the record the marker does. The
/// refusal arms and the `None` arms are each other's controls.
#[test]
fn only_listed_agents_have_an_owner() {
let owner = |agents, owner| owner_for(agents, owner, "a");
assert_eq!(owner(Some("b a"), Some("60000")).unwrap(), Some(60000));
assert_eq!(owner(Some("b c"), Some("60000")).unwrap(), None);
assert_eq!(owner(None, None).unwrap(), None);
assert!(owner(Some("a"), None).is_err(), "listed without an owner");
assert!(owner(Some("a"), Some("root")).is_err(), "not a uid");
fn the_requested_flag_decides_and_without_it_the_marker_does() {
let owner = |requested, moved, env| owner_for(requested, moved, env, "a");
assert_eq!(
owner(Some(true), false, Some("60000")).unwrap(),
Some(60000)
);
assert_eq!(owner(Some(false), false, Some("60000")).unwrap(), None);
assert_eq!(owner(Some(false), true, Some("60000")).unwrap(), None);
assert_eq!(owner(None, true, Some("60000")).unwrap(), Some(60000));
assert_eq!(owner(None, false, Some("60000")).unwrap(), None);
assert!(owner(Some(true), false, None).is_err(), "no data owner");
assert!(owner(Some(true), false, Some("root")).is_err(), "not a uid");
assert!(
owner(Some(false), false, None).is_ok(),
"not asked, not needed"
);
}
/// The move chowns the four data dirs and what is in them and nothing
@ -552,7 +574,7 @@ mod tests {
}
#[test]
fn an_unlisted_agent_is_left_alone_and_a_moved_one_is_refused() {
fn an_agent_without_the_flag_is_left_alone_and_a_moved_one_is_refused() {
let root = tree();
let (answer, seen) = run(&root, None, true, "a", &[]);
assert!(!answer.unwrap() && seen.is_empty());