swarm-controller: re-issue agent certificates and re-mint queue secrets at half-life
A five-minute pass over every agent some hive's wanted state declares as anything but destroyed queues, per agent: - `MintAgentIdentity` (the node agent creation uses) when the stored certificate at swarm/agents/<agent>/bao-mtls is past half its validity, read from its own notBefore/notAfter: day 45 of the role's 90; - the new `RenewAgentQueueCredential` node when the queue secret at swarm/agents/<agent>/queue is 45 days old or has no mint time. The node re-decides, writes a fresh value with `minted_at`, reads it back, and logs the agent and the old age. When both are due the secret node runs after_any the certificate node, because mint_and_verify compares the queue secret it read with the one it reads back. A credential that is not stored is never created here. `queue::AgentCredential` gains an optional `minted_at` (unix seconds); agent creation now sets it. Stored objects without it decode unchanged and count as due, so every existing queue secret is re-minted on the first pass. Both replacements reach the agent at its next start. The old certificate stays valid until it expires; the old queue secret does not, so a queue reconnect before that restart is denied. Adds x509-cert 0.2 (with der_derive and flagset) to read the validity. docs/swarm/credentials.md: the renewal column splits into automatic re-mint and automatic re-pull, filled from the code as it stands.
This commit is contained in:
parent
b14ff2796c
commit
2115ec2bb3
10 changed files with 763 additions and 29 deletions
496
swarm-controller/src/agent_renewal.rs
Normal file
496
swarm-controller/src/agent_renewal.rs
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
//! Renewing each live agent's two store credentials by age: its mTLS
|
||||
//! certificate (`swarm/agents/<agent>/bao-mtls`) once it is past half its
|
||||
//! validity, and its queue secret (`swarm/agents/<agent>/queue`) once it is
|
||||
//! [`SECRET_RENEW_AFTER`] old.
|
||||
//!
|
||||
//! The certificate's age is its own `notBefore`/`notAfter`. The queue secret
|
||||
//! has no expiry and `swarm-nats-auth` checks nothing about time, so its age is
|
||||
//! the controller's own record, [`queue::AgentCredential::minted_at`]; a secret
|
||||
//! without one is due.
|
||||
//!
|
||||
//! Either replacement reaches the agent at its next start, when the container
|
||||
//! is handed the certificate and fetches the secret; nothing pulls either into
|
||||
//! a running container. The old certificate stays valid until it expires. The
|
||||
//! old secret stops working at once: the queue checks it only at `CONNECT`, so
|
||||
//! an open connection is unaffected, but a reconnect before that restart
|
||||
//! presents the old secret and is denied.
|
||||
//!
|
||||
//! [`spawn`]'s pass, at start and every [`RECONCILE_INTERVAL`], inserts a job
|
||||
//! per agent with anything due: `MintAgentIdentity` for the certificate
|
||||
//! ([`crate::agent_identity::mint_and_verify`], which keeps the queue secret as
|
||||
//! it is) and `RenewAgentQueueCredential` ([`renew`]) for the secret, the
|
||||
//! second after the first when both are due. The decisions are pure
|
||||
//! ([`live_agents`], [`cert_is_due`], [`secret_is_due`], [`plan`]) so the tests
|
||||
//! pin them; the IO on either side only reads or acts.
|
||||
//!
|
||||
//! **Only agents some hive is declared to run are renewed** ([`live_agents`]):
|
||||
//! the wanted-state declarations are where a destroy is recorded, and an agent
|
||||
//! declared nowhere, or only as `Destroyed`, is skipped. A credential that is
|
||||
//! not stored is skipped too — creating one is agent creation's job, and a
|
||||
//! destroy deletes it.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use swarm_queue_client::wanted::{AgentState, HiveWanted};
|
||||
use swarm_secret_client::{SecretStore, mtls, queue};
|
||||
use x509_cert::der::DecodePem as _;
|
||||
|
||||
use crate::wanted::WantedWriter;
|
||||
|
||||
/// Age at which a queue secret is re-minted: half the agent certificate's
|
||||
/// 90-day life (`agentPkiLeafTtl` in `swarm-bao.nix`), so the two renew on one
|
||||
/// cadence.
|
||||
pub const SECRET_RENEW_AFTER: std::time::Duration = std::time::Duration::from_hours(45 * 24);
|
||||
|
||||
/// How often [`spawn`] re-checks every agent.
|
||||
const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
|
||||
|
||||
/// Now, in the unix seconds [`queue::AgentCredential::minted_at`] holds.
|
||||
pub fn unix_now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
|
||||
}
|
||||
|
||||
/// Whether a queue secret is due for a re-mint at `now`: it has no mint time,
|
||||
/// or it is at least [`SECRET_RENEW_AFTER`] old.
|
||||
pub fn secret_is_due(stored: &queue::AgentCredential, now: i64) -> bool {
|
||||
let renew_after = i64::try_from(SECRET_RENEW_AFTER.as_secs()).unwrap_or(i64::MAX);
|
||||
stored
|
||||
.minted_at
|
||||
.is_none_or(|at| now.saturating_sub(at) >= renew_after)
|
||||
}
|
||||
|
||||
/// A certificate's validity window, `(notBefore, notAfter)` in unix seconds.
|
||||
///
|
||||
/// # Errors
|
||||
/// When `pem` is not one PEM-encoded X.509 certificate.
|
||||
pub fn cert_validity(pem: &str) -> Result<(i64, i64)> {
|
||||
let cert = x509_cert::Certificate::from_pem(pem.as_bytes())
|
||||
.context("decoding the stored certificate")?;
|
||||
let validity = &cert.tbs_certificate.validity;
|
||||
let secs = |t: x509_cert::time::Time| {
|
||||
i64::try_from(t.to_unix_duration().as_secs()).unwrap_or(i64::MAX)
|
||||
};
|
||||
Ok((secs(validity.not_before), secs(validity.not_after)))
|
||||
}
|
||||
|
||||
/// Whether a certificate valid from `not_before` to `not_after` is past half
|
||||
/// its life at `now`.
|
||||
pub fn cert_is_due(not_before: i64, not_after: i64, now: i64) -> bool {
|
||||
let half = not_after.saturating_sub(not_before) / 2;
|
||||
now >= not_before.saturating_add(half)
|
||||
}
|
||||
|
||||
/// A new queue credential for `agent`, minted at `now`. Shared with agent
|
||||
/// creation, so every secret this daemon writes carries a mint time.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the kernel will not supply randomness.
|
||||
pub fn fresh(agent: &str, now: i64) -> Result<queue::AgentCredential> {
|
||||
Ok(queue::AgentCredential {
|
||||
value: crate::agent_identity::generate_queue_secret()?,
|
||||
agent: agent.to_owned(),
|
||||
minted_at: Some(now),
|
||||
})
|
||||
}
|
||||
|
||||
/// Every agent declared on at least one hive in a state other than
|
||||
/// `Destroyed`.
|
||||
pub fn live_agents(declarations: &[HiveWanted]) -> BTreeSet<String> {
|
||||
declarations
|
||||
.iter()
|
||||
.flat_map(|d| &d.agents)
|
||||
.filter(|(_, wanted)| wanted.state != AgentState::Destroyed)
|
||||
.map(|(agent, _)| agent.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What one pass found for one credential of one live agent.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Observed {
|
||||
/// Stored and due, with its age in seconds when that is known.
|
||||
Due(Option<i64>),
|
||||
/// Stored and not yet due.
|
||||
Current,
|
||||
/// Nothing stored. Never renewed: see the module doc.
|
||||
Absent,
|
||||
/// The read or the decode failed. Nothing is known, so nothing is done.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// What one pass found for one live agent.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AgentObserved {
|
||||
pub cert: Observed,
|
||||
pub secret: Observed,
|
||||
}
|
||||
|
||||
/// One agent's share of a pass: which of its credentials to renew.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Renewal {
|
||||
pub agent: String,
|
||||
pub cert: bool,
|
||||
pub secret: bool,
|
||||
}
|
||||
|
||||
/// The renewals a pass queues: one per agent with at least one
|
||||
/// [`Observed::Due`] credential.
|
||||
pub fn plan(observed: &[(String, AgentObserved)]) -> Vec<Renewal> {
|
||||
observed
|
||||
.iter()
|
||||
.map(|(agent, o)| Renewal {
|
||||
agent: agent.clone(),
|
||||
cert: matches!(o.cert, Observed::Due(_)),
|
||||
secret: matches!(o.secret, Observed::Due(_)),
|
||||
})
|
||||
.filter(|r| r.cert || r.secret)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An age for a log line: whole days, or `unrecorded`.
|
||||
struct Age(Option<i64>);
|
||||
|
||||
impl std::fmt::Display for Age {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self.0 {
|
||||
Some(secs) => write!(f, "{}d", secs / 86_400),
|
||||
None => f.write_str("unrecorded"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-mint `agent`'s queue secret if it is still stored and still due. The
|
||||
/// whole job of the `RenewAgentQueueCredential` node.
|
||||
///
|
||||
/// Re-decides rather than trusting the pass that queued it, so a node queued
|
||||
/// twice renews once. Reads the write back before reporting success.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the store refuses a step, or returns a different object than the one
|
||||
/// just written.
|
||||
pub async fn renew(agent: &str) -> Result<()> {
|
||||
let path = queue::agent_queue_path(agent)?;
|
||||
let store = crate::store::connect()
|
||||
.await
|
||||
.context("logging in to the swarm secret store")?;
|
||||
let Some(stored) = store
|
||||
.read_optional::<queue::AgentCredential>(&path)
|
||||
.await
|
||||
.with_context(|| format!("reading {path}"))?
|
||||
else {
|
||||
tracing::info!(agent, %path, "agent queue credential: nothing stored, nothing to renew");
|
||||
return Ok(());
|
||||
};
|
||||
let now = unix_now();
|
||||
if !secret_is_due(&stored, now) {
|
||||
tracing::debug!(agent, "agent queue credential is current; left as it is");
|
||||
return Ok(());
|
||||
}
|
||||
let renewed = fresh(agent, now)?;
|
||||
store
|
||||
.write(&path, &renewed)
|
||||
.await
|
||||
.with_context(|| format!("writing the re-minted agent queue credential at {path}"))?;
|
||||
let read_back: queue::AgentCredential = store
|
||||
.read(&path)
|
||||
.await
|
||||
.with_context(|| format!("reading {path} back"))?;
|
||||
if read_back != renewed {
|
||||
bail!("the store returned a different object at {path} than the one just written");
|
||||
}
|
||||
tracing::info!(
|
||||
agent,
|
||||
%path,
|
||||
old_age = %Age(stored.minted_at.map(|at| now.saturating_sub(at))),
|
||||
"agent queue credential re-minted; the agent presents it from its next restart"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What the store says about `agent`'s certificate.
|
||||
async fn observe_cert(store: &SecretStore, agent: &str, now: i64) -> Observed {
|
||||
let stored = match mtls::identity_path(agent) {
|
||||
Ok(path) => store.read_optional::<mtls::Credential>(&path).await,
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
let credential = match stored {
|
||||
Ok(Some(credential)) => credential,
|
||||
Ok(None) => return Observed::Absent,
|
||||
Err(e) => {
|
||||
tracing::warn!(agent, error = %e, "agent certificate: store read failed");
|
||||
return Observed::Unknown;
|
||||
}
|
||||
};
|
||||
match cert_validity(&credential.cert) {
|
||||
Ok((not_before, not_after)) if cert_is_due(not_before, not_after, now) => {
|
||||
Observed::Due(Some(now.saturating_sub(not_before)))
|
||||
}
|
||||
Ok(_) => Observed::Current,
|
||||
Err(e) => {
|
||||
tracing::warn!(agent, error = %format!("{e:#}"), "agent certificate: unreadable");
|
||||
Observed::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the store says about `agent`'s queue secret.
|
||||
async fn observe_secret(store: &SecretStore, agent: &str, now: i64) -> Observed {
|
||||
let stored = match queue::agent_queue_path(agent) {
|
||||
Ok(path) => store.read_optional::<queue::AgentCredential>(&path).await,
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
match stored {
|
||||
Ok(Some(stored)) if secret_is_due(&stored, now) => {
|
||||
Observed::Due(stored.minted_at.map(|at| now.saturating_sub(at)))
|
||||
}
|
||||
Ok(Some(_)) => Observed::Current,
|
||||
Ok(None) => Observed::Absent,
|
||||
Err(e) => {
|
||||
tracing::warn!(agent, error = %e, "agent queue credential: store read failed");
|
||||
Observed::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One pass: every live agent, observed. Fails as a whole when any hive's
|
||||
/// declaration cannot be read, since that hive may be the one declaring an
|
||||
/// agent destroyed.
|
||||
async fn observe_all(
|
||||
wanted: &WantedWriter,
|
||||
hives: &[String],
|
||||
) -> Result<Vec<(String, AgentObserved)>> {
|
||||
let mut declarations = Vec::with_capacity(hives.len());
|
||||
for hive in hives {
|
||||
if let Some(d) = wanted
|
||||
.view(hive)
|
||||
.await
|
||||
.with_context(|| format!("reading {hive}'s wanted-state declaration"))?
|
||||
{
|
||||
declarations.push(d);
|
||||
}
|
||||
}
|
||||
let store = crate::store::connect()
|
||||
.await
|
||||
.context("logging in to the swarm secret store")?;
|
||||
let now = unix_now();
|
||||
let mut observed = Vec::new();
|
||||
for agent in live_agents(&declarations) {
|
||||
let o = AgentObserved {
|
||||
cert: observe_cert(&store, &agent, now).await,
|
||||
secret: observe_secret(&store, &agent, now).await,
|
||||
};
|
||||
if let Observed::Due(age) = o.cert {
|
||||
tracing::info!(agent, age = %Age(age), "agent certificate past half its life; re-issuing");
|
||||
}
|
||||
if let Observed::Due(age) = o.secret {
|
||||
tracing::info!(agent, age = %Age(age), "agent queue credential due; re-minting");
|
||||
}
|
||||
observed.push((agent, o));
|
||||
}
|
||||
Ok(observed)
|
||||
}
|
||||
|
||||
/// Check every live agent now and every [`RECONCILE_INTERVAL`] after, and hand
|
||||
/// the renewals due to `enqueue`, which inserts a job for each.
|
||||
///
|
||||
/// The first tick fires immediately. A pass that fails is logged and retried
|
||||
/// on the next tick; it never stops the daemon.
|
||||
pub fn spawn(
|
||||
wanted: Arc<WantedWriter>,
|
||||
hives: Vec<String>,
|
||||
enqueue: impl Fn(Vec<Renewal>) + Send + 'static,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
match observe_all(&wanted, &hives).await {
|
||||
Ok(observed) => {
|
||||
let renewals = plan(&observed);
|
||||
if renewals.is_empty() {
|
||||
tracing::debug!(
|
||||
checked = observed.len(),
|
||||
"agent credential renewal: none due"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
checked = observed.len(),
|
||||
renewing = renewals.len(),
|
||||
"agent credential renewal: queueing"
|
||||
);
|
||||
enqueue(renewals);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
retry_in_s = RECONCILE_INTERVAL.as_secs(),
|
||||
"agent credential renewal: pass failed; retrying next tick"
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use swarm_queue_client::wanted::AgentWanted;
|
||||
|
||||
const NOW: i64 = 1_790_000_000;
|
||||
const DAY: i64 = 86_400;
|
||||
|
||||
/// Self-signed, CN `hive-agent-atlas`, valid 2026-01-01T00:00:00Z to
|
||||
/// 2026-04-01T00:00:00Z: 90 days, the agent role's `ttl`. Its key was
|
||||
/// discarded.
|
||||
const CERT_PEM: &str = "-----BEGIN CERTIFICATE-----
|
||||
MIIBizCCATGgAwIBAgIUSFpuYmy1UoHvq/MYiyrAudRaWXcwCgYIKoZIzj0EAwIw
|
||||
GzEZMBcGA1UEAwwQaGl2ZS1hZ2VudC1hdGxhczAeFw0yNjAxMDEwMDAwMDBaFw0y
|
||||
NjA0MDEwMDAwMDBaMBsxGTAXBgNVBAMMEGhpdmUtYWdlbnQtYXRsYXMwWTATBgcq
|
||||
hkjOPQIBBggqhkjOPQMBBwNCAAR7p2eZAxjiE1RdsuSHE3CcTjbnd4swzfnmqUPW
|
||||
9NJN7mvOtqEzxCKfOSFjaIhhyShIQ41/V6vGi80EPDB+uR56o1MwUTAdBgNVHQ4E
|
||||
FgQUxgZFsN1VY3ODrPS1NUfY/x3EnmswHwYDVR0jBBgwFoAUxgZFsN1VY3ODrPS1
|
||||
NUfY/x3EnmswDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEAnU9e
|
||||
WOioNTUNK9b6FignejpK5FpyrODUuH/iL4TqCh0CIAz6GzJeLebhRPvYlfVUofdV
|
||||
hWx3sOwmUgjkRQXoxY+p
|
||||
-----END CERTIFICATE-----
|
||||
";
|
||||
const CERT_NOT_BEFORE: i64 = 1_767_225_600;
|
||||
const CERT_NOT_AFTER: i64 = 1_775_001_600;
|
||||
|
||||
fn minted(at: Option<i64>) -> queue::AgentCredential {
|
||||
queue::AgentCredential {
|
||||
value: "Ab9_-zSECRET".to_owned(),
|
||||
agent: "atlas".to_owned(),
|
||||
minted_at: at,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_without_a_mint_time_is_due() {
|
||||
assert!(secret_is_due(&minted(None), NOW));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_is_due_from_forty_five_days_and_not_before() {
|
||||
assert!(!secret_is_due(&minted(Some(NOW)), NOW));
|
||||
assert!(!secret_is_due(&minted(Some(NOW - 45 * DAY + 1)), NOW));
|
||||
assert!(secret_is_due(&minted(Some(NOW - 45 * DAY)), NOW));
|
||||
assert!(secret_is_due(&minted(Some(NOW - 90 * DAY)), NOW));
|
||||
}
|
||||
|
||||
/// A mint time ahead of the clock is not due, rather than overflowing
|
||||
/// into a large age.
|
||||
#[test]
|
||||
fn a_mint_time_in_the_future_is_not_due() {
|
||||
assert!(!secret_is_due(&minted(Some(NOW + DAY)), NOW));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_re_mint_is_a_new_value_with_the_mint_time_for_the_same_agent() {
|
||||
let old = minted(None);
|
||||
let renewed = fresh("atlas", NOW).expect("the kernel supplies randomness");
|
||||
assert_ne!(renewed.value, old.value);
|
||||
assert_eq!(renewed.minted_at, Some(NOW));
|
||||
assert_eq!(renewed.agent, "atlas");
|
||||
assert!(!secret_is_due(&renewed, NOW), "a fresh secret is not due");
|
||||
let again = fresh("atlas", NOW).expect("twice");
|
||||
assert_ne!(again.value, renewed.value, "two re-mints must not agree");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_certificates_validity_is_read_from_the_certificate() {
|
||||
assert_eq!(
|
||||
cert_validity(CERT_PEM).expect("a well-formed certificate"),
|
||||
(CERT_NOT_BEFORE, CERT_NOT_AFTER)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn something_that_is_not_a_certificate_is_an_error() {
|
||||
assert!(cert_validity("not a certificate").is_err());
|
||||
assert!(cert_validity("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ninety_day_certificate_is_due_from_day_forty_five() {
|
||||
let (nb, na) = (CERT_NOT_BEFORE, CERT_NOT_AFTER);
|
||||
assert!(!cert_is_due(nb, na, nb));
|
||||
assert!(!cert_is_due(nb, na, nb + 45 * DAY - 1));
|
||||
assert!(cert_is_due(nb, na, nb + 45 * DAY));
|
||||
assert!(cert_is_due(nb, na, na + DAY), "an expired one too");
|
||||
}
|
||||
|
||||
fn declared(agents: &[(&str, AgentState)]) -> HiveWanted {
|
||||
let mut d = HiveWanted::default();
|
||||
for (agent, state) in agents {
|
||||
d.agents
|
||||
.insert((*agent).to_owned(), AgentWanted { state: *state });
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_destroyed_agent_is_not_live_and_every_other_state_is() {
|
||||
let live = live_agents(&[declared(&[
|
||||
("up", AgentState::Up),
|
||||
("offline", AgentState::Offline),
|
||||
("paused", AgentState::Paused),
|
||||
("gone", AgentState::Destroyed),
|
||||
])]);
|
||||
assert_eq!(
|
||||
live.into_iter().collect::<Vec<_>>(),
|
||||
["offline", "paused", "up"]
|
||||
);
|
||||
}
|
||||
|
||||
/// An agent destroyed on one hive and declared on another is live: it
|
||||
/// still runs somewhere.
|
||||
#[test]
|
||||
fn an_agent_live_on_any_hive_is_live() {
|
||||
let live = live_agents(&[
|
||||
declared(&[("atlas", AgentState::Destroyed)]),
|
||||
declared(&[("atlas", AgentState::Up)]),
|
||||
]);
|
||||
assert!(live.contains("atlas"), "{live:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_declaration_is_no_live_agent() {
|
||||
assert!(live_agents(&[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_due_credentials_are_planned() {
|
||||
use Observed::{Absent, Current, Due, Unknown};
|
||||
let o = |cert, secret| AgentObserved { cert, secret };
|
||||
let observed = [
|
||||
("both".to_owned(), o(Due(Some(DAY)), Due(None))),
|
||||
("cert".to_owned(), o(Due(None), Current)),
|
||||
("secret".to_owned(), o(Absent, Due(None))),
|
||||
("neither".to_owned(), o(Current, Absent)),
|
||||
("unknown".to_owned(), o(Unknown, Unknown)),
|
||||
];
|
||||
let r = |agent: &str, cert, secret| Renewal {
|
||||
agent: agent.to_owned(),
|
||||
cert,
|
||||
secret,
|
||||
};
|
||||
assert_eq!(
|
||||
plan(&observed),
|
||||
[
|
||||
r("both", true, true),
|
||||
r("cert", true, false),
|
||||
r("secret", false, true),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_age_logged_is_whole_days_or_unrecorded() {
|
||||
assert_eq!(Age(Some(46 * DAY + 5)).to_string(), "46d");
|
||||
assert_eq!(Age(None).to_string(), "unrecorded");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue