Skip to main content

ignition_core/client/
scan.rs

1//! Scan-lock and scan models plus the two pure verdicts built on them.
2//!
3//! Pure on purpose: no I/O and no clock. The wire calls live in
4//! [`super::deploy_api`]; this file only decides what a reading means,
5//! so every verdict is testable without sleeping.
6//!
7//! Wire facts (8.3.6, `20-LIVE-CAPTURES.md`, cited by block id):
8//!
9//! - `GET /data/api/v1/scan-lock/{kind}` answers 200 with
10//!   `{timestamp, remaining, actor}` while a lock is held and 204 with no
11//!   body when none is (C1a, C1c, D9).
12//! - `timestamp` is an OPAQUE value, not epoch milliseconds (380308558 in
13//!   C1c), constant for one hold and different for each new hold. It is
14//!   never read here and never compared with a clock.
15//! - `remaining` is integer seconds, floored: right after an acquire it
16//!   reads `holdTimeout - 1` (C1b: 59 for 60).
17//! - `actor` is the holder's opaque identity string
18//!   (`api-token:<name>:<ip>`, C1c); it is echoed, never parsed.
19//! - A scan releases the lock at trigger time, for the holder and for any
20//!   other actor alike (C1j, C1i; D4, D12).
21//! - `GET /data/api/v1/scan/{kind}` answers `{scanActive,
22//!   lastScanTimestamp, lastScanDuration}`; `lastScanTimestamp` is
23//!   epoch-ms of the gateway clock (C1i). The scan POST's own 200 body is
24//!   a trigger-time snapshot (`scanActive: true`, OLD timestamp) and is
25//!   never completion (D4).
26
27use std::collections::BTreeMap;
28use std::time::Duration;
29
30use serde::{Deserialize, Serialize};
31
32/// Which scan surface a lock or scan call addresses. The live OpenAPI
33/// declares both kinds (C1h); only `config` was exercised live, so
34/// `projects` is wiremock-pinned only (Drift and limits).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ScanKind {
37    /// Gateway configuration scan.
38    Config,
39    /// Project resource scan.
40    Projects,
41}
42
43impl ScanKind {
44    /// The path segment and error-payload spelling of this kind.
45    pub fn as_str(self) -> &'static str {
46        match self {
47            Self::Config => "config",
48            Self::Projects => "projects",
49        }
50    }
51}
52
53/// `/data/api/v1/scan-lock/{kind}` (GET info, POST acquire).
54pub fn scan_lock_path(kind: ScanKind) -> String {
55    format!("/data/api/v1/scan-lock/{}", kind.as_str())
56}
57
58/// `/data/api/v1/scan/{kind}` (GET status, POST trigger).
59pub fn scan_path(kind: ScanKind) -> String {
60    format!("/data/api/v1/scan/{}", kind.as_str())
61}
62
63/// POST body of a lock acquire. Field order is the wire order, so the
64/// recorded request body is deterministic. The gateway answers a body-less
65/// acquire with a 500 NullPointerException (C1b, D9), so the client always
66/// sends this.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
68pub struct LockRequest {
69    /// Seconds the gateway may wait for the lock. Never observed to wait:
70    /// a competing acquire is an immediate 409 (D1).
71    #[serde(rename = "acquireTimeout")]
72    pub acquire_timeout: u64,
73    /// Seconds the lock is held before it expires on its own.
74    #[serde(rename = "holdTimeout")]
75    pub hold_timeout: u64,
76}
77
78/// Body of a held lock (C1c). Lenient: unknown keys ride in `extra`.
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80pub struct ScanLockInfo {
81    /// Opaque per-hold value. Never read by any verdict (see module doc).
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub timestamp: Option<i64>,
84    /// Whole seconds until the hold expires, floored. An absent key reads
85    /// as 0, which the verdict treats as expiring: the cautious reading.
86    #[serde(default)]
87    pub remaining: i64,
88    /// The holder, as the gateway names it.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub actor: Option<String>,
91    /// Keys this client does not model.
92    #[serde(flatten)]
93    pub extra: BTreeMap<String, serde_json::Value>,
94}
95
96/// What `GET scan-lock/{kind}` saw.
97#[derive(Debug, Clone, PartialEq)]
98pub enum LockObservation {
99    /// 200 with a body: some actor holds the lock.
100    Held(ScanLockInfo),
101    /// 204: no lock is held.
102    Free,
103}
104
105/// The decision a caller makes from one [`LockObservation`].
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum LockVerdict {
108    /// Held with at least the requested margin left.
109    Held {
110        /// Seconds left, as the gateway reported them.
111        remaining_s: i64,
112    },
113    /// Held, but with less than the margin left: it may lapse mid-use.
114    Expiring {
115        /// Seconds left, as the gateway reported them.
116        remaining_s: i64,
117    },
118    /// No lock is held. Deliberately not named `Expired`: a 204 is also
119    /// the NORMAL state after a successful scan trigger, because a scan
120    /// releases the lock at trigger time for any actor (C1j, D4, D12).
121    /// The client cannot tell "expired" from "released by a scan"; the
122    /// caller's context decides which one it means.
123    NotHeld,
124}
125
126/// Decide what `obs` means. `Held` when `remaining >= margin_s`
127/// (inclusive), `Expiring` below it, `NotHeld` for a free lock.
128///
129/// Takes no clock and never reads `timestamp`, so a gateway timestamp at
130/// any distance from local time changes nothing (proptest below).
131/// `remaining` is floored whole seconds, so a margin under 2 s carries no
132/// information.
133pub fn lock_verdict(obs: &LockObservation, margin_s: i64) -> LockVerdict {
134    match obs {
135        LockObservation::Free => LockVerdict::NotHeld,
136        LockObservation::Held(info) if info.remaining >= margin_s => LockVerdict::Held {
137            remaining_s: info.remaining,
138        },
139        LockObservation::Held(info) => LockVerdict::Expiring {
140            remaining_s: info.remaining,
141        },
142    }
143}
144
145/// Body of `GET scan/{kind}` (C1i). The two fields that decide completion
146/// are required: a reading without them is a loud parse error, never a
147/// default that could read as "complete". Unknown keys ride in `extra`.
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149pub struct ScanStatus {
150    /// True while a scan runs.
151    #[serde(rename = "scanActive")]
152    pub scan_active: bool,
153    /// Epoch-ms of the last scan's end, on the GATEWAY clock.
154    #[serde(rename = "lastScanTimestamp")]
155    pub last_scan_timestamp: i64,
156    /// Milliseconds the last scan took.
157    #[serde(
158        rename = "lastScanDuration",
159        default,
160        skip_serializing_if = "Option::is_none"
161    )]
162    pub last_scan_duration: Option<i64>,
163    /// Keys this client does not model.
164    #[serde(flatten)]
165    pub extra: BTreeMap<String, serde_json::Value>,
166}
167
168/// True exactly when `now` is not active and its timestamp is greater than
169/// `before`'s. Both readings must come from the same gateway: they are
170/// never compared with local time or with another node's value.
171pub fn scan_complete(before: &ScanStatus, now: &ScanStatus) -> bool {
172    !now.scan_active && now.last_scan_timestamp > before.last_scan_timestamp
173}
174
175/// How a scan wait ended.
176#[derive(Debug, Clone, PartialEq)]
177pub enum ScanOutcome {
178    /// Inactive with an advanced timestamp; carries that final reading.
179    Complete(ScanStatus),
180    /// The budget ran out first. A trigger answering 200 is not success:
181    /// this is what a scan that never advances reports.
182    NotComplete {
183        /// The last reading taken.
184        last: ScanStatus,
185        /// How long the wait ran.
186        waited: Duration,
187    },
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use proptest::prelude::*;
194
195    fn status(active: bool, ts: i64) -> ScanStatus {
196        ScanStatus {
197            scan_active: active,
198            last_scan_timestamp: ts,
199            last_scan_duration: None,
200            extra: BTreeMap::new(),
201        }
202    }
203
204    fn held(timestamp: Option<i64>, remaining: i64) -> LockObservation {
205        LockObservation::Held(ScanLockInfo {
206            timestamp,
207            remaining,
208            actor: Some("api-token:cap-a:192.168.97.1".to_string()),
209            extra: BTreeMap::new(),
210        })
211    }
212
213    #[test]
214    fn scan_complete_truth_table() {
215        let before = status(false, 100);
216        assert!(!scan_complete(&before, &status(true, 100)));
217        assert!(!scan_complete(&before, &status(false, 100)));
218        assert!(scan_complete(&before, &status(false, 101)));
219        assert!(!scan_complete(&before, &status(true, 101)));
220    }
221
222    #[test]
223    fn lock_verdict_boundary_is_inclusive() {
224        assert_eq!(
225            lock_verdict(&held(None, 5), 5),
226            LockVerdict::Held { remaining_s: 5 }
227        );
228        assert_eq!(
229            lock_verdict(&held(None, 4), 5),
230            LockVerdict::Expiring { remaining_s: 4 }
231        );
232        assert_eq!(
233            lock_verdict(&LockObservation::Free, 5),
234            LockVerdict::NotHeld
235        );
236    }
237
238    proptest! {
239        #![proptest_config(ProptestConfig::with_cases(512))]
240
241        /// The skewed-clock test: the gateway `timestamp` may sit at any
242        /// distance from local time and nothing changes.
243        #[test]
244        fn lock_verdict_ignores_timestamp(
245            timestamp in any::<i64>(),
246            remaining in -10i64..=10_000,
247            margin in 0i64..=60,
248        ) {
249            prop_assert_eq!(
250                lock_verdict(&held(Some(timestamp), remaining), margin),
251                lock_verdict(&held(None, remaining), margin)
252            );
253        }
254    }
255
256    /// Structural pin: the non-test text of this file reads no clock. The
257    /// forbidden list lives here, after the cut, so it cannot trip on
258    /// itself.
259    #[test]
260    fn scan_rs_reads_no_clock() {
261        let source = include_str!("scan.rs");
262        let production = source
263            .split("#[cfg(test)]")
264            .next()
265            .expect("split yields a head");
266        let code: String = production
267            .lines()
268            .filter(|line| !line.trim_start().starts_with("//"))
269            .collect::<Vec<_>>()
270            .join("\n");
271        for forbidden in ["SystemTime", "Instant", "Utc", "chrono", "jiff", "now("] {
272            assert!(
273                !code.contains(forbidden),
274                "scan.rs must not read a clock, found {forbidden:?}"
275            );
276        }
277    }
278
279    #[test]
280    fn captured_bodies_parse() {
281        // C1c held body, verbatim.
282        let info: ScanLockInfo = serde_json::from_str(
283            "{\n  \"timestamp\": 380308558,\n  \"remaining\": 59,\n  \"actor\": \"api-token:cap-a:192.168.97.1\"\n}",
284        )
285        .expect("C1c parses");
286        assert_eq!(info.remaining, 59);
287        assert_eq!(info.timestamp, Some(380_308_558));
288        // C1i idle body, verbatim.
289        let idle: ScanStatus = serde_json::from_str(
290            "{\n  \"scanActive\": false,\n  \"lastScanTimestamp\": 1791815654089,\n  \"lastScanDuration\": 2081\n}",
291        )
292        .expect("C1i parses");
293        assert!(!idle.scan_active);
294        assert_eq!(idle.last_scan_timestamp, 1_791_815_654_089);
295        // A status without the deciding fields is refused, not defaulted.
296        assert!(serde_json::from_str::<ScanStatus>("{}").is_err());
297    }
298
299    #[test]
300    fn paths_follow_the_kind() {
301        assert_eq!(
302            scan_lock_path(ScanKind::Projects),
303            "/data/api/v1/scan-lock/projects"
304        );
305        assert_eq!(scan_path(ScanKind::Config), "/data/api/v1/scan/config");
306    }
307}