Skip to main content

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}