cratefield_core/tenant.rs
1//! The tenant a request belongs to, and the statuses the control
2//! database's registry carries (RECONCILIATION.md §6, ADR 0008,
3//! TENANT-ROUTING.md).
4//!
5//! [`TenantId`] and [`Tenant`] are nameable everywhere and constructible
6//! only here. That is the whole isolation argument: a module cannot ask
7//! the registry for a neighbour's pool by writing a different string,
8//! because it cannot write a [`TenantId`] at all. Same shape as
9//! `HarnessOnly` in the secrets crate (#39), and for the same reason —
10//! the type is the boundary, not a convention about how to call a
11//! function.
12
13/// A tenant database's reconciliation status.
14///
15/// Serializes as the registry's own text (`"active"`), not the variant
16/// name, so a structured log field and a registry row cannot drift apart.
17/// `round_trips_through_the_registry_text` and
18/// `serializes_as_the_registry_text` together pin that.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
20#[serde(rename_all = "lowercase")]
21// The lifecycle will grow again — #154 has a promotion path in it — and
22// adding a variant to an exhaustive public enum is a breaking change for
23// every downstream `match`. This release breaks anyway, adding
24// `Offboarding` and `Archived`; spending that break once buys every later
25// status for free.
26#[non_exhaustive]
27pub enum TenantStatus {
28 /// Reconciled and serving.
29 Active,
30 /// Registered, reconciliation not yet succeeded.
31 Provisioning,
32 /// The last reconciliation failed: requests answer
33 /// `503 tenant-degraded` until a boot succeeds.
34 Degraded,
35 /// Being retired: the export and key shred of
36 /// `docs/TENANT-ONBOARDING.md` §2 are under way. It has stopped
37 /// serving and will not start again.
38 Offboarding,
39 /// Retired. The database is dropped and the keys are destroyed;
40 /// nothing about this tenant can be served or recovered.
41 Archived,
42}
43
44impl TenantStatus {
45 /// The registry's text form, lowercase and stable.
46 #[must_use]
47 pub fn as_str(self) -> &'static str {
48 match self {
49 Self::Active => "active",
50 Self::Provisioning => "provisioning",
51 Self::Degraded => "degraded",
52 Self::Offboarding => "offboarding",
53 Self::Archived => "archived",
54 }
55 }
56
57 /// Whether boot-time reconciliation should fly this tenant.
58 ///
59 /// A `match` and not a negation, which is the whole point. The
60 /// reconciler asked `status != Degraded` while there were three
61 /// statuses, so it was accidentally right; adding `Offboarding` and
62 /// `Archived` silently enrolled both in the fleet, which would have
63 /// reconnected to a shredded tenant's database and flipped it back to
64 /// `active`. Written this way, the next variant added to this
65 /// `#[non_exhaustive]` enum cannot compile until someone says which
66 /// side of the line it is on.
67 #[must_use]
68 pub fn is_reconciled(self) -> bool {
69 match self {
70 Self::Active | Self::Provisioning => true,
71 Self::Degraded | Self::Offboarding | Self::Archived => false,
72 }
73 }
74
75 /// Whether the lifecycle ends here. Only [`TenantStatus::Archived`]
76 /// does: the database is dropped and the data keys are destroyed
77 /// (`docs/TENANT-ONBOARDING.md` §2), so there is nothing left to
78 /// serve and no honest way back. The registry enforces it — see
79 /// `Postgres::register_tenant`.
80 #[must_use]
81 pub fn is_terminal(self) -> bool {
82 match self {
83 Self::Archived => true,
84 Self::Active | Self::Provisioning | Self::Degraded | Self::Offboarding => false,
85 }
86 }
87
88 /// The statuses a tenant may be in and still become this one.
89 ///
90 /// The lifecycle rule, in one place, because more than one writer
91 /// moves a tenant through it: the reconciler, the offboarding
92 /// runbook's tooling and the lifecycle API of #154. A rule that lived
93 /// in one adapter's `WHERE` clause would be a rule the next writer
94 /// did not have.
95 ///
96 /// Written as "who may become me" rather than "who may I become" so
97 /// the match is on the target, which is what a caller setting a
98 /// status has in its hand — and so the list drops straight into the
99 /// `UPDATE`'s `WHERE` as the set of previous values that may be
100 /// overwritten, making the check part of the same statement rather
101 /// than a read the write races.
102 ///
103 /// Each status admits itself, because writing the status a tenant is
104 /// already in changes nothing and a reconciler that runs twice must
105 /// not fail the second time. [`TenantStatus::Archived`] is the
106 /// exception: it admits nothing, itself included. The row is the
107 /// record that the database was dropped and the keys destroyed, and a
108 /// record of an irreversible act is not rewritten.
109 #[must_use]
110 pub const fn admits(self) -> &'static [Self] {
111 match self {
112 // A tenant is registered as provisioning and nothing returns
113 // to it. Re-provisioning an existing tenant is a new tenant,
114 // because the old one's keys are either live or destroyed.
115 Self::Provisioning => &[Self::Provisioning],
116 // Reconciliation succeeded — from a first boot, from a repeat
117 // boot, or from a failure that has cleared. Not from
118 // `Offboarding`: that tenant's export and key shred are under
119 // way (`docs/TENANT-ONBOARDING.md` §2), and a reconciler
120 // reviving it would serve a database whose keys are being
121 // destroyed underneath it.
122 Self::Active => &[Self::Provisioning, Self::Degraded, Self::Active],
123 // Reconciliation failed. Reachable from anything still
124 // serving or trying to.
125 Self::Degraded => &[Self::Provisioning, Self::Active, Self::Degraded],
126 // Retirement starts from anywhere that is not already
127 // retiring or retired, including `Provisioning`: a tenant
128 // abandoned before it ever served still has keys, and they
129 // still have to be shredded.
130 Self::Offboarding => &[
131 Self::Provisioning,
132 Self::Active,
133 Self::Degraded,
134 Self::Offboarding,
135 ],
136 // Only the offboarding that performed the shred ends here.
137 // Archiving straight from `Active` would record a shred that
138 // never happened.
139 Self::Archived => &[Self::Offboarding],
140 }
141 }
142
143 /// Whether `self` may become `next`. The same rule as
144 /// [`TenantStatus::admits`], asked from the other end.
145 #[must_use]
146 pub fn can_become(self, next: Self) -> bool {
147 next.admits().contains(&self)
148 }
149
150 /// Parses the registry's text form; anything else (a future version
151 /// wrote a status this binary does not know) reads as
152 /// [`TenantStatus::Degraded`] — refuse, do not guess.
153 #[must_use]
154 pub fn parse(status: &str) -> Self {
155 match status {
156 "active" => Self::Active,
157 "provisioning" => Self::Provisioning,
158 "offboarding" => Self::Offboarding,
159 "archived" => Self::Archived,
160 _ => Self::Degraded,
161 }
162 }
163}
164
165impl std::fmt::Display for TenantStatus {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 f.write_str(self.as_str())
168 }
169}
170
171/// A tenant's key in the control database's registry.
172///
173/// Not `String`, and not constructible outside core:
174///
175/// ```compile_fail
176/// use cratefield_core::TenantId;
177/// // The field is private: module code cannot name a tenant.
178/// let _id = TenantId("someone-elses-tenant".to_owned());
179/// ```
180///
181/// Naming the type is fine, which keeps signatures writable:
182///
183/// ```
184/// use cratefield_core::TenantId;
185/// fn takes_id(_id: &TenantId) {}
186/// ```
187#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
188pub struct TenantId(String);
189
190impl TenantId {
191 /// Minted from a registry row. `pub(crate)` on purpose — see the
192 /// module docs.
193 pub(crate) fn new(id: impl Into<String>) -> Self {
194 Self(id.into())
195 }
196
197 #[must_use]
198 pub fn as_str(&self) -> &str {
199 &self.0
200 }
201}
202
203impl std::fmt::Display for TenantId {
204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 f.write_str(&self.0)
206 }
207}
208
209/// The tenant a request belongs to. Immutable, and built only by core's
210/// resolution layer from a registry row — never from a header, a path
211/// segment, or module code.
212///
213/// `Clone` because a request carries it into deferred work. Deliberately
214/// **not** `Deserialize`: the threat is an identity read back in from an
215/// attacker-supplied body, not one written out, so `Serialize` is fine
216/// and wanted — a tenant id is a structured log field.
217///
218/// ```compile_fail
219/// use cratefield_core::{Tenant, TenantStatus};
220/// // No public constructor, so a handler cannot conjure a neighbour.
221/// let _t = Tenant::new("other", TenantStatus::Active);
222/// ```
223#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
224pub struct Tenant {
225 id: TenantId,
226 status: TenantStatus,
227}
228
229impl Tenant {
230 pub(crate) fn new(id: impl Into<String>, status: TenantStatus) -> Self {
231 Self {
232 id: TenantId::new(id),
233 status,
234 }
235 }
236
237 #[must_use]
238 pub fn id(&self) -> &TenantId {
239 &self.id
240 }
241
242 #[must_use]
243 pub fn status(&self) -> TenantStatus {
244 self.status
245 }
246}
247
248/// What a deployment's registry says about a host.
249///
250/// The design sketch had three arms — `Found(Tenant)`, `Unknown`,
251/// `Degraded(TenantId)` — but a `Tenant` already carries its status, so
252/// the third was the same fact twice and two places to keep in step. It
253/// also could not work: a `ResolveTenant` impl lives in a runtime crate,
254/// and if the arm carried a `Tenant` the constructor would have to be
255/// `pub`, which is exactly what §3 wants to avoid. So the trait reports
256/// what the registry said and **core mints the [`Tenant`]**.
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub enum Resolution {
259 /// A registry row for this host.
260 Found {
261 /// The row's primary key.
262 id: String,
263 /// The row's status, parsed fail-closed by
264 /// [`TenantStatus::parse`].
265 status: TenantStatus,
266 },
267 /// No row for this host: `404 unknown-tenant`.
268 Unknown,
269}
270
271impl Resolution {
272 /// The tenant a request may proceed with, or the problem that refuses
273 /// it.
274 ///
275 /// Only [`TenantStatus::Active`] serves. `Provisioning` is refused
276 /// with the same `503 tenant-degraded` as `Degraded`: it means
277 /// registered-but-not-yet-reconciled, so its schema is not known to
278 /// match the code, which is the condition the refusal exists for. One
279 /// slug rather than two because the caller's situation is identical —
280 /// come back later — and the operator learns which from the registry,
281 /// not from the response.
282 pub(crate) fn admit(self) -> Result<Tenant, &'static crate::problems::ProblemDef> {
283 match self {
284 Self::Found { id, status } if status == TenantStatus::Active => {
285 Ok(Tenant::new(id, status))
286 }
287 // Retired, in either sense: the caller is not waiting for a
288 // boot, and there is nothing to come back to. Answered as an
289 // unknown tenant so a retired tenant and a host that never
290 // existed are indistinguishable from outside — "offboarding"
291 // is a fact about a customer, not something to publish to
292 // whoever guesses the host.
293 Self::Found { status, .. }
294 if status == TenantStatus::Offboarding || status == TenantStatus::Archived =>
295 {
296 Err(&crate::problems::SLUGS.unknown_tenant)
297 }
298 Self::Found { .. } => Err(&crate::problems::SLUGS.tenant_degraded),
299 Self::Unknown => Err(&crate::problems::SLUGS.unknown_tenant),
300 }
301 }
302}
303
304/// How a deployment turns a request's host into a tenant.
305///
306/// A trait so a deployment that must resolve by something else can, and
307/// so tests resolve without DNS. Host is the harness's answer — it is
308/// established before any tenant state is consulted, is already validated
309/// (#129), and is what the registry is keyed on anyway. A caller-supplied
310/// header is refused as policy: a deployment that got
311/// `TRUSTED_PROXY_HEADERS` wrong would let any caller name any tenant,
312/// and the blast radius of that mistake is every tenant's data
313/// (TENANT-ROUTING.md §3).
314pub trait ResolveTenant: Send + Sync {
315 /// The registry's answer for `host`. Implementations must not block
316 /// on a per-request round trip to the control database; see §13's
317 /// open question on cache lifetime.
318 fn resolve(&self, host: &str) -> Resolution;
319}
320
321/// How a deployment turns a resolved tenant into a database handle.
322///
323/// Separate from [`ResolveTenant`] because the two answers come from
324/// different places and fail differently: resolution reads a registry row
325/// (cheap, cacheable, and a miss means `404`), while this opens or reuses
326/// a connection pool (lazy, evictable, and a failure means the tenant is
327/// unreachable rather than unknown).
328///
329/// Implemented by the runtime, never by a module. The registry that backs
330/// it is the only thing in the process that knows a DSN
331/// (TENANT-ROUTING.md §4): it maps a connect failure to
332/// [`TenantDbError::Unreachable`] **before** the URL can reach a log line
333/// or a response body. `Display for DbError` already scrubs, so that is
334/// not the only defence — but `Debug` derives raw, and a DSN formatted
335/// before it ever becomes a `DbError` is scrubbed by nothing. Not
336/// producing the string is the first line; the sink is the second.
337#[async_trait::async_trait]
338pub trait TenantDatabases: Send + Sync {
339 /// The handle for `tenant`, opening its pool on first use.
340 ///
341 /// # Errors
342 ///
343 /// [`TenantDbError::Unreachable`] when the tenant's database cannot be
344 /// reached. The DSN is never part of the error.
345 async fn database(
346 &self,
347 tenant: &Tenant,
348 ) -> Result<std::sync::Arc<dyn crate::ports::Database>, TenantDbError>;
349}
350
351/// Why a tenant's database could not be handed over. Deliberately carries
352/// the tenant id and nothing else — no DSN, no driver message.
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub enum TenantDbError {
355 /// The pool could not be opened or the server did not answer.
356 Unreachable {
357 /// Which tenant. Safe to log.
358 tenant: String,
359 },
360}
361
362impl std::fmt::Display for TenantDbError {
363 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364 match self {
365 Self::Unreachable { tenant } => {
366 write!(f, "tenant `{tenant}`'s database is unreachable")
367 }
368 }
369 }
370}
371
372impl std::error::Error for TenantDbError {}
373
374/// A deployment's tenant plane: a host becomes a tenant, and a tenant
375/// becomes a database handle.
376///
377/// One slot on [`Ports`](crate::Ports) rather than two, because the
378/// resolution layer needs both halves of the same request and a
379/// deployment that supplied only one would be misconfigured in a way no
380/// type could catch. The blanket impl means a runtime writes the two
381/// traits and gets this for free.
382pub trait TenantRouting: ResolveTenant + TenantDatabases {}
383
384impl<T: ResolveTenant + TenantDatabases> TenantRouting for T {}
385
386/// The registry id a deployment without a registry resolves to.
387///
388/// Not a magic string scattered across three runtimes: one constant, so
389/// a log line from a Cloudflare Worker and one from a `cargo test` run
390/// say the same word.
391pub const IMPLICIT_TENANT: &str = "default";
392
393/// The resolver a deployment with **no control database** uses: every
394/// host is the one venture, always `active`.
395///
396/// This is not the Cloudflare path, it is the *no registry* path — which
397/// is also the browser runtime, and native in every development run and
398/// every test (TENANT-ROUTING.md §6). Scoping it to Cloudflare would mean
399/// `cargo test` cannot resolve a tenant and every module suite 500s.
400///
401/// A module is therefore written once, against the stricter shape, and
402/// the path most ventures actually run in production is not the one
403/// without the isolation.
404#[derive(Debug, Clone, Copy, Default)]
405pub struct ImplicitTenant;
406
407impl ResolveTenant for ImplicitTenant {
408 fn resolve(&self, _host: &str) -> Resolution {
409 Resolution::Found {
410 id: IMPLICIT_TENANT.to_owned(),
411 status: TenantStatus::Active,
412 }
413 }
414}
415
416/// Which of the two tenancy shapes served a request.
417///
418/// A deployment either has a tenant plane — a registry that turns each
419/// request's `Host` into a tenant — or it has none, and every host is
420/// the one implicit tenant ([`ImplicitTenant`], TENANT-ROUTING.md §6).
421/// The two shapes differ in more than wiring: they differ in what "a
422/// verified caller" can mean (issue #385). With a registry,
423/// `a.example` and `b.example` are different tenants behind one binary,
424/// and a subject who signed in as a user of tenant A can present the
425/// same bearer at tenant B's host — there, "any verified caller" and
426/// "a member of this tenant" are different sets. Without one, the
427/// request could only ever have reached the single database there is,
428/// and the two sets are the same set.
429///
430/// The bit records which shape a request arrived under, so a rule that
431/// needs membership can fail closed where the sets differ and serve
432/// where they coincide. It is a routing fact and deliberately nothing
433/// more: [`Tenancy::FromRegistry`] says a registry *named* this tenant,
434/// not that the caller belongs to it. Membership is a registry fact
435/// this bit does not carry.
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub enum Tenancy {
438 /// No tenant plane: every host is the one implicit tenant
439 /// ([`IMPLICIT_TENANT`]), and there is no second tenant to leak
440 /// into.
441 Sole,
442 /// A registry resolved this request's tenant from its `Host`
443 /// header.
444 FromRegistry,
445}
446
447#[cfg(test)]
448mod tests {
449 use super::{Resolution, TenantStatus};
450
451 fn found(status: TenantStatus) -> Resolution {
452 Resolution::Found {
453 id: "acme".to_owned(),
454 status,
455 }
456 }
457
458 /// Declares [`EVERY`] and, from the same list, a match that has to
459 /// be exhaustive.
460 ///
461 /// Every "for each status" test reads `EVERY` rather than writing its
462 /// own list, because a hand-written list quietly covers one fewer
463 /// case than its name claims the day a variant is added — which had
464 /// already happened here: `Offboarding` and `Archived` arrived in
465 /// #336 and the serialization test kept checking three of five.
466 ///
467 /// The macro is what makes that impossible rather than merely
468 /// discouraged. A first attempt at this used a separate
469 /// `position(status) -> usize` with an exhaustive match, which only
470 /// forced the *function* to be updated: nothing ever called it for a
471 /// variant the array did not already hold, so the array could stay
472 /// short and every test still passed. Adding a variant to the enum
473 /// now fails to compile in `exhaustive` below, and the only way to
474 /// satisfy that is to add it to this one invocation — which is also
475 /// the array.
476 macro_rules! statuses {
477 ($($variant:ident),+ $(,)?) => {
478 /// Every status. Not public: a caller enumerating an
479 /// `#[non_exhaustive]` enum is the thing that attribute
480 /// exists to prevent, and inside the crate it is exhaustive
481 /// anyway.
482 const EVERY: &[TenantStatus] = &[$(TenantStatus::$variant),+];
483
484 /// Never called. It exists so that a variant absent from the
485 /// list above is a compile error here.
486 #[expect(dead_code, reason = "its only job is to be exhaustive")]
487 fn exhaustive(status: TenantStatus) {
488 match status {
489 $(TenantStatus::$variant => {}),+
490 }
491 }
492 };
493 }
494
495 statuses!(Provisioning, Active, Degraded, Offboarding, Archived);
496
497 #[test]
498 fn the_implicit_tenant_answers_for_every_host() {
499 use super::{IMPLICIT_TENANT, ImplicitTenant, ResolveTenant};
500 // A deployment with no registry has one tenant, and which host
501 // asked is not a question it can answer differently - including
502 // for a host it has never seen, which is what a test client and a
503 // loopback probe both look like.
504 for host in ["acme.example", "localhost:8787", ""] {
505 let tenant = ImplicitTenant
506 .resolve(host)
507 .admit()
508 .expect("the implicit tenant always serves");
509 assert_eq!(tenant.id().as_str(), IMPLICIT_TENANT);
510 assert_eq!(tenant.status(), TenantStatus::Active);
511 }
512 }
513
514 #[test]
515 fn only_an_active_tenant_is_admitted() {
516 let tenant = found(TenantStatus::Active)
517 .admit()
518 .expect("an active tenant serves");
519 assert_eq!(tenant.id().as_str(), "acme");
520 assert_eq!(tenant.status(), TenantStatus::Active);
521 }
522
523 #[test]
524 fn provisioning_is_refused_like_degraded_and_not_like_unknown() {
525 // Registered but not yet reconciled: the schema is not known to
526 // match the code, which is the condition the refusal exists for.
527 // The caller's situation is identical to degraded - come back
528 // later - so it is one slug, and the operator learns which from
529 // the registry rather than from the response.
530 for status in [TenantStatus::Provisioning, TenantStatus::Degraded] {
531 let problem = found(status).admit().expect_err("must not serve");
532 assert_eq!(problem.slug, "tenant-degraded", "for {status}");
533 assert_eq!(problem.status.as_u16(), 503);
534 }
535 }
536
537 #[test]
538 fn a_retired_tenant_is_indistinguishable_from_one_that_never_existed() {
539 // Not `tenant-degraded`: that says "come back later", and there is
540 // nothing to come back to. Answering as unknown also keeps
541 // "this customer left" from being readable by anyone who guesses
542 // the host.
543 for status in [TenantStatus::Offboarding, TenantStatus::Archived] {
544 let retired = found(status).admit().expect_err("must not serve");
545 let never_existed = Resolution::Unknown.admit().expect_err("must not serve");
546 assert_eq!(
547 retired.slug, never_existed.slug,
548 "{status} must answer exactly as an unknown host does"
549 );
550 }
551 }
552
553 #[test]
554 fn every_status_round_trips_through_the_registry_text() {
555 for &status in EVERY {
556 assert_eq!(
557 TenantStatus::parse(status.as_str()),
558 status,
559 "{status} does not survive a trip through the registry"
560 );
561 }
562 }
563
564 #[test]
565 fn only_active_serves() {
566 // The whole lifecycle, stated once: exactly one status admits.
567 for &status in EVERY
568 .iter()
569 .filter(|status| **status != TenantStatus::Active)
570 {
571 assert!(
572 found(status).admit().is_err(),
573 "{status} must not serve requests"
574 );
575 }
576 assert!(found(TenantStatus::Active).admit().is_ok());
577 }
578
579 #[test]
580 fn a_retired_tenant_is_never_flown_by_the_fleet() {
581 // The bug this pins: the reconciler filtered `status != Degraded`,
582 // which was right for three statuses and silently enrolled both
583 // new ones. Flying an offboarding tenant reconnects to a database
584 // mid-shred and flips it back to `active`; flying an archived one
585 // reconnects to a database that no longer exists.
586 assert!(TenantStatus::Active.is_reconciled());
587 assert!(TenantStatus::Provisioning.is_reconciled());
588 for retired in [
589 TenantStatus::Degraded,
590 TenantStatus::Offboarding,
591 TenantStatus::Archived,
592 ] {
593 assert!(
594 !retired.is_reconciled(),
595 "{retired} must not be flown by boot reconciliation"
596 );
597 }
598 }
599
600 #[test]
601 fn archived_is_the_only_terminal_status() {
602 assert!(TenantStatus::Archived.is_terminal());
603 for live in [
604 TenantStatus::Active,
605 TenantStatus::Provisioning,
606 TenantStatus::Degraded,
607 TenantStatus::Offboarding,
608 ] {
609 assert!(!live.is_terminal(), "{live} is a state, not an ending");
610 }
611 }
612
613 #[test]
614 fn a_terminal_status_never_serves_and_is_never_reconciled() {
615 // The two properties together are what "retired" means: it cannot
616 // answer a request and no boot brings it back. Asserting each
617 // separately would let a future variant satisfy one and not the
618 // other.
619 for &status in EVERY {
620 if status.is_terminal() {
621 assert!(found(status).admit().is_err(), "{status} served");
622 assert!(!status.is_reconciled(), "{status} was flown");
623 }
624 }
625 }
626
627 #[test]
628 fn an_unknown_host_is_a_404_not_a_503() {
629 // Distinguishable on purpose: 503 says "this tenant exists and is
630 // unwell", 404 says "no such tenant". Collapsing them would tell
631 // an operator the wrong thing during an incident.
632 let problem = Resolution::Unknown.admit().expect_err("must not serve");
633 assert_eq!(problem.slug, "unknown-tenant");
634 assert_eq!(problem.status.as_u16(), 404);
635 }
636
637 #[test]
638 fn a_status_this_binary_does_not_know_is_refused_not_guessed() {
639 // `parse` is fail-closed, so a future version writing a status
640 // this binary has never heard of reads as degraded and is
641 // refused. The alternative - defaulting to active - would serve a
642 // tenant on the strength of not understanding it.
643 let unknown = TenantStatus::parse("quiesced");
644 assert_eq!(unknown, TenantStatus::Degraded);
645 assert!(found(unknown).admit().is_err());
646 }
647
648 #[test]
649 fn serializes_as_the_registry_text_not_the_variant_name() {
650 for &status in EVERY {
651 let json = serde_json::to_string(&status).expect("a unit variant serializes");
652 assert_eq!(
653 json,
654 format!("\"{}\"", status.as_str()),
655 "the wire form is the registry's word, so a log field and a row agree"
656 );
657 }
658 }
659
660 #[test]
661 fn an_unknown_status_reads_as_degraded() {
662 assert_eq!(TenantStatus::parse("busy"), TenantStatus::Degraded);
663 assert_eq!(TenantStatus::parse(""), TenantStatus::Degraded);
664 }
665
666 #[test]
667 fn a_tenant_being_shredded_is_not_revived_by_a_reconciliation() {
668 // The live one. `offboarding` means the export and key shred of
669 // `docs/TENANT-ONBOARDING.md` §2 are under way; a reconciler
670 // writing `active` would serve a database whose keys are being
671 // destroyed underneath it. The fleet already skips it
672 // (`is_reconciled`), and this is the second line, because the
673 // reconciler is not the only writer.
674 assert!(!TenantStatus::Offboarding.can_become(TenantStatus::Active));
675 assert!(!TenantStatus::Offboarding.can_become(TenantStatus::Degraded));
676 assert!(!TenantStatus::Offboarding.can_become(TenantStatus::Provisioning));
677 // Forward is the only way out.
678 assert!(TenantStatus::Offboarding.can_become(TenantStatus::Archived));
679 }
680
681 #[test]
682 fn archiving_records_a_shred_that_happened() {
683 // Only the offboarding that performed it ends there. Archiving
684 // straight from `active` would record a destruction of keys that
685 // nothing has destroyed.
686 assert_eq!(
687 TenantStatus::Archived.admits(),
688 &[TenantStatus::Offboarding]
689 );
690 for &from in EVERY {
691 assert_eq!(
692 from.can_become(TenantStatus::Archived),
693 from == TenantStatus::Offboarding,
694 "{from} -> archived"
695 );
696 }
697 }
698
699 #[test]
700 fn nothing_leaves_archived_including_a_write_of_archived() {
701 // The row is the record that the database was dropped and the
702 // keys destroyed. A record of an irreversible act is not
703 // rewritten — not even with the same word, which would move its
704 // timestamp and lose when the shred actually happened.
705 for &to in EVERY {
706 assert!(
707 !TenantStatus::Archived.can_become(to),
708 "archived -> {to} is not a move that exists"
709 );
710 }
711 }
712
713 #[test]
714 fn a_status_written_twice_is_not_a_failure() {
715 // A reconciler that runs twice writes `active` twice, and the
716 // second must not be refused. Archived is the exception, and has
717 // its own test saying why.
718 for &status in EVERY {
719 assert_eq!(
720 status.can_become(status),
721 status != TenantStatus::Archived,
722 "{status} -> {status}"
723 );
724 }
725 }
726
727 #[test]
728 fn a_tenant_abandoned_before_it_served_still_has_keys_to_shred() {
729 // So retirement starts from `provisioning` too. Skipping straight
730 // to `archived` would leave the keys alive and claim they were
731 // destroyed.
732 assert!(TenantStatus::Provisioning.can_become(TenantStatus::Offboarding));
733 assert!(!TenantStatus::Provisioning.can_become(TenantStatus::Archived));
734 }
735
736 #[test]
737 fn a_tenant_never_goes_back_to_provisioning() {
738 // Re-provisioning an existing tenant is a new tenant: the old
739 // one's keys are either live or destroyed, and neither is a
740 // starting point.
741 assert_eq!(
742 TenantStatus::Provisioning.admits(),
743 &[TenantStatus::Provisioning]
744 );
745 }
746
747 #[test]
748 fn the_rule_reads_the_same_from_both_ends() {
749 // `admits` is the match and `can_become` asks it backwards. One
750 // rule, so a change to either cannot disagree with the other.
751 for &to in EVERY {
752 for &from in EVERY {
753 assert_eq!(
754 from.can_become(to),
755 to.admits().contains(&from),
756 "{from} -> {to}"
757 );
758 }
759 }
760 }
761
762 #[test]
763 fn a_status_the_fleet_flies_is_one_a_reconciliation_can_write() {
764 // The two rules have to agree or a reconciled tenant is one whose
765 // outcome the registry will not take: `is_reconciled` picks the
766 // tenants the fleet boots, and reconciliation ends by writing
767 // `active` or `degraded`.
768 for &status in EVERY.iter().filter(|status| status.is_reconciled()) {
769 assert!(
770 status.can_become(TenantStatus::Active)
771 && status.can_become(TenantStatus::Degraded),
772 "{status} is flown, and its own outcome would be refused"
773 );
774 }
775 }
776}