cratefield_adapter_postgres/lifecycle.rs
1//! The Postgres side of core's [`TenantLifecycle`] port (issue #154):
2//! the runtime-neutral write half of the `harness_tenants` registry.
3//!
4//! The registry's write path already existed here —
5//! [`Postgres::register_tenant`] and [`Postgres::set_tenant_status`] —
6//! but it answered in strings and booleans: a refused move and a failed
7//! write were the same `false`, and refusing an archived id was a
8//! `DbError` whose reason lived in prose. This file does not add a
9//! second writer; it is the classification layer that turns those
10//! answers into the port's typed errors, so a caller above the adapter
11//! can tell "the lifecycle says no" from "the database is down" without
12//! parsing driver text. The lifecycle rule itself stays in
13//! `TenantStatus::admits` — this file only asks it.
14//!
15//! Deliberately a separate module from `reconcile.rs` rather than more
16//! methods there: reconciliation owns the boot-time fleet behaviour, and
17//! this owns the registry's port surface. Both call the same two
18//! registry primitives, and neither re-implements the rule.
19
20use async_trait::async_trait;
21
22use crate::Postgres;
23use crate::reconcile::TenantRecord;
24use cratefield_core::Database as _;
25use cratefield_core::{
26 DbError, Statement, TenantLifecycle, TenantLifecycleError, TenantStatus, TenantSummary,
27};
28
29impl Postgres {
30 /// The status the registry records for `tenant`, or `None` when
31 /// there is no row.
32 ///
33 /// A status this binary does not know reads as
34 /// [`TenantStatus::Degraded`] here: `TenantStatus::parse` is
35 /// fail-closed on purpose, and this read inherits that — an
36 /// unknown word is a refusal at request time, not a guess.
37 ///
38 /// # Errors
39 ///
40 /// [`DbError`] when the read fails (including when the registry has
41 /// not been bootstrapped yet).
42 pub async fn tenant_status(&self, tenant: &str) -> Result<Option<TenantStatus>, DbError> {
43 let rows = self
44 .query(&Statement::with_values(
45 "SELECT status FROM harness_tenants WHERE tenant = ?".to_owned(),
46 vec![tenant.to_owned().into()],
47 ))
48 .await?;
49 Ok(rows.rows.iter().find_map(|row| {
50 row.get::<String>("status")
51 .map(|status| TenantStatus::parse(&status))
52 }))
53 }
54}
55
56#[async_trait]
57impl TenantLifecycle for Postgres {
58 async fn create(&self, tenant: &str, dsn: &str) -> Result<(), TenantLifecycleError> {
59 if let Err(error) = self.register_tenant(tenant, dsn).await {
60 // `register_tenant` reports an archived id as a 0-rows
61 // upsert, which reaches here as a `DbError::Execute` naming
62 // the tenant in prose. Classify it: a follow-up read that
63 // finds `archived` turns the string into the typed refusal,
64 // and any other answer falls back to the write's own error,
65 // already scrubbed by `Display for DbError`.
66 let archived = matches!(
67 self.tenant_status(tenant).await,
68 Ok(Some(TenantStatus::Archived))
69 );
70 return if archived {
71 Err(TenantLifecycleError::Refused {
72 tenant: tenant.to_owned(),
73 from: TenantStatus::Archived,
74 to: TenantStatus::Provisioning,
75 })
76 } else {
77 Err(TenantLifecycleError::Backend {
78 tenant: tenant.to_owned(),
79 message: error.to_string(),
80 })
81 };
82 }
83 Ok(())
84 }
85
86 async fn status(&self, tenant: &str) -> Result<TenantStatus, TenantLifecycleError> {
87 match self.tenant_status(tenant).await {
88 Ok(Some(status)) => Ok(status),
89 Ok(None) => Err(TenantLifecycleError::Unknown {
90 tenant: tenant.to_owned(),
91 }),
92 Err(error) => Err(TenantLifecycleError::Backend {
93 tenant: tenant.to_owned(),
94 message: error.to_string(),
95 }),
96 }
97 }
98
99 async fn tenants(&self) -> Result<Vec<TenantSummary>, TenantLifecycleError> {
100 // The inherent method, called explicitly: the trait method has
101 // the same name, and a bare `self.tenants()` here would dispatch
102 // to the trait — this impl — and recurse forever. Naming the
103 // type is what keeps this a delegation instead of a loop; the
104 // contract test `tenants_through_the_port_lists_rows_without_a_dsn`
105 // would hang if it ever regressed.
106 let records: Vec<TenantRecord> = Postgres::tenants(self).await.map_err(|error| {
107 // A listing reads the whole registry: there is no single
108 // tenant to name, so the field stays empty rather than
109 // blaming a row that had nothing to do with the failure.
110 TenantLifecycleError::Backend {
111 tenant: String::new(),
112 message: error.to_string(),
113 }
114 })?;
115 // The DSN is dropped here, not scrubbed: the summary type has no
116 // field for it, so the secret cannot reach a log or a dashboard
117 // through this port at all.
118 Ok(records
119 .into_iter()
120 .map(|record| TenantSummary {
121 tenant: record.tenant,
122 status: record.status,
123 })
124 .collect())
125 }
126
127 async fn set_status(
128 &self,
129 tenant: &str,
130 next: TenantStatus,
131 ) -> Result<(), TenantLifecycleError> {
132 if self.set_tenant_status(tenant, next).await {
133 return Ok(());
134 }
135 // KNOWN IMPRECISION, stated rather than papered over:
136 // `set_tenant_status` answers `false` both for "the lifecycle
137 // does not allow the move" and for "the write itself failed" —
138 // it logs and swallows the error (see its doc). A backend fault
139 // is therefore reported here as `Refused` (or `Unknown`),
140 // classified by this follow-up read, unless the read fails too —
141 // only that surfaces as `Backend`. Fixing it means changing the
142 // primitive's best-effort contract, which reconciliation also
143 // depends on; not done in this issue.
144 match self.tenant_status(tenant).await {
145 Ok(None) => Err(TenantLifecycleError::Unknown {
146 tenant: tenant.to_owned(),
147 }),
148 Ok(Some(current)) => Err(TenantLifecycleError::Refused {
149 tenant: tenant.to_owned(),
150 from: current,
151 to: next,
152 }),
153 Err(error) => Err(TenantLifecycleError::Backend {
154 tenant: tenant.to_owned(),
155 message: error.to_string(),
156 }),
157 }
158 }
159}