Watch
0
0
Fork
You've already forked hyperhive
0

mint a live agent's missing store identity from the renewal pass, and log the step a mint stopped at

This commit is contained in:
atlas 2026-10-29 03:17:16 +01:00 • committed by mara
commit 8126e0fffa

View file

@ -27,7 +27,7 @@
//! mount; its key never leaves the store. It signs no host leaf, and no host
//! role pins it, so an agent's certificate satisfies only that agent's role.
//! [`crate::agent_renewal`] re-issues a live agent's leaf once it is past half
//! its validity (the role's `ttl`), by queueing the same node as creation.
//! its validity (the role's `ttl`) or missing, by queueing the same node as creation.
use crate::store::AccessStore;
use anyhow::{Context, Result, bail};
@ -102,10 +102,12 @@ async fn publish_access(
"writing the shared agent policy {}",
policy::SHARED_AGENT_POLICY
)
})?;
})
.at(MintStep::Policy)?;
crate::agent_entity::ensure_agent(store, agent)
.await
.with_context(|| format!("naming {agent}'s identity entity; the role is not changed"))?;
.with_context(|| format!("naming {agent}'s identity entity; the role is not changed"))
.at(MintStep::Entity)?;
store
.write_cert_role(
DEFAULT_CERT_MOUNT,
@ -120,6 +122,7 @@ async fn publish_access(
)
.await
.with_context(|| format!("writing the cert-auth role {}", inputs.name))
.at(MintStep::Role)
}
/// How many bytes of kernel randomness a queue secret is before encoding.
@ -152,6 +155,73 @@ pub(crate) fn generate_queue_secret() -> Result<String> {
))
}
/// The part of [`mint_and_verify`] a failure came from.
///
/// Attached to the error as its outermost context, so the
/// message leads with it and [`failed_step`] reads it back as a value.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MintStep {
/// Reading the environment and the agent's name, before any store call.
Setup,
/// Logging in to the store as the controller.
StoreLogin,
/// Issuing the leaf from the agent PKI role.
IssueLeaf,
/// Publishing the leaf at [`mtls::identity_path`].
WriteBaoMtls,
/// Reading or publishing the queue secret at [`queue::agent_queue_path`].
Queue,
/// Writing the shared ACL document.
Policy,
/// Naming the agent's identity entity.
Entity,
/// Deriving or writing the cert-auth role.
Role,
/// The login as the agent and the reads after it.
ReadBack,
}
impl MintStep {
/// The step as it reads in a log line.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
MintStep::Setup => "setup",
MintStep::StoreLogin => "store login",
MintStep::IssueLeaf => "issue leaf",
MintStep::WriteBaoMtls => "write bao-mtls",
MintStep::Queue => "queue",
MintStep::Policy => "policy",
MintStep::Entity => "entity",
MintStep::Role => "role",
MintStep::ReadBack => "read back",
}
}
}
impl std::fmt::Display for MintStep {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// The [`MintStep`] `error` was attached to, if [`mint_and_verify`] raised it.
#[must_use]
pub fn failed_step(error: &anyhow::Error) -> Option<MintStep> {
error.downcast_ref::<MintStep>().copied()
}
/// Names the [`MintStep`] a failing result belongs to.
trait AtStep<T> {
fn at(self, step: MintStep) -> Result<T>;
}
impl<T, E: Into<anyhow::Error>> AtStep<T> for Result<T, E> {
fn at(self, step: MintStep) -> Result<T> {
self.map_err(|e| e.into().context(step))
}
}
/// Give `agent` an identity at the store, and prove it works.
///
/// Six store calls' worth of agreement, then the login that checks it:
@ -178,27 +248,30 @@ pub(crate) fn generate_queue_secret() -> Result<String> {
/// replacing one by age is [`crate::agent_renewal`]'s.
///
/// # Errors
/// Anything that stops one of those six steps, with the step named, or the
/// read-back login. A failure here fails the job node and nothing else — a new
/// agent is still created, without a store identity, and a live agent keeps the
/// role it had.
/// Anything that stops one of those six steps, or the read-back login, with the
/// [`MintStep`] ([`failed_step`]) leading the message. A failure here fails the
/// job node and nothing else — a new agent is still created, without a store
/// identity, and a live agent keeps the role it had.
pub async fn mint_and_verify(agent: &str) -> Result<()> {
let (mount, pki_role) = agent_pki(|k| std::env::var(k).ok())?;
let name = policy::agent_object_name(agent)?;
let path = mtls::identity_path(agent)?;
let queue_path = queue::agent_queue_path(agent)?;
let (mount, pki_role) = agent_pki(|k| std::env::var(k).ok()).at(MintStep::Setup)?;
let name = policy::agent_object_name(agent).at(MintStep::Setup)?;
let path = mtls::identity_path(agent).at(MintStep::Setup)?;
let queue_path = queue::agent_queue_path(agent).at(MintStep::Setup)?;
let store = crate::store::connect()
.await
.context("logging in to the swarm secret store")?;
.context("logging in to the swarm secret store")
.at(MintStep::StoreLogin)?;
let credential = store
.issue_client_certificate(&mount, &pki_role, &name)
.await
.with_context(|| format!("issuing {name}'s certificate from {mount}/issue/{pki_role}"))?;
.with_context(|| format!("issuing {name}'s certificate from {mount}/issue/{pki_role}"))
.at(MintStep::IssueLeaf)?;
store
.write(&path, &credential)
.await
.with_context(|| format!("publishing the agent identity at {path}"))?;
.with_context(|| format!("publishing the agent identity at {path}"))
.at(MintStep::WriteBaoMtls)?;
// Read before write, and `read_optional` rather than `read`, so that only
// a genuine 404 leads to a new secret — see that method for why every
@ -206,7 +279,8 @@ pub async fn mint_and_verify(agent: &str) -> Result<()> {
let existing: Option<queue::AgentCredential> = store
.read_optional(&queue_path)
.await
.with_context(|| format!("checking whether {queue_path} already holds a credential"))?;
.with_context(|| format!("checking whether {queue_path} already holds a credential"))
.at(MintStep::Queue)?;
// The secret and its mint time survive a re-run. An object naming a
// different agent is corrected by rewriting the name around the *same*
// `value`, which no live connection notices.
@ -215,7 +289,8 @@ pub async fn mint_and_verify(agent: &str) -> Result<()> {
agent: agent.to_owned(),
..existing.clone()
},
None => crate::agent_renewal::fresh(agent, crate::agent_renewal::unix_now())?,
None => crate::agent_renewal::fresh(agent, crate::agent_renewal::unix_now())
.at(MintStep::Queue)?,
};
if existing.as_ref() == Some(&wanted) {
tracing::info!(
@ -227,7 +302,8 @@ pub async fn mint_and_verify(agent: &str) -> Result<()> {
store
.write(&queue_path, &wanted)
.await
.with_context(|| format!("publishing the agent queue credential at {queue_path}"))?;
.with_context(|| format!("publishing the agent queue credential at {queue_path}"))
.at(MintStep::Queue)?;
tracing::info!(
agent,
%queue_path,
@ -239,11 +315,13 @@ pub async fn mint_and_verify(agent: &str) -> Result<()> {
}
let queue_credential = wanted;
let inputs = role_inputs(agent, &credential)?;
let inputs = role_inputs(agent, &credential).at(MintStep::Role)?;
publish_access(&store, agent, &inputs).await?;
tracing::info!(agent, %path, role = %name, "agent store identity published");
read_back_as_agent(&credential, &name, &path, &queue_path, &queue_credential).await?;
read_back_as_agent(&credential, &name, &path, &queue_path, &queue_credential)
.await
.at(MintStep::ReadBack)?;
tracing::info!(
agent,
role = %name,
@ -369,8 +447,8 @@ async fn read_back_as_agent(
#[cfg(test)]
mod tests {
use super::{
ENV_AGENT_PKI_MOUNT, ENV_AGENT_PKI_ROLE, QUEUE_SECRET_BYTES, agent_pki,
generate_queue_secret, publish_access, role_inputs,
ENV_AGENT_PKI_MOUNT, ENV_AGENT_PKI_ROLE, MintStep, QUEUE_SECRET_BYTES, agent_pki,
failed_step, generate_queue_secret, publish_access, role_inputs,
};
use crate::store::fake::FakeAccess;
use swarm_secret_client::{mtls, policy};
@ -569,4 +647,30 @@ mod tests {
.contains(&"entity_by_alias hive-agent-atlas".to_owned())
);
}
/// Each refusal names the step it came from, in the message and as a value
/// the job runner logs. Two different steps, so a tag that named every
/// failure alike would fail one of them.
#[tokio::test]
async fn a_refused_step_is_named_in_the_message_and_by_failed_step() {
let credential = credential();
let inputs = role_inputs("atlas", &credential).expect("legal");
let err = publish_access(&FakeAccess::refusing_policies(), "atlas", &inputs)
.await
.expect_err("the policy write is refused");
assert_eq!(failed_step(&err), Some(MintStep::Policy), "{err:#}");
assert!(format!("{err:#}").starts_with("policy: "), "{err:#}");
let err = publish_access(&FakeAccess::without_accessor(), "atlas", &inputs)
.await
.expect_err("the entity step fails");
assert_eq!(failed_step(&err), Some(MintStep::Entity), "{err:#}");
assert!(format!("{err:#}").starts_with("entity: "), "{err:#}");
}
#[test]
fn an_error_from_outside_the_mint_has_no_step() {
assert_eq!(failed_step(&anyhow::anyhow!("elsewhere")), None);
}
}