The CI runner was a hive deploy module: hive-ci.nix ran it beside the forge, and hive-c0re registered it through the forge's admin API with the `core` token, writing the registration token through hive-priv. It could only run on the forge's host, and it kept an admin-scoped forge token on a hive. Now the runner is a swarm service: - swarm-ci-runner.nix runs it in a `swarm-ci-runner` container on the host that sets deploy.ci-runner.enable, reaching the forge by name. It needs neither the forge nor a hive there. - swarm-controller reads the forge's runner registration token with its own admin token and writes it to swarm/services/ci-runner/ registration-token whenever it differs from what it last wrote. Its grant on that path is create/update only. - The runner's own leaf (CN swarm-ci-runner, role and policy of the same name) reads that one path. swarm-bao-ci-runner.service fetches it into the runner's EnvironmentFile and restarts the runner on a change. - A pre-start step removes .runner when the daemon's journal since its last start holds Forgejo's "unregistered runner" refusal (the daemon exits 1 when its startup Declare gets it), or when .runner names another forge address; upstream's pre-start step then registers again. Removed from the hive side: hive-ci.nix and deploy.forgejo.ci.*, forge/ci_runner.rs, PrivRequest::RegisterCiRunner and its handler and ReadWritePaths entry, InfraContainer::Ci with hivectl's --ci and the dashboard's hive-ci journal entry, HYPERHIVE_FORGE_CI_ENABLED, and read:admin,write:admin from CORE_TOKEN_SCOPES: nothing else in hive-c0re calls /api/v1/admin/*. The old ci options, under both swarm.forge.ci and deploy.forgejo.ci, now fail evaluation naming deploy.ci-runner. The forge's half of the old ci.enable, the actions/checkout mirror and DEFAULT_ACTIONS_URL = self, is deploy.forgejo.actionsFromSelf. Closes #4954
211 lines
8.2 KiB
Rust
211 lines
8.2 KiB
Rust
//! The rules every path into the store obeys, whatever kind of secret it
|
|
//! addresses.
|
|
//!
|
|
//! The controller writes and a hive reads, and neither is senior to the other,
|
|
//! so what they must agree on is stated once here rather than at each call
|
|
//! site. A *particular* kind of secret builds its path from these pieces in its
|
|
//! own module — [`crate::matrix`] is the one that exists.
|
|
|
|
use crate::Error;
|
|
|
|
/// The KV v2 mount every swarm secret lives under.
|
|
///
|
|
/// A literal because the nix-side readers spell the same one, as in
|
|
/// `glue-queue-agent-credential.nix`; an option nobody sets would be two ways
|
|
/// to say one thing.
|
|
pub const MOUNT: &str = "secret";
|
|
|
|
/// The root under [`MOUNT`] that every swarm secret lives beneath.
|
|
pub const ROOT: &str = "swarm";
|
|
|
|
/// Whose secret it is — the second segment of every path.
|
|
///
|
|
/// An enum rather than free strings so the set is closed: the store's grant is
|
|
/// written against these segments and cannot be reached from Rust, so a
|
|
/// misspelled kind is a 403 at provision time rather than anything a compiler
|
|
/// sees. [`Kind::ALL`] exists so a test can enumerate the set instead of
|
|
/// restating it.
|
|
///
|
|
/// The segment string lives once, on the variant itself
|
|
/// (`#[strum(serialize = "...")]`), rather than a second time in a
|
|
/// hand-written `as_str()` match — [`strum::IntoStaticStr`] derives that
|
|
/// conversion, so the attribute is the only place a segment is spelled.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)]
|
|
pub enum Kind {
|
|
/// One agent container's own secrets.
|
|
#[strum(serialize = "agents")]
|
|
Agent,
|
|
/// One hive's secrets, held on behalf of whatever runs there. An identity
|
|
/// minted per hive rather than per agent lands here even though an agent
|
|
/// is what uses it.
|
|
#[strum(serialize = "hives")]
|
|
Hive,
|
|
/// One swarm service — the things a swarm runs beside the controller.
|
|
#[strum(serialize = "services")]
|
|
Service,
|
|
/// The controller itself. There is one per swarm, so the name segment
|
|
/// below it does not vary; the shape stays uniform anyway, because one
|
|
/// grant pattern over `<kind>/<name>` is cheaper than a special case.
|
|
#[strum(serialize = "controller")]
|
|
Controller,
|
|
}
|
|
|
|
impl Kind {
|
|
/// Every kind, so callers that must cover the whole set can iterate rather
|
|
/// than restate it — a second list is a list that drifts.
|
|
pub const ALL: [Kind; 4] = [Kind::Agent, Kind::Hive, Kind::Service, Kind::Controller];
|
|
|
|
/// What to call the name in an error — singular, because the message reads
|
|
/// "hive name ... is not a single path segment".
|
|
#[must_use]
|
|
pub const fn label(self) -> &'static str {
|
|
match self {
|
|
Kind::Agent => "agent",
|
|
Kind::Hive => "hive",
|
|
Kind::Service => "service",
|
|
Kind::Controller => "controller",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `swarm/<kind>/<name>` — everything one principal owns, and the only way to
|
|
/// build the head of a path.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when `name` is not a single segment of
|
|
/// `[A-Za-z0-9_-]`, which is what keeps one principal's name from addressing
|
|
/// another's secrets.
|
|
pub fn principal_prefix(kind: Kind, name: &str) -> Result<String, Error> {
|
|
checked_segment(kind.label(), name)?;
|
|
Ok(format!("{ROOT}/{}/{name}", <&str>::from(kind)))
|
|
}
|
|
|
|
/// A path segment that cannot change the path's shape.
|
|
///
|
|
/// The charset is deliberately narrower than what the store accepts: a `/`
|
|
/// turns one agent's segment into another agent's directory, and `..` walks
|
|
/// out of the prefix entirely. Both are names this crate receives from
|
|
/// elsewhere — an agent name from the topology, an account name from an
|
|
/// agent's own config — so neither is trusted to be well-formed here.
|
|
///
|
|
/// Public because the same name is also used to build a path **on disk**, and
|
|
/// that guard must accept exactly what this one does. Two copies of a charset
|
|
/// are two charsets: they agree until one is edited, and the day they diverge
|
|
/// is the day a name is legal in the store and not on the filesystem, or the
|
|
/// reverse.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when `value` is empty or holds anything outside
|
|
/// `[A-Za-z0-9_-]`.
|
|
pub fn checked_segment(kind: &'static str, value: &str) -> Result<(), Error> {
|
|
if value.is_empty() {
|
|
return Err(Error::PathSegment {
|
|
kind,
|
|
value: value.to_owned(),
|
|
});
|
|
}
|
|
if !value
|
|
.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
|
{
|
|
return Err(Error::PathSegment {
|
|
kind,
|
|
value: value.to_owned(),
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_segment_that_could_change_a_paths_shape_is_refused() {
|
|
// Each of these is a *different* way to reach outside the segment the
|
|
// caller meant, and the last two are the ones a charset check catches
|
|
// but a `contains("..")` check does not.
|
|
for bad in [
|
|
"../argus",
|
|
"atlas/../argus",
|
|
"atlas/matrix",
|
|
"a b",
|
|
"a.b",
|
|
"",
|
|
] {
|
|
assert!(
|
|
checked_segment("agent", bad).is_err(),
|
|
"segment {bad:?} must be refused"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_legal_charset_is_actually_reachable() {
|
|
// The control for the test above: if `checked_segment` rejected
|
|
// everything, the escape cases would pass for the wrong reason. The
|
|
// charset is the one `hive-priv` accepts for the same names on disk,
|
|
// so uppercase and underscore have to stay legal here.
|
|
assert!(checked_segment("agent", "a-b_C9").is_ok());
|
|
assert!(checked_segment("account", "d-e_F0").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn the_rejected_segment_is_named_in_the_error() {
|
|
// The caller usually got the name from config and needs to see which
|
|
// of the two it was.
|
|
let e = checked_segment("account", "a b").expect_err("a space is not legal");
|
|
assert!(
|
|
matches!(e, Error::PathSegment { kind, ref value } if kind == "account" && value == "a b"),
|
|
"got {e:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_bao_policy_is_written_against_exactly_these_segments() {
|
|
// Renaming any of these is a silent 403 at provision time, not a
|
|
// compile error: the controller's grant spells the mount and root out
|
|
// in `nix/host-modules/swarm-bao.nix` (`controllerPolicyText`), which
|
|
// no Rust change can reach. Editing here means editing there.
|
|
assert_eq!(MOUNT, "secret");
|
|
assert_eq!(ROOT, "swarm");
|
|
|
|
// Iterated, not listed: the grant covers `<root>/*`, so a kind added
|
|
// without a segment here would be granted by accident rather than by
|
|
// decision. Spelling each one out is what makes adding a kind a
|
|
// deliberate edit.
|
|
let segments: Vec<&str> = Kind::ALL.iter().map(|k| (*k).into()).collect();
|
|
assert_eq!(segments, ["agents", "hives", "services", "controller"]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_kind_cannot_share_a_segment_or_a_label_with_another() {
|
|
// Two kinds resolving to one segment would silently merge two
|
|
// principals' secrets into one directory; two sharing a label would
|
|
// make the error name the wrong one.
|
|
for (i, a) in Kind::ALL.iter().enumerate() {
|
|
for b in &Kind::ALL[i + 1..] {
|
|
assert_ne!(
|
|
<&str>::from(*a),
|
|
<&str>::from(*b),
|
|
"{a:?} and {b:?} share a segment"
|
|
);
|
|
assert_ne!(a.label(), b.label(), "{a:?} and {b:?} share a label");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_principal_prefix_refuses_a_name_that_would_escape_it() {
|
|
// The control for the arm below: a well-formed name really does build.
|
|
assert_eq!(
|
|
principal_prefix(Kind::Hive, "alpha").expect("a plain name is legal"),
|
|
"swarm/hives/alpha"
|
|
);
|
|
let e = principal_prefix(Kind::Hive, "../atlas").expect_err("a traversal is not");
|
|
assert!(
|
|
matches!(e, Error::PathSegment { kind, .. } if kind == "hive"),
|
|
"the error must name the principal in the singular, got {e:?}"
|
|
);
|
|
}
|
|
}
|