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}