bind a container apply to the closure realised for the agent, and record the deployed pins on drift
hive-priv applies only the path its last RealiseClosure returned for that agent. On drift the swap and create record the pins of the commit the closure was built from.
This commit is contained in:
parent
4590b04cac
commit
6b800600e2
10 changed files with 338 additions and 71 deletions
|
|
@ -172,7 +172,8 @@ the swarm builder built the agent from the updated repo (the
|
|||
writes it from the builder's reply) and have `hive-priv` download the closure
|
||||
it names from the swarm cache (`RealiseClosure`). `Swap` and `Create` then
|
||||
give `hive-priv` the store path (`toplevel` on `UpdateContainer` and
|
||||
`CreateContainer`), which accepts only a store path. On the wire the
|
||||
`CreateContainer`), which accepts only the path its last `RealiseClosure` for
|
||||
that agent returned. On the wire the
|
||||
`hive_priv_sock::PrivRequest` variants carry a required field:
|
||||
`{"op":"update_container","name":"atlas","stream":true,"toplevel":"/nix/store/<hash>-nixos-system-atlas"}`.
|
||||
The three cases when the swarm's closure isn't the one this hive expects
|
||||
|
|
|
|||
|
|
@ -493,9 +493,9 @@ same and creates the container from that path.
|
|||
A rebuild or create has three outcomes, by what the lookup and the download find:
|
||||
|
||||
| Found | What happens |
|
||||
| ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| a record that the builder pushed, for the commit this hive chose | The hive downloads the closure and deploys it. The hive logs `prebuild: substituted <path>` (`create: substituted <path>` for a create) at info level. |
|
||||
| a record that the builder pushed, for another commit (the hive and the builder disagree) | The hive downloads the builder's closure and deploys it, with a warning that names the agent, both commits and the path. The hive evaluates nothing. |
|
||||
| a record that the builder pushed, for another commit (the hive and the builder disagree) | The hive downloads the builder's closure and deploys it, with a warning that names the agent, both commits and the path. The hive evaluates nothing, and records the pins of the commit the builder built the closure from, so the agent's markers say what runs. |
|
||||
| no record, or one the hive can't use (not pushed, queue or cache unreachable, download or signature failed) | The rebuild or create fails with an error that names the agent and the reason, logged at error level, and nothing changes. A running container keeps running unchanged, because `Prebuild` fails before `StopForUpdate`. A create makes no container. |
|
||||
|
||||
The hive never falls back to a local build. The third row has one owner in the
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ known operations; there is no arbitrary command pass-through:
|
|||
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `StartContainer` / `StopContainer` | `nixos-container start/stop <name>` |
|
||||
| `KillContainer` | `machinectl kill <machine> --signal=SIGKILL` (`nixos-container` has no kill verb) |
|
||||
| `CreateContainer` / `UpdateContainer` | `nixos-container create <name> --system-path <toplevel>`; for an update, `nix-env -p <profile> --set <toplevel>` and a reload of the unit. `<toplevel>` must be a store path (`/nix/store/<32 chars>-<name>`), and hive-priv builds nothing |
|
||||
| `CreateContainer` / `UpdateContainer` | `nixos-container create <name> --system-path <toplevel>`; for an update, `nix-env -p <profile> --set <toplevel>` and a reload of the unit. `<toplevel>` must be the store path the last successful `RealiseClosure` returned for that agent (in memory, per agent), so hive-c0re can't name a path never realised for the agent; hive-priv builds nothing |
|
||||
| `RealiseClosure` | `nix-store --realise <store_path> --max-jobs 0` against the swarm cache, with the host's configured cache address, and the public key and netrc from root-owned `/run/hyperhive-priv/attic/`, as per-call settings that replace the host's substituters and trusted keys and require a signature. The path must be `/nix/store/<32 chars>-<name>` and the agent name valid. It downloads and never builds |
|
||||
| `DestroyContainer` | `nixos-container destroy <name>` |
|
||||
| `ListContainers` | `nixos-container list` |
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ pub(super) trait Host: Sync {
|
|||
/// The record the swarm's builder wrote for the agent; `None` when it wrote
|
||||
/// none.
|
||||
async fn closure_record(&self, name: &str) -> Result<Option<AgentClosure>>;
|
||||
/// The pins `internal/meta` at the commit `rev` holds for the agent.
|
||||
async fn pins_at(&self, name: &str, rev: &str) -> Result<MetaBuild>;
|
||||
/// Download `store_path` from the swarm's cache into the store, never
|
||||
/// building it.
|
||||
async fn realise_closure(&self, name: &str, store_path: &str) -> Result<()>;
|
||||
|
|
@ -125,6 +127,10 @@ impl Host for RealHost {
|
|||
crate::attic::closure::record(name).await
|
||||
}
|
||||
|
||||
async fn pins_at(&self, name: &str, rev: &str) -> Result<MetaBuild> {
|
||||
crate::meta_rev::resolve_at(name, rev).await
|
||||
}
|
||||
|
||||
async fn realise_closure(&self, name: &str, store_path: &str) -> Result<()> {
|
||||
crate::priv_client::realise_closure(name, store_path).await
|
||||
}
|
||||
|
|
@ -548,8 +554,8 @@ async fn run_meta_sync(host: &impl Host, name: &str) -> Result<()> {
|
|||
async fn run_prebuild(host: &impl Host, name: &str) -> Result<()> {
|
||||
if host.is_running(name).await {
|
||||
let rev = host.recorded_rev(name)?;
|
||||
let path = closure_for(host, name, &rev, "prebuild").await?;
|
||||
tracing::info!(%name, %rev, "prebuild: substituted {path}");
|
||||
let closure = closure_for(host, name, &rev, "prebuild").await?;
|
||||
tracing::info!(%name, %rev, "prebuild: substituted {}", closure.path);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -563,13 +569,21 @@ enum NoClosure {
|
|||
Unpullable(String),
|
||||
}
|
||||
|
||||
/// The store path of the closure the swarm built for `name`, downloaded into
|
||||
/// the store. The deploy applies the swarm's closure whatever commit it was
|
||||
/// built from: when that is not the `rev` this hive chose, it is logged.
|
||||
/// The closure the swarm built for an agent, in the store.
|
||||
struct Closure {
|
||||
path: String,
|
||||
/// The `internal/meta` commit the builder built it from.
|
||||
meta_rev: String,
|
||||
}
|
||||
|
||||
/// The closure the swarm built for `name`, downloaded into the store. The
|
||||
/// deploy applies the swarm's closure whatever commit it was built from: when
|
||||
/// that is not the `rev` this hive chose, it is logged, and the caller records
|
||||
/// the pins of the commit actually deployed ([`deployed_pins`]).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`without_closure`]'s refusal, after logging it.
|
||||
async fn closure_for(host: &impl Host, name: &str, rev: &str, step: &str) -> Result<String> {
|
||||
async fn closure_for(host: &impl Host, name: &str, rev: &str, step: &str) -> Result<Closure> {
|
||||
pull_closure(host, name, rev, step)
|
||||
.await
|
||||
.map_err(|why| without_closure(name, rev, step, &why))
|
||||
|
|
@ -580,7 +594,7 @@ async fn pull_closure(
|
|||
name: &str,
|
||||
rev: &str,
|
||||
step: &str,
|
||||
) -> Result<String, NoClosure> {
|
||||
) -> Result<Closure, NoClosure> {
|
||||
let record = host
|
||||
.closure_record(name)
|
||||
.await
|
||||
|
|
@ -601,7 +615,33 @@ async fn pull_closure(
|
|||
record.store_path, record.meta_rev
|
||||
);
|
||||
}
|
||||
Ok(record.store_path)
|
||||
Ok(Closure {
|
||||
path: record.store_path,
|
||||
meta_rev: record.meta_rev,
|
||||
})
|
||||
}
|
||||
|
||||
/// The pins to record for a deploy of `closure` when this hive chose the commit
|
||||
/// `rev`: those of the commit the closure was built from, so the markers say
|
||||
/// what runs. `None` when they are the same commit.
|
||||
///
|
||||
/// # Errors
|
||||
/// The pins of the closure's commit could not be read. The caller fails before
|
||||
/// applying anything.
|
||||
async fn deployed_pins(
|
||||
host: &impl Host,
|
||||
name: &str,
|
||||
closure: &Closure,
|
||||
rev: &str,
|
||||
) -> Result<Option<MetaBuild>> {
|
||||
if closure.meta_rev == rev {
|
||||
return Ok(None);
|
||||
}
|
||||
let pins = host
|
||||
.pins_at(name, &closure.meta_rev)
|
||||
.await
|
||||
.with_context(|| format!("{name}: reading the pins of the commit the swarm built from"))?;
|
||||
Ok(Some(pins))
|
||||
}
|
||||
|
||||
/// What a rebuild or create does when there is no closure to deploy: it
|
||||
|
|
@ -638,11 +678,19 @@ async fn run_swap(
|
|||
let hive = coord.hive_env();
|
||||
let paths = Coordinator::agent_paths(name, agent_dir)?;
|
||||
let rev = host.recorded_rev(name)?;
|
||||
let toplevel = closure_for(host, name, &rev, "swap").await?;
|
||||
tracing::info!(%name, %rev, "swap: applying {toplevel}");
|
||||
let closure = closure_for(host, name, &rev, "swap").await?;
|
||||
let deployed = deployed_pins(host, name, &closure, &rev).await?;
|
||||
tracing::info!(%name, %rev, "swap: applying {}", closure.path);
|
||||
let result = host
|
||||
.swap_update(name, &hive, &paths, &toplevel, id.get())
|
||||
.swap_update(name, &hive, &paths, &closure.path, id.get())
|
||||
.await;
|
||||
// The bookkeeping node marks what the record holds, so it holds what was
|
||||
// applied.
|
||||
if result.is_ok()
|
||||
&& let Some(deployed) = &deployed
|
||||
{
|
||||
host.record_meta(name, deployed)?;
|
||||
}
|
||||
// On success the Ok-only bookkeeping tail (rev marker, forge
|
||||
// sync, kick, rescan, snapshot) runs in the sibling `RebuildBookkeeping` node,
|
||||
// which deps `AfterOk(Swap)`. On failure `RebuildBookkeeping` is cancel-cascaded
|
||||
|
|
@ -708,9 +756,12 @@ async fn run_provision(host: &impl Host, name: &str) -> Result<()> {
|
|||
/// with none to deploy the create fails and makes no container.
|
||||
async fn run_create(host: &impl Host, name: &str) -> Result<()> {
|
||||
let build = host.resolve_meta(name).await?;
|
||||
let toplevel = closure_for(host, name, &build.rev, "create").await?;
|
||||
tracing::info!(%name, rev = %build.rev, "create: substituted {toplevel}");
|
||||
host.create_only(name, &toplevel).await?;
|
||||
let closure = closure_for(host, name, &build.rev, "create").await?;
|
||||
let build = deployed_pins(host, name, &closure, &build.rev)
|
||||
.await?
|
||||
.unwrap_or(build);
|
||||
tracing::info!(%name, rev = %build.rev, "create: substituted {}", closure.path);
|
||||
host.create_only(name, &closure.path).await?;
|
||||
host.mark_deployed(name, &build)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,6 +129,15 @@ impl Host for FakeHost {
|
|||
Ok(self.built.clone())
|
||||
}
|
||||
|
||||
async fn pins_at(&self, name: &str, rev: &str) -> anyhow::Result<MetaBuild> {
|
||||
self.step("pins_at", format!("pins_at {name} {rev}"))?;
|
||||
Ok(MetaBuild {
|
||||
rev: rev.to_owned(),
|
||||
hyperhive: "dh".to_owned(),
|
||||
config: Some("dc".to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn realise_closure(&self, name: &str, store_path: &str) -> anyhow::Result<()> {
|
||||
self.record(format!("realise {name} {store_path}"));
|
||||
if self.fails("realise") {
|
||||
|
|
@ -531,14 +540,46 @@ async fn swap_applies_a_closure_built_from_another_commit_with_a_warning() {
|
|||
let id = node_id();
|
||||
let (result, log) = logging(run_swap(&host, &coord, "alice", id)).await;
|
||||
result.expect("drift is not a refusal");
|
||||
// The pins of the commit the closure came from are read before the apply
|
||||
// and recorded after it, in place of the ones this hive chose; the
|
||||
// bookkeeping node then marks those.
|
||||
assert_eq!(
|
||||
host.calls().last(),
|
||||
Some(&format!("swap_update alice {CLOSURE} node={}", id.get()))
|
||||
host.calls(),
|
||||
[
|
||||
"recorded_rev alice".to_owned(),
|
||||
"closure_record alice".to_owned(),
|
||||
format!("realise alice {CLOSURE}"),
|
||||
format!("pins_at alice {OTHER_REV}"),
|
||||
format!("swap_update alice {CLOSURE} node={}", id.get()),
|
||||
format!("record_meta alice {OTHER_REV}"),
|
||||
]
|
||||
);
|
||||
assert_eq!(log.at(tracing::Level::WARN).len(), 1);
|
||||
assert!(log.at(tracing::Level::ERROR).is_empty());
|
||||
}
|
||||
|
||||
/// Pins that can't be read, or an apply that fails, leave the record alone.
|
||||
#[tokio::test]
|
||||
async fn a_drifted_swap_records_pins_only_for_an_apply_that_happened() {
|
||||
for failing in ["pins_at", "swap_update"] {
|
||||
let (_dir, coord) = coordinator();
|
||||
let host = FakeHost::down().built(OTHER_REV, true).failing(failing);
|
||||
run_swap(&host, &coord, "alice", node_id())
|
||||
.await
|
||||
.expect_err(failing);
|
||||
let calls = host.calls();
|
||||
assert!(
|
||||
!calls.iter().any(|c| c.starts_with("record_meta")),
|
||||
"{failing}: {calls:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
calls.iter().any(|c| c.starts_with("swap_update")),
|
||||
failing == "swap_update",
|
||||
"{failing}: {calls:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed update fails the node and nothing else: the swap never restarts or
|
||||
/// stops the container itself — recovery is the DAG's tail `Reconcile`.
|
||||
#[tokio::test]
|
||||
|
|
@ -897,9 +938,18 @@ async fn create_uses_a_closure_built_from_another_commit_with_a_warning() {
|
|||
let (result, log) = logging(run_create(&host, "alice")).await;
|
||||
result.expect("drift is not a refusal");
|
||||
let calls = host.calls();
|
||||
assert!(
|
||||
calls.contains(&format!("create_only alice {CLOSURE}")),
|
||||
"{calls:?}"
|
||||
// Created from the swarm's closure, and marked as the commit it was built
|
||||
// from, not the one this hive chose.
|
||||
assert_eq!(
|
||||
calls,
|
||||
[
|
||||
"resolve_meta alice".to_owned(),
|
||||
"closure_record alice".to_owned(),
|
||||
format!("realise alice {CLOSURE}"),
|
||||
format!("pins_at alice {OTHER_REV}"),
|
||||
format!("create_only alice {CLOSURE}"),
|
||||
format!("mark_deployed alice {OTHER_REV}"),
|
||||
]
|
||||
);
|
||||
assert_eq!(log.at(tracing::Level::WARN).len(), 1);
|
||||
assert!(log.at(tracing::Level::ERROR).is_empty());
|
||||
|
|
|
|||
|
|
@ -130,7 +130,58 @@ pub(crate) async fn resolve(agent: &str) -> Result<MetaBuild> {
|
|||
Ok(build)
|
||||
}
|
||||
|
||||
/// Record `build` as what `agent`'s rebuild builds from. The record is in
|
||||
/// The pins `internal/meta` at `rev` holds for `agent`: the hyperhive commit and,
|
||||
/// for a custom agent (`archetype` false), its config commit. This is what a
|
||||
/// deploy of the swarm's closure for `rev` applies, which can differ from the
|
||||
/// head [`choose`] took.
|
||||
///
|
||||
/// # Errors
|
||||
/// `rev` is not a commit id, the forge read failed, or the flake pins no
|
||||
/// hyperhive commit or, for a custom agent, no config commit.
|
||||
pub(crate) async fn pins_at(
|
||||
source: &impl MetaSource,
|
||||
agent: &str,
|
||||
archetype: bool,
|
||||
rev: &str,
|
||||
) -> Result<MetaBuild> {
|
||||
if !valid_meta_rev(rev) {
|
||||
bail!("{agent}: {rev:?} is not an internal/meta commit id");
|
||||
}
|
||||
let flake = source.flake_at(rev).await?;
|
||||
let Some(hyperhive) = hive_meta_flake::pinned_rev(&flake, "hyperhive") else {
|
||||
bail!("{agent}: internal/meta at {rev} pins no hyperhive commit");
|
||||
};
|
||||
let config = if archetype {
|
||||
None
|
||||
} else {
|
||||
let Some(config) = hive_meta_flake::pinned_rev(&flake, &format!("agent-{agent}")) else {
|
||||
bail!("{agent}: internal/meta at {rev} pins no config commit for it");
|
||||
};
|
||||
Some(config.to_owned())
|
||||
};
|
||||
Ok(MetaBuild {
|
||||
rev: rev.to_owned(),
|
||||
hyperhive: hyperhive.to_owned(),
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`pins_at`] against the forge this hive reads.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`pins_at`], or this hive holds no forge token or cannot read the
|
||||
/// agent's archetype.
|
||||
pub(crate) async fn resolve_at(agent: &str, rev: &str) -> Result<MetaBuild> {
|
||||
let archetype = crate::archetypes::of(agent)
|
||||
.with_context(|| format!("{agent}: cannot read the agent's archetype"))?;
|
||||
let Some(source) = crate::forge::internal_meta_source().await else {
|
||||
bail!("{agent}: this hive holds no forge token to read internal/meta with");
|
||||
};
|
||||
pins_at(&source, agent, archetype.is_some(), rev).await
|
||||
}
|
||||
|
||||
/// Record `build` as the commit `agent`'s rebuild expects the swarm's closure
|
||||
/// from, and the pins it deploys. The record is in
|
||||
/// hive-c0re's own state ([`crate::paths::meta_rev_dir`]), where the agent
|
||||
/// cannot write.
|
||||
///
|
||||
|
|
@ -397,6 +448,28 @@ mod tests {
|
|||
assert_unpinned(&choose(&source, "rev", None).await.unwrap_err(), "rev");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_pins_at_a_commit_are_what_its_flake_holds() {
|
||||
let source = forge(Some(ARGUS_HEAD));
|
||||
let argus = pins_at(&source, "argus", false, META).await.unwrap();
|
||||
assert_eq!(
|
||||
argus,
|
||||
MetaBuild {
|
||||
rev: META.to_owned(),
|
||||
hyperhive: HYPERHIVE.to_owned(),
|
||||
config: Some(ARGUS_HEAD.to_owned()),
|
||||
}
|
||||
);
|
||||
// An archetype agent has no config pin of its own.
|
||||
let rev = pins_at(&source, "rev", true, META).await.unwrap();
|
||||
assert_eq!((rev.hyperhive.as_str(), rev.config), (HYPERHIVE, None));
|
||||
// A custom agent the flake doesn't list, and a rev that is no commit id.
|
||||
let err = pins_at(&source, "nobody", false, META).await.unwrap_err();
|
||||
assert!(format!("{err:#}").contains("no config commit"), "{err:#}");
|
||||
let err = pins_at(&source, "argus", false, "main").await.unwrap_err();
|
||||
assert!(format!("{err:#}").contains("not an internal/meta commit"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_repo_or_no_commit_is_an_error_naming_the_agent() {
|
||||
let mut source = forge(Some(ARGUS_HEAD));
|
||||
|
|
|
|||
|
|
@ -263,8 +263,8 @@ pub enum PrivRequest {
|
|||
KillContainer { name: String },
|
||||
|
||||
/// Point `name`'s container profile at `toplevel`, then reload the unit if
|
||||
/// it runs. hive-priv never builds: `toplevel` is a closure already in the
|
||||
/// store (see `RealiseClosure`).
|
||||
/// it runs. hive-priv never builds: `toplevel` must be the path the last
|
||||
/// successful `RealiseClosure` for `name` returned, and it refuses any other.
|
||||
/// When `stream` is `true`, hive-priv sends `PrivEvent::Line` messages
|
||||
/// as the process runs, then a terminal `PrivEvent::Done`.
|
||||
UpdateContainer {
|
||||
|
|
|
|||
|
|
@ -47,8 +47,14 @@ Each module's `//!` header lists its ops.
|
|||
`container_toplevel_action` (in `src/container.rs`) applies a closure that is
|
||||
already in the store; hive-priv builds nothing. hive-c0re names the closure
|
||||
(`toplevel` on `CreateContainer` / `UpdateContainer`), downloaded beforehand
|
||||
with `RealiseClosure`, and hive-priv accepts it only in the shape of a store
|
||||
path.
|
||||
with `RealiseClosure`. hive-priv remembers, per agent, the path its last
|
||||
successful `RealiseClosure` returned (in memory), and applies only that path to
|
||||
that agent. It refuses a path never realised for the agent, another agent's
|
||||
and the host's own, and logs an error naming the agent and both paths. A
|
||||
restart between the download and the apply empties the record, so the apply
|
||||
fails and the job retries. hive-priv does not re-check the signature at apply
|
||||
time: `RealiseClosure` enforces it, and the binding limits an apply to what that
|
||||
call stored.
|
||||
|
||||
`create` passes the path to `nixos-container create --system-path`, so
|
||||
`nixos-container`'s own `buildFlake()` never runs. Without the flag, it builds
|
||||
|
|
|
|||
|
|
@ -143,14 +143,78 @@ fn reason(stderr: &str) -> String {
|
|||
/// out of the store: no cache or key on this host, nix failing or timing out.
|
||||
pub(crate) async fn realise_closure(agent: &str, store_path: &str) -> Result<(String, String)> {
|
||||
let url = std::env::var(CACHE_URL_ENV).ok();
|
||||
realise_with(
|
||||
let done = realise_with(
|
||||
url.as_deref(),
|
||||
Path::new(ROOT_FACTS_DIR),
|
||||
0,
|
||||
agent,
|
||||
store_path,
|
||||
)
|
||||
.await
|
||||
.await?;
|
||||
if let Ok(mut realised) = REALISED.lock() {
|
||||
remember(&mut realised, agent, store_path);
|
||||
}
|
||||
Ok(done)
|
||||
}
|
||||
|
||||
/// The store path each agent's last successful `RealiseClosure` put in the
|
||||
/// store. `CreateContainer` / `UpdateContainer` apply only that path for that
|
||||
/// agent. hive-priv is a long-running process, so the entry outlives the
|
||||
/// calls between the download and the apply; a restart in between empties it
|
||||
/// and the apply is refused.
|
||||
static REALISED: std::sync::Mutex<Vec<(String, String)>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
fn remember(realised: &mut Vec<(String, String)>, agent: &str, store_path: &str) {
|
||||
realised.retain(|(a, _)| a != agent);
|
||||
realised.push((agent.to_owned(), store_path.to_owned()));
|
||||
}
|
||||
|
||||
/// The path recorded for `agent`.
|
||||
fn lookup<'a>(realised: &'a [(String, String)], agent: &str) -> Option<&'a str> {
|
||||
realised
|
||||
.iter()
|
||||
.find(|(a, _)| a == agent)
|
||||
.map(|(_, p)| p.as_str())
|
||||
}
|
||||
|
||||
/// `toplevel` when it is a store path and the one `agent`'s last
|
||||
/// `RealiseClosure` returned (`realised`); else the refusal, which is also
|
||||
/// logged at error level with the agent and both paths.
|
||||
///
|
||||
/// The request comes from hive-c0re, which is not root. A signed path is not
|
||||
/// enough: it could be another agent's closure or the host's own.
|
||||
///
|
||||
/// # Errors
|
||||
/// `toplevel` is not a store path, or is not `realised`.
|
||||
pub(crate) fn applicable<'a>(
|
||||
agent: &str,
|
||||
toplevel: &'a str,
|
||||
realised: Option<&str>,
|
||||
) -> Result<&'a str> {
|
||||
if !valid_closure_path(toplevel) {
|
||||
bail!("{toplevel:?} is not a store path");
|
||||
}
|
||||
if realised == Some(toplevel) {
|
||||
return Ok(toplevel);
|
||||
}
|
||||
let realised = realised.unwrap_or("none");
|
||||
tracing::error!(
|
||||
%agent, requested = %toplevel, %realised,
|
||||
"refusing to apply a closure that is not the one realised for the agent"
|
||||
);
|
||||
bail!("{agent}: {toplevel} is not the closure realised for this agent ({realised})")
|
||||
}
|
||||
|
||||
/// [`applicable`] against what `RealiseClosure` recorded for `agent`.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`applicable`].
|
||||
pub(crate) fn applicable_for<'a>(agent: &str, toplevel: &'a str) -> Result<&'a str> {
|
||||
let realised = REALISED
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|r| lookup(&r, agent).map(str::to_owned));
|
||||
applicable(agent, toplevel, realised.as_deref())
|
||||
}
|
||||
|
||||
/// [`realise_closure`] with the cache's address and the key's directory and
|
||||
|
|
@ -216,6 +280,62 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
const OTHER: &str = "/nix/store/zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz-nixos-system-iris";
|
||||
|
||||
#[test]
|
||||
fn the_path_realised_for_the_agent_is_applied() {
|
||||
assert_eq!(applicable("atlas", PATH, Some(PATH)).unwrap(), PATH);
|
||||
}
|
||||
|
||||
/// Another agent's realised path, a valid-shaped path never realised, and
|
||||
/// a restart that emptied the record are all refused, naming the agent
|
||||
/// and both paths.
|
||||
#[test]
|
||||
fn any_other_path_is_refused() {
|
||||
for (case, realised) in [
|
||||
("another path was realised for the agent", Some(OTHER)),
|
||||
("nothing was realised for the agent", None),
|
||||
] {
|
||||
let err = applicable("atlas", PATH, realised).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("atlas")
|
||||
&& msg.contains(PATH)
|
||||
&& msg.contains(realised.unwrap_or("none")),
|
||||
"{case}: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_store_path_is_applied_even_when_it_was_recorded() {
|
||||
for bad in [
|
||||
"",
|
||||
"main",
|
||||
"/etc/passwd",
|
||||
"/nix/store/x",
|
||||
&format!("{PATH}/bin"),
|
||||
] {
|
||||
let err = applicable("atlas", bad, Some(bad)).unwrap_err();
|
||||
assert!(format!("{err:#}").contains("not a store path"), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Each agent keeps its own entry, and a newer download replaces the
|
||||
/// older one.
|
||||
#[test]
|
||||
fn a_record_is_per_agent_and_the_latest_wins() {
|
||||
let mut realised = Vec::new();
|
||||
remember(&mut realised, "atlas", PATH);
|
||||
remember(&mut realised, "iris", OTHER);
|
||||
assert_eq!(lookup(&realised, "atlas"), Some(PATH));
|
||||
assert_eq!(lookup(&realised, "iris"), Some(OTHER));
|
||||
remember(&mut realised, "atlas", OTHER);
|
||||
assert_eq!(lookup(&realised, "atlas"), Some(OTHER));
|
||||
assert_eq!(lookup(&realised, "nobody"), None);
|
||||
assert_eq!(realised.len(), 2);
|
||||
}
|
||||
|
||||
fn option<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
args.windows(3)
|
||||
.find(|w| w[0] == "--option" && w[1] == name)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//! and stream progress to the caller as [`PrivEvent::Line`]s when asked to.
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use hive_priv_sock::{PrivEvent, PrivStream, PrivStreamLine, valid_closure_path};
|
||||
use hive_priv_sock::{PrivEvent, PrivStream, PrivStreamLine};
|
||||
use tokio::io::{AsyncWriteExt, BufReader};
|
||||
use tokio::net::unix::OwnedWriteHalf;
|
||||
use tokio::process::Command;
|
||||
|
|
@ -71,17 +71,8 @@ pub(crate) async fn destroy_container(name: &str) -> Result<(String, String)> {
|
|||
container_run(&["destroy", &container_system_name(name)]).await
|
||||
}
|
||||
|
||||
/// `toplevel` as a store path, or the error saying it is not one. The path
|
||||
/// crosses the socket from hive-c0re, which is not root.
|
||||
fn toplevel_to_apply(toplevel: &str) -> Result<&str> {
|
||||
if !valid_closure_path(toplevel) {
|
||||
bail!("{toplevel:?} is not a store path");
|
||||
}
|
||||
Ok(toplevel)
|
||||
}
|
||||
|
||||
/// Shared body for `CreateContainer` / `UpdateContainer`: validate the name
|
||||
/// and the closure, and apply it (streaming line events to `writer` when
|
||||
/// and that `toplevel` is the closure realised for the agent, and apply it (streaming line events to `writer` when
|
||||
/// `stream` is set). Nothing is built here: `toplevel` is a closure already in
|
||||
/// the store, which `RealiseClosure` put there.
|
||||
///
|
||||
|
|
@ -102,7 +93,7 @@ pub(crate) async fn container_toplevel_action(
|
|||
writer: &mut OwnedWriteHalf,
|
||||
) -> Result<(String, String)> {
|
||||
validate_agent_name(name)?;
|
||||
let toplevel = toplevel_to_apply(toplevel)?;
|
||||
let toplevel = crate::closure::applicable_for(name, toplevel)?;
|
||||
let system_name = container_system_name(name);
|
||||
if verb == "update" {
|
||||
return swap_container_profile(&system_name, toplevel, stream, writer).await;
|
||||
|
|
@ -567,34 +558,9 @@ async fn container_run_streaming(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BoundedRun, run_bounded, toplevel_to_apply};
|
||||
use super::{BoundedRun, run_bounded};
|
||||
use crate::shared::test_support::scratch;
|
||||
|
||||
const TOPLEVEL: &str = "/nix/store/0123456789abcdfghijklmnpqrsvwxyz-nixos-system-atlas";
|
||||
|
||||
/// A store path passes through unchanged; anything else, whatever the
|
||||
/// request says, never reaches `nix-env` or `nixos-container`.
|
||||
#[test]
|
||||
fn only_a_store_path_is_applied() {
|
||||
assert_eq!(toplevel_to_apply(TOPLEVEL).unwrap(), TOPLEVEL);
|
||||
for bad in [
|
||||
"",
|
||||
"main",
|
||||
"../../x",
|
||||
"/etc/passwd",
|
||||
"/nix/store/x",
|
||||
&format!("{TOPLEVEL}/bin"),
|
||||
&format!("{TOPLEVEL};id"),
|
||||
&"c".repeat(40),
|
||||
] {
|
||||
let err = toplevel_to_apply(bad).unwrap_err();
|
||||
assert!(
|
||||
format!("{err:#}").contains("not a store path"),
|
||||
"{bad}: {err:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `pid` is gone, a zombie (`Z`), or being reaped (`X`): none of
|
||||
/// these runs again, and a process passes through them in that order,
|
||||
/// so once this is true it stays true.
|
||||
|
|
|
|||
Loading…
Reference in a new issue