edgeguard/acme_budget.rs
1//! ACME issuance budget — refuse to ask a CA for a certificate it is about to refuse.
2//!
3//! # The failure this prevents
4//!
5//! Certificate authorities rate-limit issuance, and the limits are low enough to hit by accident.
6//! Let's Encrypt allows **5 certificates per exact set of identifiers per 7 days**. An edge that
7//! re-orders on every start — because its certificate cache is not durable, or because a rollout
8//! replaces the instance — burns that in five restarts and then has **no certificate for a week**.
9//! That is not hypothetical: it is why the k3s edge deployment carries a dedicated persistent volume
10//! for the ACME cache, with a comment explaining exactly this.
11//!
12//! Without a budget the edge finds out by being refused, at which point the week has started.
13//!
14//! # Modelled as the CA models it
15//!
16//! Let's Encrypt publishes these as **token buckets with refill rates** — "50 per 7 days, refilling
17//! 1 every 202 minutes" — not as fixed windows. The difference matters in the dangerous direction: a
18//! fixed-window counter believes the whole allowance returns at a boundary, so it permits a burst
19//! the CA will refuse. GCRA is exactly the right shape, and this crate already has it in
20//! [`crate::limiter`], so the buckets reuse that arithmetic rather than growing a second
21//! rate-limiter.
22//!
23//! # What this ledger is, and what decides when it is not
24//!
25//! **It is per-edge.** Each edge keeps its own ledger, which fully covers the per-edge limit — the
26//! exact-identifier-set bucket, the one behind the outage above. It cannot, on its own, coordinate
27//! the per-registered-domain bucket across many edges: fifty edges under one registered domain
28//! would each believe they had the full 50-per-week allowance and between them spend it.
29//!
30//! That gap is closed **above** this module rather than inside it. In managed mode the edge asks the
31//! control plane for an issuance lease first (`crate::cp::CpClient::acme_lease`), and the control
32//! plane holds the shared buckets for the whole fleet — see `acme::check_budget` for the decision
33//! and the fallback. This ledger is then the authority in exactly two cases: an unmanaged edge, and
34//! a managed edge whose control plane cannot answer. Both are real, so it is not vestigial; but the
35//! per-registered-domain bucket **here** is still a local guard rather than a fleet total, and the
36//! metric help text says so rather than implying a number it does not have.
37//!
38//! # Never trades a real certificate for a self-signed one
39//!
40//! A refusal here is a *deferral*, never a downgrade. `main.rs` documents the invariant this
41//! respects: ACME runs before the self-signed floor precisely so a failed order cannot silently put
42//! an untrusted certificate on a public name. An exhausted budget keeps whatever certificate is
43//! already on disk and says so loudly; it does not manufacture one.
44
45use std::collections::HashMap;
46use std::path::{Path, PathBuf};
47use std::time::{Duration, SystemTime, UNIX_EPOCH};
48
49use anyhow::{Context, Result};
50use serde::{Deserialize, Serialize};
51use tracing::{info, warn};
52
53use crate::limiter::{gcra_admit, Gcra};
54
55/// Which CA limit refused an order. Also the metric label, so it is a fixed set.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Bucket {
58 /// New orders per ACME account.
59 Orders,
60 /// New certificates per registered domain.
61 RegisteredDomain,
62 /// New certificates per exact set of identifiers. The binding limit for a restart loop.
63 IdentifierSet,
64}
65
66impl Bucket {
67 pub fn label(&self) -> &'static str {
68 match self {
69 Bucket::Orders => "orders",
70 Bucket::RegisteredDomain => "registered_domain",
71 Bucket::IdentifierSet => "identifier_set",
72 }
73 }
74}
75
76/// The verdict for one proposed order.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum Decision {
79 Allow,
80 /// The CA would refuse. `retry_at_unix` is when this bucket next admits.
81 Defer {
82 bucket: Bucket,
83 retry_at_unix: i64,
84 },
85}
86
87/// One CA's published limits.
88///
89/// Per-CA because these numbers are **not** universal — they are Let's Encrypt's. A CA whose
90/// directory URL is not recognised gets no profile and no budget, rather than being held to somebody
91/// else's numbers: refusing an order a private or commercial CA would happily accept is a
92/// self-inflicted outage.
93#[derive(Debug, Clone)]
94pub struct CaProfile {
95 pub name: &'static str,
96 pub orders: Gcra,
97 pub registered_domain: Gcra,
98 pub identifier_set: Gcra,
99}
100
101impl CaProfile {
102 /// Let's Encrypt's published production limits, verified against their rate-limits page:
103 ///
104 /// | Limit | Value | Refill |
105 /// |---|---|---|
106 /// | New orders per account | 300 / 3 h | 1 per 36 s |
107 /// | New certificates per registered domain | 50 / 7 d | 1 per 202 min |
108 /// | New certificates per exact identifier set | 5 / 7 d | 1 per 34 h |
109 ///
110 /// The burst equals the limit and the emission interval is `window / limit`, which reproduces
111 /// the published refill rates exactly — that agreement is what says the model matches the CA's.
112 fn lets_encrypt() -> Result<CaProfile> {
113 Ok(CaProfile {
114 name: "letsencrypt",
115 orders: Gcra::from_parts(300, Duration::from_secs(3 * 3_600), 300)?,
116 registered_domain: Gcra::from_parts(50, Duration::from_secs(7 * 86_400), 50)?,
117 identifier_set: Gcra::from_parts(5, Duration::from_secs(7 * 86_400), 5)?,
118 })
119 }
120
121 /// Resolve a profile from the ACME directory URL.
122 ///
123 /// Staging gets the **production** profile deliberately, even though its real limits are much
124 /// higher. Staging is the compiled-in default for this crate, so an inert budget there would
125 /// mean the budget code is never exercised in the configuration most deployments start from —
126 /// untested code on the path that matters. Being conservative on a test CA costs nothing.
127 pub fn for_directory(url: &str) -> Option<CaProfile> {
128 let host = url
129 .split_once("://")
130 .map(|(_, r)| r)
131 .unwrap_or(url)
132 .split('/')
133 .next()
134 .unwrap_or("");
135 if host.ends_with("letsencrypt.org") {
136 CaProfile::lets_encrypt().ok()
137 } else {
138 None
139 }
140 }
141
142 fn gcra(&self, b: Bucket) -> &Gcra {
143 match b {
144 Bucket::Orders => &self.orders,
145 Bucket::RegisteredDomain => &self.registered_domain,
146 Bucket::IdentifierSet => &self.identifier_set,
147 }
148 }
149}
150
151/// Group a hostname for the per-registered-domain bucket.
152///
153/// The CA groups by registered domain (eTLD+1), which needs the Public Suffix List to compute
154/// exactly. This crate will not take that dependency for one bucket, so it uses the **last two
155/// labels** and is honest about the consequence:
156///
157/// - `a.b.example.com` → `example.com` — correct.
158/// - `a.example.co.uk` → `co.uk` — **wrong, and wrong in the safe direction.** It merges every
159/// `.co.uk` registered domain into one bucket, so the guard becomes stricter than the CA. It never
160/// splits one CA bucket into two, which is the only error that would let issuance through that the
161/// CA then refuses.
162///
163/// An IP literal, or a name with two or fewer labels, is returned unchanged.
164pub fn group_key(host: &str) -> String {
165 let h = host.trim().trim_end_matches('.').to_ascii_lowercase();
166 if h.is_empty() || h.parse::<std::net::IpAddr>().is_ok() {
167 return h;
168 }
169 let labels: Vec<&str> = h.split('.').collect();
170 if labels.len() <= 2 {
171 return h;
172 }
173 labels[labels.len() - 2..].join(".")
174}
175
176/// The stable key for the exact-identifier-set bucket: the domains, lowercased, deduplicated and
177/// sorted, so the same set in a different order is the same key. The CA matches on the set, not the
178/// order it was written in.
179pub fn identifier_set_key(domains: &[String]) -> String {
180 let mut d: Vec<String> = domains
181 .iter()
182 .map(|s| s.trim().trim_end_matches('.').to_ascii_lowercase())
183 .filter(|s| !s.is_empty())
184 .collect();
185 d.sort();
186 d.dedup();
187 d.join(",")
188}
189
190/// The persisted state: one theoretical-arrival-time per bucket key, in microseconds.
191#[derive(Debug, Default, Serialize, Deserialize)]
192struct LedgerFile {
193 /// `"<bucket>:<key>" -> TAT µs`.
194 #[serde(default)]
195 tats: HashMap<String, u64>,
196}
197
198/// The issuance budget for one edge.
199///
200/// Persisted in the ACME cache directory — the same place the account key lives, and the directory a
201/// deployment must already make durable for ACME to work at all. A budget that resets on restart
202/// would be no budget: restarts are precisely the event it exists to survive.
203pub struct IssuanceBudget {
204 profile: CaProfile,
205 path: PathBuf,
206 file: LedgerFile,
207}
208
209impl IssuanceBudget {
210 /// Load (or start) the ledger for `cache_dir`. `None` when the CA is unrecognised — see
211 /// [`CaProfile::for_directory`].
212 pub fn load(directory_url: &str, cache_dir: &str) -> Option<IssuanceBudget> {
213 let profile = CaProfile::for_directory(directory_url)?;
214 let path = Path::new(cache_dir).join("issuance-budget.json");
215 let file = std::fs::read_to_string(&path)
216 .ok()
217 .and_then(|s| serde_json::from_str::<LedgerFile>(&s).ok())
218 .unwrap_or_default();
219 info!(
220 ca = profile.name,
221 ledger = %path.display(),
222 entries = file.tats.len(),
223 "ACME issuance budget active"
224 );
225 Some(IssuanceBudget {
226 profile,
227 path,
228 file,
229 })
230 }
231
232 fn key(bucket: Bucket, k: &str) -> String {
233 format!("{}:{}", bucket.label(), k)
234 }
235
236 /// Would every bucket admit an order for `domains`? Does **not** debit — see [`Self::debit`].
237 pub fn check(&self, domains: &[String], now_unix: i64) -> Decision {
238 let now_us = (now_unix.max(0) as u64).saturating_mul(1_000_000);
239 for (bucket, k) in self.keys_for(domains) {
240 let g = self.profile.gcra(bucket);
241 let stored = self.file.tats.get(&Self::key(bucket, &k)).copied();
242 if gcra_admit(stored, now_us, g).is_none() {
243 return Decision::Defer {
244 bucket,
245 retry_at_unix: (g.next_admit_at(stored, now_us) / 1_000_000) as i64,
246 };
247 }
248 }
249 Decision::Allow
250 }
251
252 /// Debit every bucket and persist, **before** the order is sent.
253 ///
254 /// Ordering is the whole point. An order that reaches the CA may have been counted by it even if
255 /// the response never arrives, so a debit that happened only on success would let a failing loop
256 /// spend the real allowance while the local ledger showed it untouched. There is deliberately no
257 /// refund on failure, for the same reason.
258 pub fn debit(&mut self, domains: &[String], now_unix: i64) -> Result<()> {
259 let now_us = (now_unix.max(0) as u64).saturating_mul(1_000_000);
260 for (bucket, k) in self.keys_for(domains) {
261 let g = self.profile.gcra(bucket);
262 let key = Self::key(bucket, &k);
263 let stored = self.file.tats.get(&key).copied();
264 if let Some(new_tat) = gcra_admit(stored, now_us, g) {
265 self.file.tats.insert(key, new_tat);
266 }
267 }
268 self.persist()
269 }
270
271 fn keys_for(&self, domains: &[String]) -> Vec<(Bucket, String)> {
272 let mut out = vec![
273 (Bucket::Orders, "account".to_string()),
274 (Bucket::IdentifierSet, identifier_set_key(domains)),
275 ];
276 // One entry per distinct registered domain in the request.
277 let mut groups: Vec<String> = domains.iter().map(|d| group_key(d)).collect();
278 groups.sort();
279 groups.dedup();
280 for g in groups {
281 out.push((Bucket::RegisteredDomain, g));
282 }
283 out
284 }
285
286 /// Write the ledger atomically: a torn file would be unparseable and silently reset the budget
287 /// to empty, which is the one failure mode that looks like everything is fine.
288 fn persist(&self) -> Result<()> {
289 if let Some(dir) = self.path.parent() {
290 std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
291 }
292 let tmp = self.path.with_extension("json.tmp");
293 let body = serde_json::to_string(&self.file).context("serialising the issuance ledger")?;
294 std::fs::write(&tmp, body).with_context(|| format!("writing {}", tmp.display()))?;
295 std::fs::rename(&tmp, &self.path)
296 .with_context(|| format!("renaming into {}", self.path.display()))?;
297 Ok(())
298 }
299
300 /// Remaining admissions per bucket, for metrics and for the operator's warning before
301 /// exhaustion rather than after.
302 pub fn remaining(&self, domains: &[String], now_unix: i64) -> Vec<(Bucket, String, u64)> {
303 let now_us = (now_unix.max(0) as u64).saturating_mul(1_000_000);
304 self.keys_for(domains)
305 .into_iter()
306 .map(|(bucket, k)| {
307 let stored = self.file.tats.get(&Self::key(bucket, &k)).copied();
308 let left = self.profile.gcra(bucket).remaining(stored, now_us);
309 (bucket, k, left)
310 })
311 .collect()
312 }
313
314 pub fn ca_name(&self) -> &'static str {
315 self.profile.name
316 }
317}
318
319/// Unix seconds now.
320pub fn now_unix() -> i64 {
321 SystemTime::now()
322 .duration_since(UNIX_EPOCH)
323 .map(|d| d.as_secs() as i64)
324 .unwrap_or(0)
325}
326
327/// Render the budget as Prometheus text, so an operator sees it draining rather than gone.
328///
329/// Deliberately a gauge per bucket rather than a single "is it exhausted" flag: the alert that
330/// matters fires at 60% consumed, days before the outage, and a boolean cannot say that.
331///
332/// The identifier-set key is a tenant's full hostname list, so it is **hashed** into the label.
333/// Putting it in plaintext would publish the customer's domain names to whoever can read
334/// `/metrics`; the mapping stays behind the authenticated API.
335pub fn render_metrics(budget: &IssuanceBudget, domains: &[String], now_unix: i64) -> String {
336 let mut out = String::new();
337 // The help text names the scope, because the number is easy to misread as a fleet total and an
338 // operator would size a rollout against it. The registered_domain bucket in particular is
339 // SHARED with every other edge under that domain; this gauge only knows what this edge spent.
340 out.push_str(
341 "# HELP edgeguard_acme_budget_remaining Issuance THIS EDGE's own ledger still allows, per bucket. Not a fleet total: the registered_domain limit is shared across every edge under that domain, and the control plane's GET /v3/acme/budget is the fleet figure.\n",
342 );
343 out.push_str("# TYPE edgeguard_acme_budget_remaining gauge\n");
344 for (bucket, key, left) in budget.remaining(domains, now_unix) {
345 let label = match bucket {
346 Bucket::IdentifierSet => short_hash(&key),
347 _ => key.clone(),
348 };
349 out.push_str(&format!(
350 "edgeguard_acme_budget_remaining{{ca=\"{}\",bucket=\"{}\",key=\"{}\"}} {left}\n",
351 budget.ca_name(),
352 bucket.label(),
353 escape_label(&label),
354 ));
355 }
356 out
357}
358
359/// A short, stable, non-reversible label for a hostname set.
360fn short_hash(s: &str) -> String {
361 // FNV-1a, 64-bit. Not a cryptographic commitment — it only has to be stable and to not be the
362 // customer's domain list in plaintext.
363 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
364 for b in s.as_bytes() {
365 h ^= *b as u64;
366 h = h.wrapping_mul(0x1000_0000_01b3);
367 }
368 format!("{h:016x}")
369}
370
371/// Prometheus label values may not carry a raw `"` or `\`.
372fn escape_label(s: &str) -> String {
373 s.replace('\\', "\\\\").replace('"', "\\\"")
374}
375
376/// Log a deferral in the form an operator can act on.
377pub fn warn_deferred(bucket: Bucket, retry_at_unix: i64, domains: &[String]) {
378 warn!(
379 bucket = bucket.label(),
380 retry_at_unix,
381 domains = ?domains,
382 "ACME issuance deferred: the CA's rate limit for this bucket is exhausted. \
383 The existing certificate (if any) keeps serving; no self-signed certificate is \
384 substituted on a public name."
385 );
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 fn budget(dir: &Path) -> IssuanceBudget {
393 IssuanceBudget::load(
394 "https://acme-v02.api.letsencrypt.org/directory",
395 dir.to_str().unwrap(),
396 )
397 .expect("letsencrypt profile")
398 }
399
400 fn tmpdir(tag: &str) -> PathBuf {
401 let d = std::env::temp_dir().join(format!("eg-acme-budget-{tag}-{}", std::process::id()));
402 let _ = std::fs::remove_dir_all(&d);
403 std::fs::create_dir_all(&d).unwrap();
404 d
405 }
406
407 #[test]
408 fn the_identifier_set_bucket_stops_a_restart_loop_at_five() {
409 // THE failure this module exists for. An edge whose cert cache is not durable re-orders the
410 // same identifier set on every boot; the CA allows five per week and then refuses for a
411 // week. The sixth attempt must be refused HERE, before the CA counts it.
412 let d = tmpdir("restart");
413 let mut b = budget(&d);
414 let domains = vec!["edge.example.com".to_string()];
415 let now = 1_800_000_000;
416
417 for i in 0..5 {
418 assert_eq!(
419 b.check(&domains, now),
420 Decision::Allow,
421 "order {i} must be allowed"
422 );
423 b.debit(&domains, now).unwrap();
424 }
425 match b.check(&domains, now) {
426 Decision::Defer {
427 bucket,
428 retry_at_unix,
429 } => {
430 assert_eq!(bucket, Bucket::IdentifierSet);
431 assert!(retry_at_unix > now, "a deferral must say when to retry");
432 }
433 Decision::Allow => panic!("the sixth order must be deferred"),
434 }
435 }
436
437 #[test]
438 fn the_budget_survives_a_restart() {
439 // A budget that resets on restart is no budget: restarts are the event it exists to
440 // survive, and its ledger lives in the ACME cache dir for exactly that reason.
441 let d = tmpdir("persist");
442 let domains = vec!["edge.example.com".to_string()];
443 let now = 1_800_000_000;
444 {
445 let mut b = budget(&d);
446 for _ in 0..5 {
447 b.debit(&domains, now).unwrap();
448 }
449 }
450 // A fresh process, same cache directory.
451 let b2 = budget(&d);
452 assert!(
453 matches!(b2.check(&domains, now), Decision::Defer { .. }),
454 "the ledger must be read back from disk"
455 );
456 }
457
458 #[test]
459 fn the_bucket_refills_over_time_rather_than_resetting_at_a_boundary() {
460 // Let's Encrypt publishes these as token buckets with refill rates, not fixed windows. A
461 // fixed-window model believes the whole allowance returns at a boundary and permits a burst
462 // the CA refuses — wrong in the dangerous direction.
463 let d = tmpdir("refill");
464 let mut b = budget(&d);
465 let domains = vec!["edge.example.com".to_string()];
466 let now = 1_800_000_000;
467 for _ in 0..5 {
468 b.debit(&domains, now).unwrap();
469 }
470 assert!(matches!(b.check(&domains, now), Decision::Defer { .. }));
471
472 // The identifier-set bucket refills one per emission interval, which is exactly
473 // 7 days / 5 = 120 960 s = 33.6 h. Let's Encrypt publishes this as "1 every 34 hours",
474 // rounded; the model uses the exact quotient, which is what reproduces their limit over a
475 // full window rather than drifting by 0.4 h per token.
476 const EI: i64 = 7 * 86_400 / 5;
477 assert!(matches!(
478 b.check(&domains, now + EI - 60),
479 Decision::Defer { .. }
480 ));
481 assert_eq!(b.check(&domains, now + EI + 60), Decision::Allow);
482 // And one refill is one token, not a reset: a second order at the same instant is refused.
483 b.debit(&domains, now + EI + 60).unwrap();
484 assert!(matches!(
485 b.check(&domains, now + EI + 60),
486 Decision::Defer { .. }
487 ));
488 }
489
490 #[test]
491 fn a_different_identifier_set_has_its_own_bucket() {
492 let d = tmpdir("sets");
493 let mut b = budget(&d);
494 let now = 1_800_000_000;
495 let a = vec!["a.example.com".to_string()];
496 for _ in 0..5 {
497 b.debit(&a, now).unwrap();
498 }
499 assert!(matches!(b.check(&a, now), Decision::Defer { .. }));
500 // A different set under the same registered domain still has 45 of the domain bucket left.
501 let c = vec!["b.example.com".to_string()];
502 assert_eq!(b.check(&c, now), Decision::Allow);
503 }
504
505 #[test]
506 fn the_registered_domain_bucket_binds_across_different_hostnames() {
507 let d = tmpdir("domain");
508 let mut b = budget(&d);
509 let now = 1_800_000_000;
510 // 50 distinct hostnames under one registered domain exhausts the 50/7d domain bucket, even
511 // though each identifier set is used once.
512 for i in 0..50 {
513 let h = vec![format!("h{i}.example.com")];
514 assert_eq!(b.check(&h, now), Decision::Allow, "host {i}");
515 b.debit(&h, now).unwrap();
516 }
517 let next = vec!["h50.example.com".to_string()];
518 match b.check(&next, now) {
519 Decision::Defer { bucket, .. } => assert_eq!(bucket, Bucket::RegisteredDomain),
520 Decision::Allow => panic!("the 51st registered-domain order must be deferred"),
521 }
522 // A different registered domain is unaffected.
523 assert_eq!(b.check(&["x.other.com".to_string()], now), Decision::Allow);
524 }
525
526 #[test]
527 fn identifier_set_key_is_order_and_case_insensitive() {
528 // The CA matches the SET. Treating a reordering as a new set would let a caller bypass the
529 // bucket entirely by shuffling its SAN list.
530 let a = identifier_set_key(&["b.example.com".into(), "A.example.com".into()]);
531 let b = identifier_set_key(&["a.example.com".into(), "B.EXAMPLE.COM.".into()]);
532 assert_eq!(a, b);
533 // ...and a genuinely different set is a different key.
534 assert_ne!(a, identifier_set_key(&["a.example.com".into()]));
535 }
536
537 #[test]
538 fn group_key_errs_toward_merging_never_splitting() {
539 // Splitting one CA bucket into two would let issuance through that the CA then refuses —
540 // the only error direction that matters. Merging is merely stricter than necessary.
541 assert_eq!(group_key("a.b.example.com"), "example.com");
542 assert_eq!(group_key("example.com"), "example.com");
543 assert_eq!(group_key("EXAMPLE.COM."), "example.com");
544 // The known-wrong case, wrong safely: every .co.uk shares one bucket.
545 assert_eq!(group_key("a.example.co.uk"), "co.uk");
546 assert_eq!(group_key("b.other.co.uk"), "co.uk");
547 // Literals and short names pass through.
548 assert_eq!(group_key("127.0.0.1"), "127.0.0.1");
549 assert_eq!(group_key("localhost"), "localhost");
550 }
551
552 #[test]
553 fn an_unrecognised_ca_gets_no_budget() {
554 // Holding a private or commercial CA to Let's Encrypt's numbers would refuse orders it
555 // would have accepted — a self-inflicted outage from a guard.
556 let d = tmpdir("unknown");
557 assert!(
558 IssuanceBudget::load("https://acme.internal/directory", d.to_str().unwrap()).is_none()
559 );
560 assert!(CaProfile::for_directory("https://ca.example.com/dir").is_none());
561 // Staging IS budgeted, deliberately: it is the compiled-in default, so leaving it inert
562 // would mean this code is never exercised in the configuration most deployments start from.
563 assert!(
564 CaProfile::for_directory("https://acme-staging-v02.api.letsencrypt.org/directory")
565 .is_some()
566 );
567 }
568
569 #[test]
570 fn metrics_never_publish_the_customers_hostname_set() {
571 // /metrics is commonly scraped by something less privileged than the API. The identifier-set
572 // key is the tenant's full hostname list and must not appear there in plaintext.
573 let d = tmpdir("metrics");
574 let b = budget(&d);
575 let domains = vec!["secret-customer.example.com".to_string()];
576 let text = render_metrics(&b, &domains, 1_800_000_000);
577 assert!(
578 !text.contains("secret-customer"),
579 "the hostname set leaked into /metrics:\n{text}"
580 );
581 // The registered domain IS published — it is the bucket the CA groups by, and an operator
582 // cannot act on the alert without it.
583 assert!(
584 text.contains("bucket=\"registered_domain\",key=\"example.com\""),
585 "{text}"
586 );
587 assert!(text.contains("edgeguard_acme_budget_remaining"), "{text}");
588 }
589
590 #[test]
591 fn remaining_counts_down_and_is_reported_per_bucket() {
592 let d = tmpdir("remaining");
593 let mut b = budget(&d);
594 let domains = vec!["edge.example.com".to_string()];
595 let now = 1_800_000_000;
596 let before = b.remaining(&domains, now);
597 let set_before = before
598 .iter()
599 .find(|(bk, _, _)| *bk == Bucket::IdentifierSet)
600 .unwrap()
601 .2;
602 assert_eq!(set_before, 5);
603 b.debit(&domains, now).unwrap();
604 let after = b.remaining(&domains, now);
605 let set_after = after
606 .iter()
607 .find(|(bk, _, _)| *bk == Bucket::IdentifierSet)
608 .unwrap()
609 .2;
610 assert_eq!(
611 set_after, 4,
612 "an operator must see the budget draining before it is gone"
613 );
614 }
615}