Skip to main content

flint_lean/
conformance.rs

1//! Does this store actually enforce the conditions lean writes under?
2//!
3//! Everything the protocol promises rests on one property of the
4//! backend: a conditional write is REFUSED when its condition does not
5//! hold. The manifest CAS, the lease cell, the upload gate and the file
6//! collector are all conditional writes, and a store that accepts the
7//! header and ignores it answers byte-for-byte like one that enforces
8//! it — until the day it overwrites or deletes the wrong generation.
9//!
10//! This was a documented assumption (`lean/SAFETY.md` §2) and a CLI verb
11//! an operator could remember to run. That is not enough, and the model
12//! says why in the sharpest possible terms: the garbage collector's
13//! DELETE carries `If-Match` because the variant WITHOUT it —
14//! `LeanBarrierLeaseGCUnconditional` — is refuted by the gate, a
15//! mutation that must violate an invariant. On a store that ignores the
16//! header, the shipped binary IS that mutation. Nothing about the code
17//! is wrong; the store makes it wrong.
18//!
19//! So the probes run before the first barrier, and the answer is acted
20//! on rather than printed:
21//!
22//! - **PUT conditionals broken** → refuse the workspace. `If-None-Match`
23//!   and `If-Match` on PUT carry the manifest CAS and the lease itself;
24//!   there is no degraded mode, only last-writer-wins pretending to be
25//!   arbitration.
26//! - **`If-Match` on DELETE broken** → refuse the COLLECTOR, not the
27//!   workspace. The syncer runs, publishes and arbitrates exactly as
28//!   before; the collector leaves its objects behind (`barrier.rs`,
29//!   `report.leaked`). Uncited objects are served to nobody and swept by
30//!   nothing, so the cost is storage growth. Apache Ozone 2.2.x is this
31//!   case (HDDS-14907, finding L-27), and refusing it outright would
32//!   refuse the on-prem target over a leak.
33//!
34//! The verdict is cached in the state dir per store identity: the probes
35//! cost ~10 requests and a store does not change its mind between two
36//! barriers. The state dir is the worker's emptyDir, so a store that is
37//! FIXED is re-probed by the next pod without anyone clearing a cache.
38
39use crate::state::StoreConformance;
40use crate::{now_unix, Access, LeanResult, Syncer, LEAN_DIR};
41
42/// Bump when a probe asks a NEW question, so an old verdict is never
43/// read as the answer to it.
44pub const PROBE_VERSION: u32 = 1;
45
46/// What the gate decided, for the caller to act on and report.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Decision {
49    /// Both surfaces enforced (or a read-only workspace, which writes
50    /// nothing and so stands on neither).
51    Conformant,
52    /// `If-Match` on DELETE is not enforced: the collector is off for
53    /// this workspace and objects it would have taken are left behind.
54    CollectorOff(String),
55    /// A conditional PUT was ACCEPTED when it had to be refused. The
56    /// workspace must not be published.
57    Refuse(String),
58    /// The probe could not write its own object, so the store answered
59    /// nothing: a read-only credential, a bucket policy, an endpoint
60    /// that is down. NOT a verdict — the verb runs exactly as it did
61    /// before this gate existed and fails with its own, accurate error.
62    ///
63    /// Refusing here would be refusing an ERROR while reporting an
64    /// ANSWER: `lean/e2e/access/read-grant-minio.sh` D1 runs a
65    /// read-write syncer on read-only credentials on purpose, and the
66    /// operator it hands a diagnosis to deserves "403", not "this store
67    /// does not enforce conditional writes".
68    Unknown(String),
69}
70
71/// Probe this store (or read the cached verdict), apply the result to
72/// `sc.cfg`, and say what was decided.
73///
74/// `identity` names the store the verdict belongs to — endpoint, bucket
75/// and prefix — so a workspace re-pointed somewhere else is asked again
76/// rather than inheriting an answer about a different bucket.
77pub async fn gate(sc: &mut Syncer, identity: &str) -> LeanResult<Decision> {
78    // A read-only syncer never CASes a manifest, never claims the cell
79    // and never collects. It also cannot run the probes, which write.
80    if sc.cfg.access == Access::Read {
81        return Ok(Decision::Conformant);
82    }
83
84    let cached = sc.state.load_conformance()?.filter(|c| {
85        c.identity == identity && c.probe_version == PROBE_VERSION
86    });
87    let verdict = match cached {
88        Some(c) => c,
89        None => {
90            let c = probe(sc, identity).await;
91            // Only an ANSWER is cached. A probe that could not write at
92            // all says nothing about the store, and a cached "no" would
93            // outlive the outage that produced it.
94            if c.answered {
95                sc.state.save_conformance(&c)?;
96            }
97            c
98        }
99    };
100
101    sc.trace(
102        "conformance",
103        serde_json::json!({
104            "identity": verdict.identity, "answered": verdict.answered,
105            "put": verdict.conditional_put, "delete": verdict.conditional_delete,
106            "detail": verdict.detail,
107        }),
108    );
109
110    // Could not write its own object: no verdict exists. Say so and get
111    // out of the way — the verb will fail on the same credential with a
112    // message that names it.
113    if !verdict.answered {
114        return Ok(Decision::Unknown(verdict.detail.unwrap_or_default()));
115    }
116    if !verdict.conditional_put {
117        return Ok(Decision::Refuse(verdict.detail.unwrap_or_default()));
118    }
119    if !verdict.conditional_delete {
120        return Ok(Decision::CollectorOff(verdict.detail.unwrap_or_default()));
121    }
122    Ok(Decision::Conformant)
123}
124
125/// The two probes, on keys nothing else uses.
126///
127/// The key carries a per-writer id because the probes are themselves
128/// conditional writes: two conformant syncers sharing a workspace and a
129/// probe key would fail each other's `If-None-Match` and each report the
130/// other's conformance as its own broken store.
131async fn probe(sc: &Syncer, identity: &str) -> StoreConformance {
132    let who = std::env::var("HOSTNAME")
133        .ok()
134        .filter(|h| !h.is_empty())
135        .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
136    let mut out = StoreConformance {
137        identity: identity.to_string(),
138        probe_version: PROBE_VERSION,
139        answered: true,
140        conditional_put: false,
141        conditional_delete: false,
142        detail: None,
143        at_unix: now_unix(),
144    };
145    // A probe that could not write its own object did not ANSWER: that
146    // is the credential or the network, and reporting it as a verdict
147    // about conditional writes is how a 403 comes to read as "this store
148    // is not conformant".
149    let unanswered = |e: &str| e.starts_with(flint_store::probe::PROBE_UNREACHABLE);
150
151    let put_key = format!("{}/{}/probe/conditional-put-{who}", sc.cfg.prefix, LEAN_DIR);
152    if let Err(e) = flint_store::probe::probe_conditional_writes(sc.store.as_ref(), &put_key).await
153    {
154        out.answered = !unanswered(&e);
155        out.detail = Some(e);
156        return out;
157    }
158    out.conditional_put = true;
159
160    let del_key = format!("{}/{}/probe/conditional-delete-{who}", sc.cfg.prefix, LEAN_DIR);
161    match flint_store::probe::probe_conditional_delete(sc.store.as_ref(), &del_key).await {
162        Ok(()) => out.conditional_delete = true,
163        // The PUT probe just wrote here, so a write failure now is a
164        // surprise rather than a credential. Still not a verdict about
165        // the DELETE condition: leave the collector alone.
166        Err(e) if unanswered(&e) => {
167            out.answered = false;
168            out.detail = Some(e);
169        }
170        Err(e) => out.detail = Some(e),
171    }
172    out
173}
174
175/// The operator-facing sentence for each outcome. Kept here, next to the
176/// decision, so the CLI and the worker cannot describe the same verdict
177/// two different ways.
178pub fn message(d: &Decision, prefix: &str) -> String {
179    match d {
180        Decision::Conformant => "conditional writes enforced (PUT and DELETE)".into(),
181        Decision::CollectorOff(why) => format!(
182            "this store does not enforce If-Match on DELETE ({why}). Under immutable object \
183             handles that costs nothing: every write lands at a handle nobody else writes, \
184             and a handle the installed document stopped citing can never be cited again, so \
185             the collector's DELETE has nothing left to guard and runs unconditionally here \
186             as everywhere. The syncer publishes and collects normally under {prefix}/files/. \
187             Reported because it is a real property of this store — it was the posture switch \
188             before handles, and the one the conditional-DELETE row of lean/SAFETY.md §2 was \
189             written for"
190        ),
191        Decision::Refuse(why) => format!(
192            "this store does not enforce conditional writes ({why}). Lean arbitrates \
193             ENTIRELY through them — the manifest CAS, the publish fence and every upload \
194             — so on this store two writers would silently overwrite each other instead of \
195             conflicting. Refusing rather than degrading; see lean/SAFETY.md §2"
196        ),
197        Decision::Unknown(why) => format!(
198            "could not ask this store whether it enforces conditional writes ({why}) — the \
199             probe could not write its own object. Continuing UNVERIFIED, exactly as before \
200             this check existed; the verb below will report the underlying error itself"
201        ),
202    }
203}