Skip to main content

ignition_core/client/
deploy_api.rs

1//! The deploy wire: config resources, scan-lock and scan calls on a trait of
2//! their own.
3//!
4//! A separate trait by roadmap lock: [`super::GatewayApi`] has 15
5//! hand-written test doubles, and the deploy wire must not force a change
6//! on any of them. Every method here maps to exactly one [`EndpointDecl`]
7//! in [`DEPLOY_ENDPOINTS`], whose declared [`ResponseShape`] is enforced in
8//! one place, `send_shaped`. Only `id` and `shape` are read at runtime; the
9//! `method` and `path_template` columns are tied to what each method sends
10//! by the `wire_matches_the_endpoint_table` contract test. A state-changing call is confirmed by a
11//! follow-up read, never by its status: the scan POST's own 200 is a
12//! trigger-time snapshot (D4), and an acquire's 200 body adds nothing the
13//! info read does not give.
14//!
15//! [`ReqwestGatewayApi::apply_auth`] is the only credential site; every
16//! request built here passes through it before it is sent.
17//!
18//! No POST (lock acquire, scan trigger, redundancy resync) is ever retried, refusals
19//! included: a repeated non-idempotent call against a production gateway
20//! is never silent. The poll engine wraps only the GET probes.
21
22use std::collections::BTreeSet;
23use std::time::{Duration, Instant};
24
25use serde::Deserialize;
26use serde::de::DeserializeOwned;
27
28use crate::client::config_resources::{
29    MutationOutcome, ResourceCreate, ResourceName, ResourceRecord, ResourceUpdate,
30    list_query_pairs, resource_find_path, resource_list_path, resource_names_path,
31    resource_singleton_path, resource_write_path, validate_segment,
32};
33use crate::client::mode::{
34    GatewayModeProbe, MODE_PATH, ModeCreate, ModeRecord, normalize_deployment_mode,
35};
36use crate::client::query::{ListEnvelope, ListQuery};
37use crate::client::restart::RESTART_PENDING_PATH;
38use crate::client::scan::{
39    LockObservation, LockRequest, ScanKind, ScanLockInfo, ScanOutcome, ScanStatus, scan_complete,
40    scan_lock_path, scan_path,
41};
42use crate::client::{GATEWAY_INFO_PATH, ReqwestGatewayApi, classify};
43use crate::error::{CoreError, SignatureReason};
44use crate::poll::{self, PollConfig, PollState};
45
46/// Added to `acquireTimeout` for the per-request client timeout, so a
47/// gateway that does wait out the acquire window answers before the client
48/// gives up and reports a fake `network_error` (RESEARCH Pitfall 4). The
49/// live gateway never waited (D1); the slack costs nothing when it does not.
50pub const ACQUIRE_TIMEOUT_SLACK: Duration = Duration::from_secs(10);
51
52/// Longest actor string taken from a gateway answer.
53const ACTOR_CAP: usize = 256;
54
55/// What a 2xx answer on an endpoint may look like.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum ResponseShape {
58    /// A non-empty JSON body.
59    Json,
60    /// Any 2xx; the body is discarded.
61    EmptyOk,
62    /// 204 (no body) or 200 with a JSON body. A 200 with an empty body is
63    /// an error: only the 204 status means "nothing there".
64    JsonOrNoContent,
65}
66
67/// One declared endpoint: its id, wire verb, path and 2xx shape, and the
68/// capture block the shape was read from.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct EndpointDecl {
71    /// Stable id, also named by shape errors.
72    pub id: &'static str,
73    /// HTTP method.
74    pub method: &'static str,
75    /// Path with `{kind}` where the scan kind goes, `{module}`, `{type}`
76    /// and `{name}` where a resource route takes them, and `{provider}`
77    /// where the secret-provider listing takes the provider name.
78    pub path_template: &'static str,
79    /// What a 2xx answer must look like.
80    pub shape: ResponseShape,
81    /// The `20-LIVE-CAPTURES.md` block(s) this row rests on.
82    pub capture_ref: &'static str,
83}
84
85/// The declared deploy endpoints. Shapes are copied from the
86/// `20-LIVE-CAPTURES.md` Endpoint shapes table.
87pub const DEPLOY_ENDPOINTS: &[EndpointDecl] = &[
88    EndpointDecl {
89        id: "resource_names",
90        method: "GET",
91        path_template: "/data/api/v1/resources/names/{module}/{type}",
92        shape: ResponseShape::Json,
93        capture_ref: "C-list",
94    },
95    EndpointDecl {
96        id: "resource_list",
97        method: "GET",
98        path_template: "/data/api/v1/resources/list/{module}/{type}",
99        shape: ResponseShape::Json,
100        capture_ref: "C-list",
101    },
102    EndpointDecl {
103        id: "resource_find",
104        method: "GET",
105        path_template: "/data/api/v1/resources/find/{module}/{type}/{name}",
106        shape: ResponseShape::Json,
107        capture_ref: "C3a",
108    },
109    EndpointDecl {
110        id: "resource_singleton",
111        method: "GET",
112        path_template: "/data/api/v1/resources/singleton/{module}/{type}",
113        shape: ResponseShape::Json,
114        capture_ref: "C-list",
115    },
116    EndpointDecl {
117        id: "resource_create",
118        method: "POST",
119        path_template: "/data/api/v1/resources/{module}/{type}",
120        shape: ResponseShape::Json,
121        capture_ref: "C3a",
122    },
123    EndpointDecl {
124        id: "resource_update",
125        method: "PUT",
126        path_template: "/data/api/v1/resources/{module}/{type}",
127        shape: ResponseShape::Json,
128        capture_ref: "C3a",
129    },
130    EndpointDecl {
131        id: "scan_lock_acquire",
132        method: "POST",
133        path_template: "/data/api/v1/scan-lock/{kind}",
134        shape: ResponseShape::EmptyOk,
135        capture_ref: "C1b",
136    },
137    EndpointDecl {
138        id: "scan_lock_info",
139        method: "GET",
140        path_template: "/data/api/v1/scan-lock/{kind}",
141        shape: ResponseShape::JsonOrNoContent,
142        capture_ref: "C1c",
143    },
144    EndpointDecl {
145        id: "scan_status",
146        method: "GET",
147        path_template: "/data/api/v1/scan/{kind}",
148        shape: ResponseShape::Json,
149        capture_ref: "C1i",
150    },
151    EndpointDecl {
152        id: "scan_request",
153        method: "POST",
154        path_template: "/data/api/v1/scan/{kind}",
155        shape: ResponseShape::EmptyOk,
156        capture_ref: "C1i",
157    },
158    EndpointDecl {
159        id: "modes",
160        method: "GET",
161        path_template: "/data/api/v1/mode",
162        shape: ResponseShape::Json,
163        capture_ref: "C-mode",
164    },
165    EndpointDecl {
166        id: "mode_create",
167        method: "POST",
168        path_template: "/data/api/v1/mode",
169        shape: ResponseShape::EmptyOk,
170        capture_ref: "C-mode",
171    },
172    EndpointDecl {
173        id: "deployment_mode",
174        method: "GET",
175        path_template: "/data/api/v1/gateway-info",
176        shape: ResponseShape::Json,
177        capture_ref: "C-null",
178    },
179    EndpointDecl {
180        id: "restart_pending",
181        method: "GET",
182        path_template: "/data/api/v1/restart-tasks/pending",
183        shape: ResponseShape::Json,
184        capture_ref: "C-restart",
185    },
186    EndpointDecl {
187        id: "redundancy_resync",
188        method: "POST",
189        path_template: "/data/api/v1/redundancy/gwaction/resync",
190        shape: ResponseShape::EmptyOk,
191        capture_ref: "C-P3",
192    },
193    EndpointDecl {
194        id: "secret_names",
195        method: "GET",
196        path_template: "/data/api/v1/secret-providers/{provider}/secrets",
197        shape: ResponseShape::Json,
198        capture_ref: "C-P7",
199    },
200];
201
202/// What a secret provider's name listing answered.
203///
204/// Names only: the listing route is marked non-secret in the 8.3.6 OpenAPI
205/// and no value is ever read (ENT-3115, DPAIR-03). Names are exact,
206/// case-sensitive strings (24-02 K9).
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum SecretNameListing {
209    /// The provider listed these names (possibly none).
210    Listed(BTreeSet<String>),
211    /// The provider does not exist on this node (404, C-P7).
212    NoProvider,
213    /// The provider cannot list its names: HTTP 501, and also the HTTP 500
214    /// a `remote` provider answers (C-P7, 24-02 K7).
215    CannotList,
216}
217
218/// Look a declared endpoint up by id.
219pub fn endpoint_decl(id: &str) -> Option<&'static EndpointDecl> {
220    DEPLOY_ENDPOINTS.iter().find(|decl| decl.id == id)
221}
222
223/// The declaration for a method of this file. Every call site names an id
224/// that exists in [`DEPLOY_ENDPOINTS`] (pinned by a unit test), so a miss
225/// is a programming error surfaced as `internal`, not a panic.
226fn decl(id: &'static str) -> Result<&'static EndpointDecl, CoreError> {
227    endpoint_decl(id).ok_or_else(|| CoreError::Internal(format!("no endpoint declaration {id}")))
228}
229
230/// Per-request client timeout of a lock acquire.
231fn acquire_request_timeout(acquire_s: u64) -> Duration {
232    Duration::from_secs(acquire_s).saturating_add(ACQUIRE_TIMEOUT_SLACK)
233}
234
235/// The deploy-facing gateway calls. Not part of [`super::GatewayApi`].
236#[async_trait::async_trait]
237pub trait DeployApi: Send + Sync {
238    /// List the names of a resource type (`resource_names`, capture
239    /// C-list "GET names"): each item carries the collections (`modes`)
240    /// that hold it. The answer is the `{items, metadata}` envelope; a
241    /// type with no resources is Ok with no items. Gateway order is kept.
242    async fn resource_names(
243        &self,
244        module: &str,
245        ty: &str,
246    ) -> Result<ListEnvelope<ResourceName>, CoreError>;
247
248    /// List the resources of a type (`resource_list`, capture C-list "GET
249    /// list"). Decision D7: this sees the EFFECTIVE view only, one item per
250    /// name, so a shadowed collection cannot be listed here; use
251    /// [`resource_list_in_collection`] for that. The gateway reads a filter
252    /// as its own `field[op]=value` parameter, so `q.filter` must be of
253    /// that form (see `config_resources::list_query_pairs`). Gateway order
254    /// is kept.
255    async fn resource_list(
256        &self,
257        module: &str,
258        ty: &str,
259        q: &ListQuery,
260    ) -> Result<ListEnvelope<ResourceRecord>, CoreError>;
261
262    /// Read one resource by name (`resource_find`, captures C3a / C-list).
263    /// `None` sends no `collection` parameter and reads the effective view;
264    /// `Some(c)` sends exactly `collection=c` and reads that copy. The two
265    /// carry different signatures (D7). An absent name is `not_found`.
266    async fn resource_find(
267        &self,
268        module: &str,
269        ty: &str,
270        name: &str,
271        collection: Option<&str>,
272    ) -> Result<ResourceRecord, CoreError>;
273
274    /// Read a singleton resource (`resource_singleton`, capture C-list
275    /// "GET singleton"), from the effective view or from `collection`.
276    async fn resource_singleton(
277        &self,
278        module: &str,
279        ty: &str,
280        collection: Option<&str>,
281    ) -> Result<ResourceRecord, CoreError>;
282
283    /// Create resources (`resource_create`), `POST` of an array body:
284    /// DAPI-02 as amended 2026-10-12 (create by POST, update by the
285    /// signature-checked array PUT). Capture C3a and Decision D10 are the
286    /// 8.3.6 confirmation: `[{name, config}]` answers 200 with
287    /// `{success, changes[].newSignature}`.
288    ///
289    /// A 2xx whose body says `success:false` is an error, never Ok. A
290    /// duplicate name is a 409 (D10) and is `resource_exists` (exit 6). The POST is sent once
291    /// per call and never retried: a repeated create against a production
292    /// gateway must never be silent. An empty `items` is `invalid_input`
293    /// with no request.
294    async fn resource_create(
295        &self,
296        module: &str,
297        ty: &str,
298        items: &[ResourceCreate],
299    ) -> Result<MutationOutcome, CoreError>;
300
301    /// Update resources (`resource_update`), `PUT` of an array body whose
302    /// every item carries a name and a signature: DAPI-02 as amended
303    /// 2026-10-12. Capture C3a and Decision D10 are the 8.3.6
304    /// confirmation: a `PUT` of an unknown name answers a bare 404
305    /// (`not_found`), so a create is never done by PUT.
306    ///
307    /// Decision D3 fixes the refusals. A stale signature is a 500 whose
308    /// `problem.message` says `signature mismatch`, and it comes back as
309    /// `resource_signature_rejected` (stale, exit 6). An omitted or empty
310    /// signature is one byte-identical 422 with `fieldName` `signature`
311    /// (the two cannot be told apart) and comes back as the same error
312    /// (missing); an empty signature is refused before any request. The
313    /// refusals leave the resource unchanged. Neither error carries the
314    /// signature or the gateway's problem text. Every successful write
315    /// rotates the signature, so an update reusing the pre-write one is
316    /// stale. The PUT is sent once per call and never retried. When two
317    /// writers race, the gateway's check serializes them and the loser
318    /// gets the stale refusal; the client adds no lock of its own.
319    async fn resource_update(
320        &self,
321        module: &str,
322        ty: &str,
323        items: &[ResourceUpdate],
324    ) -> Result<MutationOutcome, CoreError>;
325
326    /// Acquire the scan-lock (`scan_lock_acquire`, capture C1b).
327    ///
328    /// Success is the gateway's 2xx; the 200 body is ignored. A lock held
329    /// by anyone, the caller's own token included (D2), is refused with
330    /// `scan_lock_held` naming the holder: taken from the refusal body
331    /// (D1), else from ONE follow-up [`DeployApi::scan_lock_info`]. The
332    /// POST is sent once per call. There is no release route (D8); the
333    /// lock ends when the holder's scan runs (D12) or `hold_s` lapses.
334    async fn scan_lock_acquire(
335        &self,
336        kind: ScanKind,
337        acquire_s: u64,
338        hold_s: u64,
339    ) -> Result<(), CoreError>;
340
341    /// Read the scan-lock (`scan_lock_info`, capture C1c): 200 with a body
342    /// is `Held`, 204 is `Free`.
343    async fn scan_lock_info(&self, kind: ScanKind) -> Result<LockObservation, CoreError>;
344
345    /// Read the scan state (`scan_status`, capture C1i).
346    async fn scan_status(&self, kind: ScanKind) -> Result<ScanStatus, CoreError>;
347
348    /// Trigger a scan (`scan_request`, capture C1i). The POST answering 200
349    /// is NEVER completion: its body is a trigger-time snapshot (D4), so it
350    /// is ignored; completion comes from [`DeployApi::scan_status`] via
351    /// `scan_complete`. The scan-lock does not block another actor's scan
352    /// (D4: a non-holder's POST answered 200 and ran), so no refusal is
353    /// classified on this route. The POST is sent once per call.
354    async fn scan_request(&self, kind: ScanKind) -> Result<(), CoreError>;
355
356    /// List the deployment modes (`modes`, capture C-mode "GET /mode"):
357    /// `limit=-1` and `offset=0` go out, the answer is the `{items,
358    /// metadata}` envelope, and gateway order is kept. A gateway with no
359    /// modes is Ok with no items.
360    async fn modes(&self) -> Result<ListEnvelope<ModeRecord>, CoreError>;
361
362    /// Define a deployment mode (`mode_create`, capture C-mode, D6).
363    ///
364    /// The name is validated before any request (`invalid_input`). The POST
365    /// is sent exactly once and its 2xx body is ignored; success is
366    /// confirmed by a follow-up [`DeployApi::modes`] read, and the listed
367    /// record is returned. A 2xx whose mode is absent from that list is
368    /// `internal`. A duplicate (409) is `mode_exists` (exit 6) and an
369    /// illegal name (400) is `invalid_input` (exit 2), never Ok. This only DEFINES a mode: it never activates one, restarts
370    /// a gateway or sets a JVM argument.
371    async fn mode_create(&self, body: &ModeCreate) -> Result<ModeRecord, CoreError>;
372
373    /// The gateway's active deployment mode (`deployment_mode`, captures
374    /// C-null, D5): `None` when `deploymentMode` is `null`, `""`, absent
375    /// or whitespace-only (no mode), else the name verbatim. Read through
376    /// a private probe, so `GatewayInfo` is untouched.
377    async fn deployment_mode(&self) -> Result<Option<String>, CoreError>;
378
379    /// The changes waiting for a gateway restart (`restart_pending`, capture
380    /// C-restart, DAPI-06): the strings of `{"pending": [...]}`, in gateway
381    /// order, free text of the form `Module <State>: <module id>`. An empty
382    /// list is Ok. A pending entry stays until the gateway restarts, so a
383    /// caller that wants the entries a run added compares two reads.
384    ///
385    /// Read only (SF-3): `apply` reports this list and never restarts or
386    /// fails over. A refused or unreadable answer (401 and 403 are Jetty
387    /// pages, not JSON) is the usual error; the caller decides that it is a
388    /// warning.
389    async fn restart_pending(&self) -> Result<Vec<String>, CoreError>;
390
391    /// Ask the redundancy master to resynchronise its backup
392    /// (`redundancy_resync`, capture C-P3): one POST, no body.
393    ///
394    /// Send it to the MASTER's session only (D-07); the caller owns that
395    /// rule. The POST is sent once per call and never retried, refusals
396    /// included. A 200 (`{"resync_state":"started"}`, either documented body
397    /// is accepted and discarded) only means the request was accepted, so
398    /// the caller re-verifies the backup's content.
399    async fn redundancy_resync(&self) -> Result<(), CoreError>;
400
401    /// The names of the secrets one provider lists (`secret_names`, capture
402    /// C-P7): a 200 `{"secrets": [...]}` is [`SecretNameListing::Listed`], a
403    /// 404 is `NoProvider`, a 501 or a 500 is `CannotList` (24-02 K7), and
404    /// anything else is the usual classified error (a 401 or 403 is a Jetty
405    /// page and is `auth_rejected`). The provider name must be a safe path
406    /// segment (`invalid_input` before any request). Names only: no value
407    /// is read.
408    async fn secret_names(&self, provider: &str) -> Result<SecretNameListing, CoreError>;
409}
410
411/// The body of `restart-tasks/pending` (C-restart): one key, `pending`.
412#[derive(Deserialize)]
413struct RestartPendingBody {
414    pending: Vec<String>,
415}
416
417/// The body of a secret-provider listing (C-P7): one key, `secrets`.
418#[derive(Deserialize)]
419struct SecretNamesBody {
420    secrets: Vec<String>,
421}
422
423/// A 2xx body after the declared shape was enforced.
424enum ShapedBody {
425    /// The text of a JSON body.
426    Json(String),
427    /// An `EmptyOk` answer; the body was discarded.
428    Empty,
429    /// A 204.
430    NoContent,
431}
432
433impl ReqwestGatewayApi {
434    /// Send `request`, classify the answer, and enforce `decl.shape` on a
435    /// 2xx. Keeps no state between requests: nothing here is shared, so
436    /// concurrent callers cannot observe each other's statuses or bodies.
437    async fn send_shaped(
438        &self,
439        decl: &EndpointDecl,
440        request: reqwest::RequestBuilder,
441        url: &url::Url,
442    ) -> Result<ShapedBody, CoreError> {
443        let response = self.send_and_classify(request, url).await?;
444        let status = response.status();
445        if decl.shape == ResponseShape::EmptyOk {
446            return Ok(ShapedBody::Empty);
447        }
448        // Decided from the status, before any body read.
449        if decl.shape == ResponseShape::JsonOrNoContent && status == reqwest::StatusCode::NO_CONTENT
450        {
451            return Ok(ShapedBody::NoContent);
452        }
453        let text = response.text().await.map_err(|err| CoreError::Network {
454            url: url.to_string(),
455            source: Some(err),
456            observation: None,
457        })?;
458        if text.trim().is_empty() {
459            return Err(CoreError::Internal(format!(
460                "endpoint {} answered HTTP {status} from {url} with an empty body, \
461                 but its declared shape is {:?}",
462                decl.id, decl.shape
463            )));
464        }
465        Ok(ShapedBody::Json(text))
466    }
467
468    /// GET `path` with `pairs`, enforce the declared shape and parse the
469    /// JSON body into `T`. The shared tail of the four resource reads.
470    async fn read_resource_json<T: DeserializeOwned>(
471        &self,
472        id: &'static str,
473        path: &str,
474        pairs: &[(String, String)],
475    ) -> Result<T, CoreError> {
476        let decl = decl(id)?;
477        let url = self.url_for(path);
478        let mut request = self.client.get(url.clone());
479        if !pairs.is_empty() {
480            request = request.query(pairs);
481        }
482        let request = self.apply_auth(request);
483        match self.send_shaped(decl, request, &url).await? {
484            ShapedBody::Json(text) => parse_json(decl, &url, &text),
485            ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
486                "endpoint {} produced no body",
487                decl.id
488            ))),
489        }
490    }
491
492    /// Send a resource write and decide its outcome. Deploy-scoped: this is
493    /// the ONLY place a signature refusal gets its typed
494    /// [`CoreError::ResourceSignatureRejected`], so the v1.1
495    /// EAM routes (a 500 signature refusal is `internal` there) keep their
496    /// behaviour, and `classify` gains no signature arm.
497    ///
498    /// Decision D3 (capture C3a) keys the guard:
499    /// - a 500 whose JSON `problem.message` contains [`SIGNATURE_MARKER`]
500    ///   is a stale signature; any other 500 is `internal`;
501    /// - a 422 whose `fieldMessages` name the field `signature` is a
502    ///   missing signature (omitted and empty are the same bytes); any
503    ///   other 422 goes to the shared resource 422 mapping;
504    /// - every other status goes to `classify` unchanged.
505    ///
506    /// On a 2xx the body is parsed: `success:true` is Ok, `success:false`
507    /// is an error, stale when the problem carries the marker, `internal`
508    /// otherwise. No error built here carries the signature, the problem
509    /// message or the stacktrace, and the request is never retried.
510    async fn send_resource_write(
511        &self,
512        decl: &EndpointDecl,
513        request: reqwest::RequestBuilder,
514        url: &url::Url,
515        names: &[&str],
516    ) -> Result<MutationOutcome, CoreError> {
517        let network = |err: reqwest::Error| CoreError::Network {
518            url: url.to_string(),
519            source: Some(err),
520            observation: None,
521        };
522        let response = request.send().await.map_err(network)?;
523        let status = response.status();
524        if status == reqwest::StatusCode::INTERNAL_SERVER_ERROR {
525            let body = response.text().await.unwrap_or_default();
526            if problem_has_marker(&body) {
527                return Err(signature_rejected(
528                    names,
529                    SignatureReason::Stale,
530                    url,
531                    Some(&body),
532                ));
533            }
534            return Err(CoreError::Internal(format!(
535                "unexpected HTTP {status} from {url} (the gateway's problem text is withheld)"
536            )));
537        }
538        if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
539            let body = response.text().await.unwrap_or_default();
540            if names_signature_field(&body) {
541                return Err(signature_rejected(
542                    names,
543                    SignatureReason::Missing,
544                    url,
545                    None,
546                ));
547            }
548            return Err(classify::resource_body_rejected(url.as_str(), &body));
549        }
550        // D10: a duplicate create is a 409 with a JSON `problem` naming the
551        // resource. Scoped to the create endpoint; a 409 on any other write
552        // keeps the shared mapping.
553        if status == reqwest::StatusCode::CONFLICT && decl.id == "resource_create" {
554            let body = response.text().await.unwrap_or_default();
555            return Err(CoreError::ResourceExists {
556                name: resource_name_for(names, Some(&body)),
557                endpoint: Some(url.to_string()),
558            });
559        }
560        let response = classify::classify(response, url.as_str(), false).await?;
561        let text = response.text().await.map_err(network)?;
562        if text.trim().is_empty() {
563            return Err(CoreError::Internal(format!(
564                "endpoint {} answered HTTP {status} from {url} with an empty body, \
565                 but its declared shape is {:?}",
566                decl.id, decl.shape
567            )));
568        }
569        let outcome: MutationOutcome = parse_json(decl, url, &text)?;
570        if outcome.success {
571            return Ok(outcome);
572        }
573        let stale = outcome
574            .problem
575            .as_ref()
576            .is_some_and(|problem| problem.message.contains(SIGNATURE_MARKER));
577        if stale {
578            let message = outcome.problem.map(|p| p.message);
579            return Err(signature_rejected(
580                names,
581                SignatureReason::Stale,
582                url,
583                message.as_deref(),
584            ));
585        }
586        Err(CoreError::Internal(format!(
587            "endpoint {} answered HTTP {status} from {url} with success:false \
588             (the gateway's problem text is withheld)",
589            decl.id
590        )))
591    }
592}
593
594/// The marker D3 recorded for a stale signature: the lowercase phrase in
595/// the 500 body's `problem.message` (`MODIFY illegal: signature mismatch
596/// for 'ResourceId{...}'`). It is the same discriminator v1.1 recorded for
597/// the EAM routes; frames and prose around it drift between versions.
598const SIGNATURE_MARKER: &str = "signature mismatch";
599
600/// Does `body` parse as a mutation envelope whose problem message carries
601/// [`SIGNATURE_MARKER`]?
602fn problem_has_marker(body: &str) -> bool {
603    serde_json::from_str::<MutationOutcome>(body)
604        .ok()
605        .and_then(|outcome| outcome.problem)
606        .is_some_and(|problem| problem.message.contains(SIGNATURE_MARKER))
607}
608
609/// Does a 422 body name the field `signature` in `fieldMessages` (D3:
610/// `Missing 'signature' parameter`)?
611fn names_signature_field(body: &str) -> bool {
612    serde_json::from_str::<serde_json::Value>(body)
613        .ok()
614        .and_then(|parsed| parsed["fieldMessages"].as_array().cloned())
615        .is_some_and(|fields| {
616            fields
617                .iter()
618                .any(|field| field["fieldName"].as_str() == Some("signature"))
619        })
620}
621
622/// The resource a refusal is about: the batch item whose name appears in the
623/// gateway's resource path (`message`), else the only item, else
624/// `<batch of N>`. Never echoes the message itself.
625fn resource_name_for(names: &[&str], message: Option<&str>) -> String {
626    let from_message = message.and_then(|message| {
627        names
628            .iter()
629            .find(|name| message.contains(&format!("/{name},")))
630            .map(|name| (*name).to_string())
631    });
632    from_message.unwrap_or_else(|| match names {
633        [only] => (*only).to_string(),
634        _ => format!("<batch of {}>", names.len()),
635    })
636}
637
638/// Build the typed [`CoreError::ResourceSignatureRejected`] without the
639/// signature or the problem text. The resource is named by
640/// [`resource_name_for`].
641fn signature_rejected(
642    names: &[&str],
643    reason: SignatureReason,
644    url: &url::Url,
645    message: Option<&str>,
646) -> CoreError {
647    CoreError::ResourceSignatureRejected {
648        name: resource_name_for(names, message),
649        reason,
650        endpoint: Some(url.to_string()),
651    }
652}
653
654/// Parse a JSON body, naming the endpoint id and URL on failure and never
655/// echoing the body.
656fn parse_json<T: DeserializeOwned>(
657    decl: &EndpointDecl,
658    url: &url::Url,
659    text: &str,
660) -> Result<T, CoreError> {
661    serde_json::from_str(text).map_err(|err| {
662        CoreError::Internal(format!(
663            "endpoint {} answered from {url} with a body that did not match the \
664             expected shape: {err}",
665            decl.id
666        ))
667    })
668}
669
670#[async_trait::async_trait]
671impl DeployApi for ReqwestGatewayApi {
672    async fn resource_names(
673        &self,
674        module: &str,
675        ty: &str,
676    ) -> Result<ListEnvelope<ResourceName>, CoreError> {
677        let path = resource_names_path(module, ty)?;
678        self.read_resource_json("resource_names", &path, &[]).await
679    }
680
681    async fn resource_list(
682        &self,
683        module: &str,
684        ty: &str,
685        q: &ListQuery,
686    ) -> Result<ListEnvelope<ResourceRecord>, CoreError> {
687        let path = resource_list_path(module, ty)?;
688        let pairs = list_query_pairs(q)?;
689        self.read_resource_json("resource_list", &path, &pairs)
690            .await
691    }
692
693    async fn resource_find(
694        &self,
695        module: &str,
696        ty: &str,
697        name: &str,
698        collection: Option<&str>,
699    ) -> Result<ResourceRecord, CoreError> {
700        let path = resource_find_path(module, ty, name)?;
701        self.read_resource_json("resource_find", &path, &collection_pairs(collection))
702            .await
703    }
704
705    async fn resource_singleton(
706        &self,
707        module: &str,
708        ty: &str,
709        collection: Option<&str>,
710    ) -> Result<ResourceRecord, CoreError> {
711        let path = resource_singleton_path(module, ty)?;
712        self.read_resource_json("resource_singleton", &path, &collection_pairs(collection))
713            .await
714    }
715
716    async fn resource_create(
717        &self,
718        module: &str,
719        ty: &str,
720        items: &[ResourceCreate],
721    ) -> Result<MutationOutcome, CoreError> {
722        let path = resource_write_path(module, ty)?;
723        if items.is_empty() {
724            return Err(CoreError::InvalidInput {
725                reason: "resource create needs at least one item".to_string(),
726            });
727        }
728        if items.iter().any(|item| item.name.is_empty()) {
729            return Err(CoreError::InvalidInput {
730                reason: "resource name must not be empty".to_string(),
731            });
732        }
733        let decl = decl("resource_create")?;
734        let url = self.url_for(&path);
735        let request = self.apply_auth(self.client.post(url.clone()).json(items));
736        let names: Vec<&str> = items.iter().map(|item| item.name.as_str()).collect();
737        self.send_resource_write(decl, request, &url, &names).await
738    }
739
740    async fn resource_update(
741        &self,
742        module: &str,
743        ty: &str,
744        items: &[ResourceUpdate],
745    ) -> Result<MutationOutcome, CoreError> {
746        let path = resource_write_path(module, ty)?;
747        if items.is_empty() {
748            return Err(CoreError::InvalidInput {
749                reason: "resource update needs at least one item".to_string(),
750            });
751        }
752        let decl = decl("resource_update")?;
753        let url = self.url_for(&path);
754        let request = self.apply_auth(self.client.put(url.clone()).json(items));
755        let names: Vec<&str> = items.iter().map(|item| item.name.as_str()).collect();
756        self.send_resource_write(decl, request, &url, &names).await
757    }
758
759    async fn scan_lock_acquire(
760        &self,
761        kind: ScanKind,
762        acquire_s: u64,
763        hold_s: u64,
764    ) -> Result<(), CoreError> {
765        let decl = decl("scan_lock_acquire")?;
766        let url = self.url_for(&scan_lock_path(kind));
767        let request = self.apply_auth(
768            self.client
769                .post(url.clone())
770                .json(&LockRequest {
771                    acquire_timeout: acquire_s,
772                    hold_timeout: hold_s,
773                })
774                .timeout(acquire_request_timeout(acquire_s)),
775        );
776        match self.send_shaped(decl, request, &url).await {
777            Ok(_) => Ok(()),
778            // The refusal did not name the holder: ONE follow-up read may.
779            // Any enrichment failure keeps the original refusal, so the
780            // held signal is never masked. The POST is not re-sent.
781            Err(CoreError::ScanLockHeld {
782                actor: None,
783                kind: held_kind,
784                endpoint,
785            }) => {
786                let actor = match self.scan_lock_info(kind).await {
787                    Ok(LockObservation::Held(info)) => info
788                        .actor
789                        .map(|actor| actor.chars().take(ACTOR_CAP).collect::<String>()),
790                    _ => None,
791                };
792                Err(CoreError::ScanLockHeld {
793                    actor,
794                    kind: held_kind,
795                    endpoint,
796                })
797            }
798            Err(other) => Err(other),
799        }
800    }
801
802    async fn scan_lock_info(&self, kind: ScanKind) -> Result<LockObservation, CoreError> {
803        let decl = decl("scan_lock_info")?;
804        let url = self.url_for(&scan_lock_path(kind));
805        let request = self.apply_auth(self.client.get(url.clone()));
806        match self.send_shaped(decl, request, &url).await? {
807            ShapedBody::NoContent => Ok(LockObservation::Free),
808            ShapedBody::Json(text) => {
809                parse_json::<ScanLockInfo>(decl, &url, &text).map(LockObservation::Held)
810            }
811            ShapedBody::Empty => Err(CoreError::Internal(format!(
812                "endpoint {} produced no body shape",
813                decl.id
814            ))),
815        }
816    }
817
818    async fn scan_status(&self, kind: ScanKind) -> Result<ScanStatus, CoreError> {
819        let decl = decl("scan_status")?;
820        let url = self.url_for(&scan_path(kind));
821        let request = self.apply_auth(self.client.get(url.clone()));
822        match self.send_shaped(decl, request, &url).await? {
823            ShapedBody::Json(text) => parse_json(decl, &url, &text),
824            ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
825                "endpoint {} produced no body",
826                decl.id
827            ))),
828        }
829    }
830
831    async fn scan_request(&self, kind: ScanKind) -> Result<(), CoreError> {
832        let decl = decl("scan_request")?;
833        let url = self.url_for(&scan_path(kind));
834        let request = self.apply_auth(self.client.post(url.clone()));
835        self.send_shaped(decl, request, &url).await.map(|_| ())
836    }
837
838    async fn modes(&self) -> Result<ListEnvelope<ModeRecord>, CoreError> {
839        let decl = decl("modes")?;
840        let url = self.url_for(MODE_PATH);
841        let request = self.apply_auth(
842            self.client
843                .get(url.clone())
844                .query(&ListQuery::default().to_query_pairs()),
845        );
846        match self.send_shaped(decl, request, &url).await? {
847            ShapedBody::Json(text) => parse_json(decl, &url, &text),
848            ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
849                "endpoint {} produced no body",
850                decl.id
851            ))),
852        }
853    }
854
855    async fn mode_create(&self, body: &ModeCreate) -> Result<ModeRecord, CoreError> {
856        validate_segment("mode name", &body.name)?;
857        decl("mode_create")?;
858        let url = self.url_for(MODE_PATH);
859        let request = self.apply_auth(self.client.post(url.clone()).json(body));
860        // Exactly one POST: a repeated create is never silent. D6: a
861        // duplicate is a 409 and an illegal name a 400, both Jetty HTML;
862        // both are typed here (deploy-scoped, so classify stays as is) and
863        // neither echoes the gateway's page.
864        let response = request.send().await.map_err(|err| CoreError::Network {
865            url: url.to_string(),
866            source: Some(err),
867            observation: None,
868        })?;
869        match response.status() {
870            reqwest::StatusCode::CONFLICT => {
871                return Err(CoreError::ModeExists {
872                    name: body.name.clone(),
873                    endpoint: Some(url.to_string()),
874                });
875            }
876            reqwest::StatusCode::BAD_REQUEST => {
877                return Err(CoreError::InvalidInput {
878                    reason: format!(
879                        "gateway rejected the deployment mode name {:?} (HTTP 400 from {url})",
880                        body.name
881                    ),
882                });
883            }
884            _ => {}
885        }
886        // Success is any 2xx (EmptyOk); every other status is classified.
887        classify::classify(response, url.as_str(), false).await?;
888        // The 2xx is not the confirmation (D6): the list is.
889        let listed = self.modes().await?;
890        listed
891            .items
892            .into_iter()
893            .find(|mode| mode.name == body.name)
894            .ok_or_else(|| {
895                CoreError::Internal(format!(
896                    "mode_create answered 2xx from {url} but mode {:?} is not in the \
897                     follow-up mode list",
898                    body.name
899                ))
900            })
901    }
902
903    async fn deployment_mode(&self) -> Result<Option<String>, CoreError> {
904        let decl = decl("deployment_mode")?;
905        let url = self.url_for(GATEWAY_INFO_PATH);
906        let request = self.apply_auth(self.client.get(url.clone()));
907        match self.send_shaped(decl, request, &url).await? {
908            ShapedBody::Json(text) => {
909                let probe: GatewayModeProbe = parse_json(decl, &url, &text)?;
910                Ok(normalize_deployment_mode(probe.deployment_mode))
911            }
912            ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
913                "endpoint {} produced no body",
914                decl.id
915            ))),
916        }
917    }
918
919    async fn restart_pending(&self) -> Result<Vec<String>, CoreError> {
920        let decl = decl("restart_pending")?;
921        let url = self.url_for(RESTART_PENDING_PATH);
922        let request = self.apply_auth(self.client.get(url.clone()));
923        match self.send_shaped(decl, request, &url).await? {
924            ShapedBody::Json(text) => {
925                let body: RestartPendingBody = parse_json(decl, &url, &text)?;
926                Ok(body.pending)
927            }
928            ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
929                "endpoint {} produced no body",
930                decl.id
931            ))),
932        }
933    }
934
935    async fn redundancy_resync(&self) -> Result<(), CoreError> {
936        let decl = decl("redundancy_resync")?;
937        let url = self.url_for(REDUNDANCY_RESYNC_PATH);
938        // Exactly one POST: nothing in this path retries.
939        let request = self.apply_auth(self.client.post(url.clone()));
940        self.send_shaped(decl, request, &url).await.map(|_| ())
941    }
942
943    async fn secret_names(&self, provider: &str) -> Result<SecretNameListing, CoreError> {
944        validate_segment("provider", provider)?;
945        let decl = decl("secret_names")?;
946        let url = self.url_for(&secret_names_path(provider));
947        let request = self.apply_auth(self.client.get(url.clone()));
948        let response = request.send().await.map_err(|err| CoreError::Network {
949            url: url.to_string(),
950            source: Some(err),
951            observation: None,
952        })?;
953        // The "no listing" statuses are decided here, before the shared
954        // classification, so a 500 from a `remote` provider (C-P7) is never
955        // read as a gateway fault: 404 is no provider, 500 and 501 cannot
956        // list (24-02 K7). Everything else goes through `classify`.
957        match response.status() {
958            reqwest::StatusCode::NOT_FOUND => return Ok(SecretNameListing::NoProvider),
959            reqwest::StatusCode::INTERNAL_SERVER_ERROR | reqwest::StatusCode::NOT_IMPLEMENTED => {
960                return Ok(SecretNameListing::CannotList);
961            }
962            _ => {}
963        }
964        let response = classify::classify(response, url.as_str(), false).await?;
965        let text = response.text().await.map_err(|err| CoreError::Network {
966            url: url.to_string(),
967            source: Some(err),
968            observation: None,
969        })?;
970        if text.trim().is_empty() {
971            return Err(CoreError::Internal(format!(
972                "endpoint {} answered from {url} with an empty body, but its declared \
973                 shape is {:?}",
974                decl.id, decl.shape
975            )));
976        }
977        let body: SecretNamesBody = parse_json(decl, &url, &text)?;
978        Ok(SecretNameListing::Listed(
979            body.secrets.into_iter().collect(),
980        ))
981    }
982}
983
984/// `POST` path of the redundancy resync (C-P3).
985const REDUNDANCY_RESYNC_PATH: &str = "/data/api/v1/redundancy/gwaction/resync";
986
987/// `GET` path of one provider's secret-name listing (C-P7). The provider is
988/// validated as a path segment by the caller.
989fn secret_names_path(provider: &str) -> String {
990    format!("/data/api/v1/secret-providers/{provider}/secrets")
991}
992
993/// The `collection` query pair, only when a collection was asked for.
994fn collection_pairs(collection: Option<&str>) -> Vec<(String, String)> {
995    collection
996        .map(|c| vec![("collection".to_string(), c.to_string())])
997        .unwrap_or_default()
998}
999
1000/// Read every resource of a type from one NAMED collection.
1001///
1002/// Decision D7: `list` sees the effective view only, so a collection that
1003/// is shadowed (C-list: a `core` copy under a `production` override) cannot
1004/// be listed. This composes the two calls that can: `names` (each item's
1005/// `modes` says which collections hold it), then `find?collection=` per
1006/// name. Results keep the order of `names`.
1007///
1008/// A name whose `modes` is non-empty and lacks `collection` is skipped
1009/// without a request. A `not_found` for a name means it is absent from that
1010/// collection and is skipped; any other error propagates. Because the
1011/// captures never show what `find?collection=` answers for a collection
1012/// that does not hold the name, a record that comes back stamped with a
1013/// different collection is skipped too, never returned as if it were
1014/// `collection`'s copy.
1015pub async fn resource_list_in_collection(
1016    api: &dyn DeployApi,
1017    module: &str,
1018    ty: &str,
1019    collection: &str,
1020) -> Result<Vec<ResourceRecord>, CoreError> {
1021    if collection.is_empty() {
1022        return Err(CoreError::InvalidInput {
1023            reason: "resource collection must not be empty".to_string(),
1024        });
1025    }
1026    let names = api.resource_names(module, ty).await?;
1027    let mut records = Vec::new();
1028    for item in names.items {
1029        if !item.modes.is_empty() && !item.modes.iter().any(|m| m == collection) {
1030            continue;
1031        }
1032        match api
1033            .resource_find(module, ty, &item.name, Some(collection))
1034            .await
1035        {
1036            Ok(record) => {
1037                if record
1038                    .collection
1039                    .as_deref()
1040                    .is_none_or(|held| held == collection)
1041                {
1042                    records.push(record);
1043                }
1044            }
1045            Err(CoreError::NotFound { .. }) => {}
1046            Err(other) => return Err(other),
1047        }
1048    }
1049    Ok(records)
1050}
1051
1052/// Everything a wait probe reads, owned by the poll loop.
1053#[derive(Clone, Copy)]
1054struct WaitCtx<'a> {
1055    api: &'a dyn DeployApi,
1056    kind: ScanKind,
1057    started: Instant,
1058    budget: Duration,
1059    /// The pre-trigger reading; `None` during the first phase.
1060    before: Option<&'a ScanStatus>,
1061}
1062
1063/// What one wait phase reached.
1064enum Waited {
1065    /// The phase's condition was met; carries the reading that met it.
1066    Reached(ScanStatus),
1067    /// The budget ran out first; carries the last reading.
1068    OutOfBudget(ScanStatus),
1069}
1070
1071/// Trigger a scan and wait until it is done: `scanActive` is false and
1072/// `lastScanTimestamp` exceeds the pre-trigger value read from the same
1073/// gateway. An HTTP 200 on the trigger is never success on its own.
1074///
1075/// Steps: read the status; if a scan is already active, wait it out BEFORE
1076/// triggering, so its completion is never counted as ours (D4); POST the
1077/// trigger exactly once; then poll the status until
1078/// [`scan_complete`]. The budget is `cfg.deadline`, measured here with a
1079/// local monotonic clock; a gateway timestamp is never compared with it.
1080/// When the budget runs out the answer is `Ok(NotComplete)`, not an error.
1081///
1082/// `crate::poll::poll` drives both waits, so transient network and
1083/// restarting answers are retried and an auth failure fails fast. The
1084/// probe owns the budget check, and poll's own deadline (budget plus two
1085/// intervals) is only a transport backstop, so `Err` means a transport or
1086/// auth failure, not a slow scan.
1087pub async fn scan_and_wait(
1088    api: &dyn DeployApi,
1089    kind: ScanKind,
1090    cfg: PollConfig,
1091) -> Result<ScanOutcome, CoreError> {
1092    let started = Instant::now();
1093    let budget = cfg.deadline;
1094    let backstop = budget.saturating_add(cfg.interval.saturating_mul(2));
1095    let poll_cfg = PollConfig {
1096        deadline: backstop,
1097        ..cfg
1098    };
1099
1100    // The probe state carries every borrow: poll lends the probe a
1101    // `&mut` of it, which ties the borrows' lifetimes to the poll call.
1102    let mut ctx = WaitCtx {
1103        api,
1104        kind,
1105        started,
1106        budget,
1107        before: None,
1108    };
1109
1110    // Phase 1: the pre-trigger reading, taken while no scan is active.
1111    let before = match poll::poll(poll_cfg.clone(), ctx, |ctx| {
1112        Box::pin(async move {
1113            let status = ctx.api.scan_status(ctx.kind).await?;
1114            if !status.scan_active {
1115                return Ok(PollState::Done(Waited::Reached(status)));
1116            }
1117            if ctx.started.elapsed() >= ctx.budget {
1118                return Ok(PollState::Done(Waited::OutOfBudget(status)));
1119            }
1120            Ok(PollState::Pending(Some(
1121                "a scan is already active — waiting it out before triggering".to_string(),
1122            )))
1123        })
1124    })
1125    .await?
1126    {
1127        Waited::Reached(before) => before,
1128        Waited::OutOfBudget(last) => {
1129            return Ok(ScanOutcome::NotComplete {
1130                last,
1131                waited: started.elapsed(),
1132            });
1133        }
1134    };
1135
1136    // Exactly one trigger POST, never retried.
1137    api.scan_request(kind).await?;
1138
1139    // Phase 2: wait for inactive plus an advanced timestamp.
1140    ctx = WaitCtx {
1141        before: Some(&before),
1142        ..ctx
1143    };
1144    let outcome = poll::poll(poll_cfg, ctx, |ctx| {
1145        Box::pin(async move {
1146            let now = ctx.api.scan_status(ctx.kind).await?;
1147            let Some(before) = ctx.before else {
1148                return Err(CoreError::Internal(
1149                    "scan wait lost its pre-trigger reading".to_string(),
1150                ));
1151            };
1152            if scan_complete(before, &now) {
1153                return Ok(PollState::Done(Waited::Reached(now)));
1154            }
1155            if ctx.started.elapsed() >= ctx.budget {
1156                return Ok(PollState::Done(Waited::OutOfBudget(now)));
1157            }
1158            Ok(PollState::Pending(Some(format!(
1159                "scanActive {} lastScanTimestamp {} (before {})",
1160                now.scan_active, now.last_scan_timestamp, before.last_scan_timestamp
1161            ))))
1162        })
1163    })
1164    .await?;
1165    Ok(match outcome {
1166        Waited::Reached(now) => ScanOutcome::Complete(now),
1167        Waited::OutOfBudget(last) => ScanOutcome::NotComplete {
1168            last,
1169            waited: started.elapsed(),
1170        },
1171    })
1172}
1173
1174#[cfg(test)]
1175mod tests {
1176    use super::*;
1177
1178    /// The rows copied from the 20-LIVE-CAPTURES Endpoint shapes table.
1179    #[test]
1180    fn endpoint_rows_match_the_capture_table() {
1181        let expected: [(&str, &str, &str, ResponseShape, &str); 16] = [
1182            (
1183                "resource_names",
1184                "GET",
1185                "/data/api/v1/resources/names/{module}/{type}",
1186                ResponseShape::Json,
1187                "C-list",
1188            ),
1189            (
1190                "resource_list",
1191                "GET",
1192                "/data/api/v1/resources/list/{module}/{type}",
1193                ResponseShape::Json,
1194                "C-list",
1195            ),
1196            (
1197                "resource_find",
1198                "GET",
1199                "/data/api/v1/resources/find/{module}/{type}/{name}",
1200                ResponseShape::Json,
1201                "C3a",
1202            ),
1203            (
1204                "resource_singleton",
1205                "GET",
1206                "/data/api/v1/resources/singleton/{module}/{type}",
1207                ResponseShape::Json,
1208                "C-list",
1209            ),
1210            (
1211                "resource_create",
1212                "POST",
1213                "/data/api/v1/resources/{module}/{type}",
1214                ResponseShape::Json,
1215                "C3a",
1216            ),
1217            (
1218                "resource_update",
1219                "PUT",
1220                "/data/api/v1/resources/{module}/{type}",
1221                ResponseShape::Json,
1222                "C3a",
1223            ),
1224            (
1225                "scan_lock_acquire",
1226                "POST",
1227                "/data/api/v1/scan-lock/{kind}",
1228                ResponseShape::EmptyOk,
1229                "C1b",
1230            ),
1231            (
1232                "scan_lock_info",
1233                "GET",
1234                "/data/api/v1/scan-lock/{kind}",
1235                ResponseShape::JsonOrNoContent,
1236                "C1c",
1237            ),
1238            (
1239                "scan_status",
1240                "GET",
1241                "/data/api/v1/scan/{kind}",
1242                ResponseShape::Json,
1243                "C1i",
1244            ),
1245            (
1246                "scan_request",
1247                "POST",
1248                "/data/api/v1/scan/{kind}",
1249                ResponseShape::EmptyOk,
1250                "C1i",
1251            ),
1252            (
1253                "modes",
1254                "GET",
1255                "/data/api/v1/mode",
1256                ResponseShape::Json,
1257                "C-mode",
1258            ),
1259            (
1260                "mode_create",
1261                "POST",
1262                "/data/api/v1/mode",
1263                ResponseShape::EmptyOk,
1264                "C-mode",
1265            ),
1266            (
1267                "deployment_mode",
1268                "GET",
1269                "/data/api/v1/gateway-info",
1270                ResponseShape::Json,
1271                "C-null",
1272            ),
1273            (
1274                "restart_pending",
1275                "GET",
1276                "/data/api/v1/restart-tasks/pending",
1277                ResponseShape::Json,
1278                "C-restart",
1279            ),
1280            (
1281                "redundancy_resync",
1282                "POST",
1283                "/data/api/v1/redundancy/gwaction/resync",
1284                ResponseShape::EmptyOk,
1285                "C-P3",
1286            ),
1287            (
1288                "secret_names",
1289                "GET",
1290                "/data/api/v1/secret-providers/{provider}/secrets",
1291                ResponseShape::Json,
1292                "C-P7",
1293            ),
1294        ];
1295        assert_eq!(DEPLOY_ENDPOINTS.len(), expected.len());
1296        for (id, method, template, shape, capture) in expected {
1297            let row = endpoint_decl(id).unwrap_or_else(|| panic!("row {id}"));
1298            assert_eq!(row.method, method, "{id}");
1299            assert_eq!(row.path_template, template, "{id}");
1300            assert_eq!(row.shape, shape, "{id}");
1301            assert_eq!(row.capture_ref, capture, "{id}");
1302        }
1303        assert!(endpoint_decl("nope").is_none());
1304    }
1305
1306    /// Every id a method of this file asks for resolves.
1307    #[test]
1308    fn every_method_id_is_declared() {
1309        for id in [
1310            "resource_names",
1311            "resource_list",
1312            "resource_find",
1313            "resource_singleton",
1314            "resource_create",
1315            "resource_update",
1316            "scan_lock_acquire",
1317            "scan_lock_info",
1318            "scan_status",
1319            "scan_request",
1320            "modes",
1321            "mode_create",
1322            "deployment_mode",
1323            "restart_pending",
1324            "redundancy_resync",
1325            "secret_names",
1326        ] {
1327            assert!(decl(id).is_ok(), "{id}");
1328        }
1329    }
1330
1331    /// DAPI-06: the table is closed. Sixteen rows, in trait declaration
1332    /// order, unique ids, every row backed by a capture block, every verb
1333    /// one of GET, POST and PUT.
1334    #[test]
1335    fn deploy_endpoints_cover_every_method() {
1336        let ids: Vec<&str> = DEPLOY_ENDPOINTS.iter().map(|row| row.id).collect();
1337        assert_eq!(
1338            ids,
1339            [
1340                "resource_names",
1341                "resource_list",
1342                "resource_find",
1343                "resource_singleton",
1344                "resource_create",
1345                "resource_update",
1346                "scan_lock_acquire",
1347                "scan_lock_info",
1348                "scan_status",
1349                "scan_request",
1350                "modes",
1351                "mode_create",
1352                "deployment_mode",
1353                "restart_pending",
1354                "redundancy_resync",
1355                "secret_names",
1356            ]
1357        );
1358        assert_eq!(DEPLOY_ENDPOINTS.len(), 16);
1359        let unique: std::collections::BTreeSet<&str> = ids.iter().copied().collect();
1360        assert_eq!(unique.len(), ids.len(), "ids are unique");
1361        for row in DEPLOY_ENDPOINTS {
1362            assert!(!row.capture_ref.is_empty(), "{}", row.id);
1363            assert!(
1364                matches!(row.method, "GET" | "POST" | "PUT"),
1365                "{} uses {}",
1366                row.id,
1367                row.method
1368            );
1369        }
1370    }
1371
1372    /// C-restart: the recorded bodies. An empty list, and the entry the rig
1373    /// produced after a module was disabled (`Module <State>: <module id>`).
1374    #[tokio::test]
1375    async fn restart_pending_parses_the_captured_shape() {
1376        use wiremock::matchers::{method, path};
1377        use wiremock::{Mock, MockServer, ResponseTemplate};
1378
1379        let pending = "/data/api/v1/restart-tasks/pending";
1380        let one = r#"{"pending":["Module Disabled: com.inductiveautomation.sfc"]}"#;
1381        let cases = [
1382            (
1383                ResponseTemplate::new(200).set_body_string(r#"{"pending":[]}"#),
1384                Ok(vec![]),
1385            ),
1386            (
1387                ResponseTemplate::new(200).set_body_string(one),
1388                Ok(vec![
1389                    "Module Disabled: com.inductiveautomation.sfc".to_string(),
1390                ]),
1391            ),
1392            (ResponseTemplate::new(403), Err("auth")),
1393            (ResponseTemplate::new(401), Err("auth")),
1394            (
1395                ResponseTemplate::new(200).set_body_string("{}"),
1396                Err("shape"),
1397            ),
1398            (
1399                ResponseTemplate::new(200).set_body_string(r#"{"pending":null}"#),
1400                Err("shape"),
1401            ),
1402        ];
1403        for (answer, want) in cases {
1404            let server = MockServer::start().await;
1405            Mock::given(method("GET"))
1406                .and(path(pending))
1407                .respond_with(answer)
1408                .expect(1)
1409                .mount(&server)
1410                .await;
1411            let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
1412            let got = api.restart_pending().await;
1413            match (got, want) {
1414                (Ok(list), Ok(expected)) => assert_eq!(list, expected),
1415                (Err(CoreError::Auth { .. }), Err("auth")) => {}
1416                (Err(CoreError::Internal(_)), Err("shape")) => {}
1417                (got, want) => panic!("got {got:?}, wanted {want:?}"),
1418            }
1419        }
1420    }
1421
1422    /// A 403 keeps its status in the typed error.
1423    #[tokio::test]
1424    async fn restart_pending_403_is_auth_with_the_status() {
1425        use wiremock::matchers::{method, path};
1426        use wiremock::{Mock, MockServer, ResponseTemplate};
1427
1428        let server = MockServer::start().await;
1429        Mock::given(method("GET"))
1430            .and(path("/data/api/v1/restart-tasks/pending"))
1431            .respond_with(ResponseTemplate::new(403))
1432            .mount(&server)
1433            .await;
1434        let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
1435        let err = api.restart_pending().await.expect_err("403");
1436        assert!(
1437            matches!(err, CoreError::Auth { status: 403, .. }),
1438            "{err:?}"
1439        );
1440    }
1441
1442    /// T-20-15: the client waits at least as long as the gateway may.
1443    #[test]
1444    fn acquire_request_timeout_is_acquire_plus_slack() {
1445        assert_eq!(acquire_request_timeout(10), Duration::from_secs(20));
1446        assert_eq!(acquire_request_timeout(0), ACQUIRE_TIMEOUT_SLACK);
1447        assert_eq!(ACQUIRE_TIMEOUT_SLACK, Duration::from_secs(10));
1448    }
1449}