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    /// `PUT` the array `items` to the write path of `module`/`ty` through the
435    /// deploy-scoped write decision ([`Self::send_resource_write`] with the
436    /// `resource_update` declaration), for a resource whose modify body is
437    /// not a [`ResourceUpdate`] (the tag provider's C11 shape, 26-10).
438    /// `names` are the items' names, for the refusal. Sent once, never
439    /// retried.
440    pub(super) async fn resource_update_items<T: serde::Serialize + Sync>(
441        &self,
442        module: &str,
443        ty: &str,
444        items: &[T],
445        names: &[&str],
446    ) -> Result<MutationOutcome, CoreError> {
447        let path = resource_write_path(module, ty)?;
448        let decl = decl("resource_update")?;
449        let url = self.url_for(&path);
450        let request = self.apply_auth(self.client.put(url.clone()).json(items));
451        self.send_resource_write(decl, request, &url, names).await
452    }
453
454    /// Send `request`, classify the answer, and enforce `decl.shape` on a
455    /// 2xx. Keeps no state between requests: nothing here is shared, so
456    /// concurrent callers cannot observe each other's statuses or bodies.
457    async fn send_shaped(
458        &self,
459        decl: &EndpointDecl,
460        request: reqwest::RequestBuilder,
461        url: &url::Url,
462    ) -> Result<ShapedBody, CoreError> {
463        let response = self.send_and_classify(request, url).await?;
464        let status = response.status();
465        if decl.shape == ResponseShape::EmptyOk {
466            return Ok(ShapedBody::Empty);
467        }
468        // Decided from the status, before any body read.
469        if decl.shape == ResponseShape::JsonOrNoContent && status == reqwest::StatusCode::NO_CONTENT
470        {
471            return Ok(ShapedBody::NoContent);
472        }
473        let text = response.text().await.map_err(|err| CoreError::Network {
474            url: url.to_string(),
475            source: Some(err),
476            observation: None,
477        })?;
478        if text.trim().is_empty() {
479            return Err(CoreError::Internal(format!(
480                "endpoint {} answered HTTP {status} from {url} with an empty body, \
481                 but its declared shape is {:?}",
482                decl.id, decl.shape
483            )));
484        }
485        Ok(ShapedBody::Json(text))
486    }
487
488    /// GET `path` with `pairs`, enforce the declared shape and parse the
489    /// JSON body into `T`. The shared tail of the four resource reads.
490    async fn read_resource_json<T: DeserializeOwned>(
491        &self,
492        id: &'static str,
493        path: &str,
494        pairs: &[(String, String)],
495    ) -> Result<T, CoreError> {
496        let decl = decl(id)?;
497        let url = self.url_for(path);
498        let mut request = self.client.get(url.clone());
499        if !pairs.is_empty() {
500            request = request.query(pairs);
501        }
502        let request = self.apply_auth(request);
503        match self.send_shaped(decl, request, &url).await? {
504            ShapedBody::Json(text) => parse_json(decl, &url, &text),
505            ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
506                "endpoint {} produced no body",
507                decl.id
508            ))),
509        }
510    }
511
512    /// Send a resource write and decide its outcome. Deploy-scoped: this is
513    /// the ONLY place a signature refusal gets its typed
514    /// [`CoreError::ResourceSignatureRejected`], so the v1.1
515    /// EAM routes (a 500 signature refusal is `internal` there) keep their
516    /// behaviour, and `classify` gains no signature arm.
517    ///
518    /// Decision D3 (capture C3a) keys the guard:
519    /// - a 500 whose JSON `problem.message` contains [`SIGNATURE_MARKER`]
520    ///   is a stale signature; any other 500 is `internal`;
521    /// - a 422 whose `fieldMessages` name the field `signature` is a
522    ///   missing signature (omitted and empty are the same bytes); any
523    ///   other 422 goes to the shared resource 422 mapping;
524    /// - every other status goes to `classify` unchanged.
525    ///
526    /// On a 2xx the body is parsed: `success:true` is Ok, `success:false`
527    /// is an error, stale when the problem carries the marker, `internal`
528    /// otherwise. No error built here carries the signature, the problem
529    /// message or the stacktrace, and the request is never retried.
530    async fn send_resource_write(
531        &self,
532        decl: &EndpointDecl,
533        request: reqwest::RequestBuilder,
534        url: &url::Url,
535        names: &[&str],
536    ) -> Result<MutationOutcome, CoreError> {
537        let network = |err: reqwest::Error| CoreError::Network {
538            url: url.to_string(),
539            source: Some(err),
540            observation: None,
541        };
542        let response = request.send().await.map_err(network)?;
543        let status = response.status();
544        if status == reqwest::StatusCode::INTERNAL_SERVER_ERROR {
545            let body = response.text().await.unwrap_or_default();
546            if problem_has_marker(&body) {
547                return Err(signature_rejected(
548                    names,
549                    SignatureReason::Stale,
550                    url,
551                    Some(&body),
552                ));
553            }
554            return Err(CoreError::Internal(format!(
555                "unexpected HTTP {status} from {url} (the gateway's problem text is withheld)"
556            )));
557        }
558        if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
559            let body = response.text().await.unwrap_or_default();
560            if names_signature_field(&body) {
561                return Err(signature_rejected(
562                    names,
563                    SignatureReason::Missing,
564                    url,
565                    None,
566                ));
567            }
568            return Err(classify::resource_body_rejected(url.as_str(), &body));
569        }
570        // D10: a duplicate create is a 409 with a JSON `problem` naming the
571        // resource. Scoped to the create endpoint; a 409 on any other write
572        // keeps the shared mapping.
573        if status == reqwest::StatusCode::CONFLICT && decl.id == "resource_create" {
574            let body = response.text().await.unwrap_or_default();
575            return Err(CoreError::ResourceExists {
576                name: resource_name_for(names, Some(&body)),
577                endpoint: Some(url.to_string()),
578            });
579        }
580        let response = classify::classify(response, url.as_str(), false).await?;
581        let text = response.text().await.map_err(network)?;
582        if text.trim().is_empty() {
583            return Err(CoreError::Internal(format!(
584                "endpoint {} answered HTTP {status} from {url} with an empty body, \
585                 but its declared shape is {:?}",
586                decl.id, decl.shape
587            )));
588        }
589        let outcome: MutationOutcome = parse_json(decl, url, &text)?;
590        if outcome.success {
591            return Ok(outcome);
592        }
593        let stale = outcome
594            .problem
595            .as_ref()
596            .is_some_and(|problem| problem.message.contains(SIGNATURE_MARKER));
597        if stale {
598            let message = outcome.problem.map(|p| p.message);
599            return Err(signature_rejected(
600                names,
601                SignatureReason::Stale,
602                url,
603                message.as_deref(),
604            ));
605        }
606        Err(CoreError::Internal(format!(
607            "endpoint {} answered HTTP {status} from {url} with success:false \
608             (the gateway's problem text is withheld)",
609            decl.id
610        )))
611    }
612}
613
614/// The marker D3 recorded for a stale signature: the lowercase phrase in
615/// the 500 body's `problem.message` (`MODIFY illegal: signature mismatch
616/// for 'ResourceId{...}'`). It is the same discriminator v1.1 recorded for
617/// the EAM routes; frames and prose around it drift between versions.
618const SIGNATURE_MARKER: &str = "signature mismatch";
619
620/// Does `body` parse as a mutation envelope whose problem message carries
621/// [`SIGNATURE_MARKER`]?
622fn problem_has_marker(body: &str) -> bool {
623    serde_json::from_str::<MutationOutcome>(body)
624        .ok()
625        .and_then(|outcome| outcome.problem)
626        .is_some_and(|problem| problem.message.contains(SIGNATURE_MARKER))
627}
628
629/// Does a 422 body name the field `signature` in `fieldMessages` (D3:
630/// `Missing 'signature' parameter`)?
631fn names_signature_field(body: &str) -> bool {
632    serde_json::from_str::<serde_json::Value>(body)
633        .ok()
634        .and_then(|parsed| parsed["fieldMessages"].as_array().cloned())
635        .is_some_and(|fields| {
636            fields
637                .iter()
638                .any(|field| field["fieldName"].as_str() == Some("signature"))
639        })
640}
641
642/// The resource a refusal is about: the batch item whose name appears in the
643/// gateway's resource path (`message`), else the only item, else
644/// `<batch of N>`. Never echoes the message itself.
645fn resource_name_for(names: &[&str], message: Option<&str>) -> String {
646    let from_message = message.and_then(|message| {
647        names
648            .iter()
649            .find(|name| message.contains(&format!("/{name},")))
650            .map(|name| (*name).to_string())
651    });
652    from_message.unwrap_or_else(|| match names {
653        [only] => (*only).to_string(),
654        _ => format!("<batch of {}>", names.len()),
655    })
656}
657
658/// Build the typed [`CoreError::ResourceSignatureRejected`] without the
659/// signature or the problem text. The resource is named by
660/// [`resource_name_for`].
661fn signature_rejected(
662    names: &[&str],
663    reason: SignatureReason,
664    url: &url::Url,
665    message: Option<&str>,
666) -> CoreError {
667    CoreError::ResourceSignatureRejected {
668        name: resource_name_for(names, message),
669        reason,
670        endpoint: Some(url.to_string()),
671    }
672}
673
674/// Parse a JSON body, naming the endpoint id and URL on failure and never
675/// echoing the body.
676fn parse_json<T: DeserializeOwned>(
677    decl: &EndpointDecl,
678    url: &url::Url,
679    text: &str,
680) -> Result<T, CoreError> {
681    serde_json::from_str(text).map_err(|err| {
682        CoreError::Internal(format!(
683            "endpoint {} answered from {url} with a body that did not match the \
684             expected shape: {err}",
685            decl.id
686        ))
687    })
688}
689
690#[async_trait::async_trait]
691impl DeployApi for ReqwestGatewayApi {
692    async fn resource_names(
693        &self,
694        module: &str,
695        ty: &str,
696    ) -> Result<ListEnvelope<ResourceName>, CoreError> {
697        let path = resource_names_path(module, ty)?;
698        self.read_resource_json("resource_names", &path, &[]).await
699    }
700
701    async fn resource_list(
702        &self,
703        module: &str,
704        ty: &str,
705        q: &ListQuery,
706    ) -> Result<ListEnvelope<ResourceRecord>, CoreError> {
707        let path = resource_list_path(module, ty)?;
708        let pairs = list_query_pairs(q)?;
709        self.read_resource_json("resource_list", &path, &pairs)
710            .await
711    }
712
713    async fn resource_find(
714        &self,
715        module: &str,
716        ty: &str,
717        name: &str,
718        collection: Option<&str>,
719    ) -> Result<ResourceRecord, CoreError> {
720        let path = resource_find_path(module, ty, name)?;
721        self.read_resource_json("resource_find", &path, &collection_pairs(collection))
722            .await
723    }
724
725    async fn resource_singleton(
726        &self,
727        module: &str,
728        ty: &str,
729        collection: Option<&str>,
730    ) -> Result<ResourceRecord, CoreError> {
731        let path = resource_singleton_path(module, ty)?;
732        self.read_resource_json("resource_singleton", &path, &collection_pairs(collection))
733            .await
734    }
735
736    async fn resource_create(
737        &self,
738        module: &str,
739        ty: &str,
740        items: &[ResourceCreate],
741    ) -> Result<MutationOutcome, CoreError> {
742        let path = resource_write_path(module, ty)?;
743        if items.is_empty() {
744            return Err(CoreError::InvalidInput {
745                reason: "resource create needs at least one item".to_string(),
746            });
747        }
748        if items.iter().any(|item| item.name.is_empty()) {
749            return Err(CoreError::InvalidInput {
750                reason: "resource name must not be empty".to_string(),
751            });
752        }
753        let decl = decl("resource_create")?;
754        let url = self.url_for(&path);
755        let request = self.apply_auth(self.client.post(url.clone()).json(items));
756        let names: Vec<&str> = items.iter().map(|item| item.name.as_str()).collect();
757        self.send_resource_write(decl, request, &url, &names).await
758    }
759
760    async fn resource_update(
761        &self,
762        module: &str,
763        ty: &str,
764        items: &[ResourceUpdate],
765    ) -> Result<MutationOutcome, CoreError> {
766        let path = resource_write_path(module, ty)?;
767        if items.is_empty() {
768            return Err(CoreError::InvalidInput {
769                reason: "resource update needs at least one item".to_string(),
770            });
771        }
772        let decl = decl("resource_update")?;
773        let url = self.url_for(&path);
774        let request = self.apply_auth(self.client.put(url.clone()).json(items));
775        let names: Vec<&str> = items.iter().map(|item| item.name.as_str()).collect();
776        self.send_resource_write(decl, request, &url, &names).await
777    }
778
779    async fn scan_lock_acquire(
780        &self,
781        kind: ScanKind,
782        acquire_s: u64,
783        hold_s: u64,
784    ) -> Result<(), CoreError> {
785        let decl = decl("scan_lock_acquire")?;
786        let url = self.url_for(&scan_lock_path(kind));
787        let request = self.apply_auth(
788            self.client
789                .post(url.clone())
790                .json(&LockRequest {
791                    acquire_timeout: acquire_s,
792                    hold_timeout: hold_s,
793                })
794                .timeout(acquire_request_timeout(acquire_s)),
795        );
796        match self.send_shaped(decl, request, &url).await {
797            Ok(_) => Ok(()),
798            // The refusal did not name the holder: ONE follow-up read may.
799            // Any enrichment failure keeps the original refusal, so the
800            // held signal is never masked. The POST is not re-sent.
801            Err(CoreError::ScanLockHeld {
802                actor: None,
803                kind: held_kind,
804                endpoint,
805            }) => {
806                let actor = match self.scan_lock_info(kind).await {
807                    Ok(LockObservation::Held(info)) => info
808                        .actor
809                        .map(|actor| actor.chars().take(ACTOR_CAP).collect::<String>()),
810                    _ => None,
811                };
812                Err(CoreError::ScanLockHeld {
813                    actor,
814                    kind: held_kind,
815                    endpoint,
816                })
817            }
818            Err(other) => Err(other),
819        }
820    }
821
822    async fn scan_lock_info(&self, kind: ScanKind) -> Result<LockObservation, CoreError> {
823        let decl = decl("scan_lock_info")?;
824        let url = self.url_for(&scan_lock_path(kind));
825        let request = self.apply_auth(self.client.get(url.clone()));
826        match self.send_shaped(decl, request, &url).await? {
827            ShapedBody::NoContent => Ok(LockObservation::Free),
828            ShapedBody::Json(text) => {
829                parse_json::<ScanLockInfo>(decl, &url, &text).map(LockObservation::Held)
830            }
831            ShapedBody::Empty => Err(CoreError::Internal(format!(
832                "endpoint {} produced no body shape",
833                decl.id
834            ))),
835        }
836    }
837
838    async fn scan_status(&self, kind: ScanKind) -> Result<ScanStatus, CoreError> {
839        let decl = decl("scan_status")?;
840        let url = self.url_for(&scan_path(kind));
841        let request = self.apply_auth(self.client.get(url.clone()));
842        match self.send_shaped(decl, request, &url).await? {
843            ShapedBody::Json(text) => parse_json(decl, &url, &text),
844            ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
845                "endpoint {} produced no body",
846                decl.id
847            ))),
848        }
849    }
850
851    async fn scan_request(&self, kind: ScanKind) -> Result<(), CoreError> {
852        let decl = decl("scan_request")?;
853        let url = self.url_for(&scan_path(kind));
854        let request = self.apply_auth(self.client.post(url.clone()));
855        self.send_shaped(decl, request, &url).await.map(|_| ())
856    }
857
858    async fn modes(&self) -> Result<ListEnvelope<ModeRecord>, CoreError> {
859        let decl = decl("modes")?;
860        let url = self.url_for(MODE_PATH);
861        let request = self.apply_auth(
862            self.client
863                .get(url.clone())
864                .query(&ListQuery::default().to_query_pairs()),
865        );
866        match self.send_shaped(decl, request, &url).await? {
867            ShapedBody::Json(text) => parse_json(decl, &url, &text),
868            ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
869                "endpoint {} produced no body",
870                decl.id
871            ))),
872        }
873    }
874
875    async fn mode_create(&self, body: &ModeCreate) -> Result<ModeRecord, CoreError> {
876        validate_segment("mode name", &body.name)?;
877        decl("mode_create")?;
878        let url = self.url_for(MODE_PATH);
879        let request = self.apply_auth(self.client.post(url.clone()).json(body));
880        // Exactly one POST: a repeated create is never silent. D6: a
881        // duplicate is a 409 and an illegal name a 400, both Jetty HTML;
882        // both are typed here (deploy-scoped, so classify stays as is) and
883        // neither echoes the gateway's page.
884        let response = request.send().await.map_err(|err| CoreError::Network {
885            url: url.to_string(),
886            source: Some(err),
887            observation: None,
888        })?;
889        match response.status() {
890            reqwest::StatusCode::CONFLICT => {
891                return Err(CoreError::ModeExists {
892                    name: body.name.clone(),
893                    endpoint: Some(url.to_string()),
894                });
895            }
896            reqwest::StatusCode::BAD_REQUEST => {
897                return Err(CoreError::InvalidInput {
898                    reason: format!(
899                        "gateway rejected the deployment mode name {:?} (HTTP 400 from {url})",
900                        body.name
901                    ),
902                });
903            }
904            _ => {}
905        }
906        // Success is any 2xx (EmptyOk); every other status is classified.
907        classify::classify(response, url.as_str(), false).await?;
908        // The 2xx is not the confirmation (D6): the list is.
909        let listed = self.modes().await?;
910        listed
911            .items
912            .into_iter()
913            .find(|mode| mode.name == body.name)
914            .ok_or_else(|| {
915                CoreError::Internal(format!(
916                    "mode_create answered 2xx from {url} but mode {:?} is not in the \
917                     follow-up mode list",
918                    body.name
919                ))
920            })
921    }
922
923    async fn deployment_mode(&self) -> Result<Option<String>, CoreError> {
924        let decl = decl("deployment_mode")?;
925        let url = self.url_for(GATEWAY_INFO_PATH);
926        let request = self.apply_auth(self.client.get(url.clone()));
927        match self.send_shaped(decl, request, &url).await? {
928            ShapedBody::Json(text) => {
929                let probe: GatewayModeProbe = parse_json(decl, &url, &text)?;
930                Ok(normalize_deployment_mode(probe.deployment_mode))
931            }
932            ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
933                "endpoint {} produced no body",
934                decl.id
935            ))),
936        }
937    }
938
939    async fn restart_pending(&self) -> Result<Vec<String>, CoreError> {
940        let decl = decl("restart_pending")?;
941        let url = self.url_for(RESTART_PENDING_PATH);
942        let request = self.apply_auth(self.client.get(url.clone()));
943        match self.send_shaped(decl, request, &url).await? {
944            ShapedBody::Json(text) => {
945                let body: RestartPendingBody = parse_json(decl, &url, &text)?;
946                Ok(body.pending)
947            }
948            ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
949                "endpoint {} produced no body",
950                decl.id
951            ))),
952        }
953    }
954
955    async fn redundancy_resync(&self) -> Result<(), CoreError> {
956        let decl = decl("redundancy_resync")?;
957        let url = self.url_for(REDUNDANCY_RESYNC_PATH);
958        // Exactly one POST: nothing in this path retries.
959        let request = self.apply_auth(self.client.post(url.clone()));
960        self.send_shaped(decl, request, &url).await.map(|_| ())
961    }
962
963    async fn secret_names(&self, provider: &str) -> Result<SecretNameListing, CoreError> {
964        validate_segment("provider", provider)?;
965        let decl = decl("secret_names")?;
966        let url = self.url_for(&secret_names_path(provider));
967        let request = self.apply_auth(self.client.get(url.clone()));
968        let response = request.send().await.map_err(|err| CoreError::Network {
969            url: url.to_string(),
970            source: Some(err),
971            observation: None,
972        })?;
973        // The "no listing" statuses are decided here, before the shared
974        // classification, so a 500 from a `remote` provider (C-P7) is never
975        // read as a gateway fault: 404 is no provider, 500 and 501 cannot
976        // list (24-02 K7). Everything else goes through `classify`.
977        match response.status() {
978            reqwest::StatusCode::NOT_FOUND => return Ok(SecretNameListing::NoProvider),
979            reqwest::StatusCode::INTERNAL_SERVER_ERROR | reqwest::StatusCode::NOT_IMPLEMENTED => {
980                return Ok(SecretNameListing::CannotList);
981            }
982            _ => {}
983        }
984        let response = classify::classify(response, url.as_str(), false).await?;
985        let text = response.text().await.map_err(|err| CoreError::Network {
986            url: url.to_string(),
987            source: Some(err),
988            observation: None,
989        })?;
990        if text.trim().is_empty() {
991            return Err(CoreError::Internal(format!(
992                "endpoint {} answered from {url} with an empty body, but its declared \
993                 shape is {:?}",
994                decl.id, decl.shape
995            )));
996        }
997        let body: SecretNamesBody = parse_json(decl, &url, &text)?;
998        Ok(SecretNameListing::Listed(
999            body.secrets.into_iter().collect(),
1000        ))
1001    }
1002}
1003
1004/// `POST` path of the redundancy resync (C-P3).
1005const REDUNDANCY_RESYNC_PATH: &str = "/data/api/v1/redundancy/gwaction/resync";
1006
1007/// `GET` path of one provider's secret-name listing (C-P7). The provider is
1008/// validated as a path segment by the caller.
1009fn secret_names_path(provider: &str) -> String {
1010    format!("/data/api/v1/secret-providers/{provider}/secrets")
1011}
1012
1013/// The `collection` query pair, only when a collection was asked for.
1014fn collection_pairs(collection: Option<&str>) -> Vec<(String, String)> {
1015    collection
1016        .map(|c| vec![("collection".to_string(), c.to_string())])
1017        .unwrap_or_default()
1018}
1019
1020/// Read every resource of a type from one NAMED collection.
1021///
1022/// Decision D7: `list` sees the effective view only, so a collection that
1023/// is shadowed (C-list: a `core` copy under a `production` override) cannot
1024/// be listed. This composes the two calls that can: `names` (each item's
1025/// `modes` says which collections hold it), then `find?collection=` per
1026/// name. Results keep the order of `names`.
1027///
1028/// A name whose `modes` is non-empty and lacks `collection` is skipped
1029/// without a request. A `not_found` for a name means it is absent from that
1030/// collection and is skipped; any other error propagates. Because the
1031/// captures never show what `find?collection=` answers for a collection
1032/// that does not hold the name, a record that comes back stamped with a
1033/// different collection is skipped too, never returned as if it were
1034/// `collection`'s copy.
1035pub async fn resource_list_in_collection(
1036    api: &dyn DeployApi,
1037    module: &str,
1038    ty: &str,
1039    collection: &str,
1040) -> Result<Vec<ResourceRecord>, CoreError> {
1041    if collection.is_empty() {
1042        return Err(CoreError::InvalidInput {
1043            reason: "resource collection must not be empty".to_string(),
1044        });
1045    }
1046    let names = api.resource_names(module, ty).await?;
1047    let mut records = Vec::new();
1048    for item in names.items {
1049        if !item.modes.is_empty() && !item.modes.iter().any(|m| m == collection) {
1050            continue;
1051        }
1052        match api
1053            .resource_find(module, ty, &item.name, Some(collection))
1054            .await
1055        {
1056            Ok(record) => {
1057                if record
1058                    .collection
1059                    .as_deref()
1060                    .is_none_or(|held| held == collection)
1061                {
1062                    records.push(record);
1063                }
1064            }
1065            Err(CoreError::NotFound { .. }) => {}
1066            Err(other) => return Err(other),
1067        }
1068    }
1069    Ok(records)
1070}
1071
1072/// Everything a wait probe reads, owned by the poll loop.
1073#[derive(Clone, Copy)]
1074struct WaitCtx<'a> {
1075    api: &'a dyn DeployApi,
1076    kind: ScanKind,
1077    started: Instant,
1078    budget: Duration,
1079    /// The pre-trigger reading; `None` during the first phase.
1080    before: Option<&'a ScanStatus>,
1081}
1082
1083/// What one wait phase reached.
1084enum Waited {
1085    /// The phase's condition was met; carries the reading that met it.
1086    Reached(ScanStatus),
1087    /// The budget ran out first; carries the last reading.
1088    OutOfBudget(ScanStatus),
1089}
1090
1091/// Trigger a scan and wait until it is done: `scanActive` is false and
1092/// `lastScanTimestamp` exceeds the pre-trigger value read from the same
1093/// gateway. An HTTP 200 on the trigger is never success on its own.
1094///
1095/// Steps: read the status; if a scan is already active, wait it out BEFORE
1096/// triggering, so its completion is never counted as ours (D4); POST the
1097/// trigger exactly once; then poll the status until
1098/// [`scan_complete`]. The budget is `cfg.deadline`, measured here with a
1099/// local monotonic clock; a gateway timestamp is never compared with it.
1100/// When the budget runs out the answer is `Ok(NotComplete)`, not an error.
1101///
1102/// `crate::poll::poll` drives both waits, so transient network and
1103/// restarting answers are retried and an auth failure fails fast. The
1104/// probe owns the budget check, and poll's own deadline (budget plus two
1105/// intervals) is only a transport backstop, so `Err` means a transport or
1106/// auth failure, not a slow scan.
1107pub async fn scan_and_wait(
1108    api: &dyn DeployApi,
1109    kind: ScanKind,
1110    cfg: PollConfig,
1111) -> Result<ScanOutcome, CoreError> {
1112    let started = Instant::now();
1113    let budget = cfg.deadline;
1114    let backstop = budget.saturating_add(cfg.interval.saturating_mul(2));
1115    let poll_cfg = PollConfig {
1116        deadline: backstop,
1117        ..cfg
1118    };
1119
1120    // The probe state carries every borrow: poll lends the probe a
1121    // `&mut` of it, which ties the borrows' lifetimes to the poll call.
1122    let mut ctx = WaitCtx {
1123        api,
1124        kind,
1125        started,
1126        budget,
1127        before: None,
1128    };
1129
1130    // Phase 1: the pre-trigger reading, taken while no scan is active.
1131    let before = match poll::poll(poll_cfg.clone(), ctx, |ctx| {
1132        Box::pin(async move {
1133            let status = ctx.api.scan_status(ctx.kind).await?;
1134            if !status.scan_active {
1135                return Ok(PollState::Done(Waited::Reached(status)));
1136            }
1137            if ctx.started.elapsed() >= ctx.budget {
1138                return Ok(PollState::Done(Waited::OutOfBudget(status)));
1139            }
1140            Ok(PollState::Pending(Some(
1141                "a scan is already active — waiting it out before triggering".to_string(),
1142            )))
1143        })
1144    })
1145    .await?
1146    {
1147        Waited::Reached(before) => before,
1148        Waited::OutOfBudget(last) => {
1149            return Ok(ScanOutcome::NotComplete {
1150                last,
1151                waited: started.elapsed(),
1152            });
1153        }
1154    };
1155
1156    // Exactly one trigger POST, never retried.
1157    api.scan_request(kind).await?;
1158
1159    // Phase 2: wait for inactive plus an advanced timestamp.
1160    ctx = WaitCtx {
1161        before: Some(&before),
1162        ..ctx
1163    };
1164    let outcome = poll::poll(poll_cfg, ctx, |ctx| {
1165        Box::pin(async move {
1166            let now = ctx.api.scan_status(ctx.kind).await?;
1167            let Some(before) = ctx.before else {
1168                return Err(CoreError::Internal(
1169                    "scan wait lost its pre-trigger reading".to_string(),
1170                ));
1171            };
1172            if scan_complete(before, &now) {
1173                return Ok(PollState::Done(Waited::Reached(now)));
1174            }
1175            if ctx.started.elapsed() >= ctx.budget {
1176                return Ok(PollState::Done(Waited::OutOfBudget(now)));
1177            }
1178            Ok(PollState::Pending(Some(format!(
1179                "scanActive {} lastScanTimestamp {} (before {})",
1180                now.scan_active, now.last_scan_timestamp, before.last_scan_timestamp
1181            ))))
1182        })
1183    })
1184    .await?;
1185    Ok(match outcome {
1186        Waited::Reached(now) => ScanOutcome::Complete(now),
1187        Waited::OutOfBudget(last) => ScanOutcome::NotComplete {
1188            last,
1189            waited: started.elapsed(),
1190        },
1191    })
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196    use super::*;
1197
1198    /// The rows copied from the 20-LIVE-CAPTURES Endpoint shapes table.
1199    #[test]
1200    fn endpoint_rows_match_the_capture_table() {
1201        let expected: [(&str, &str, &str, ResponseShape, &str); 16] = [
1202            (
1203                "resource_names",
1204                "GET",
1205                "/data/api/v1/resources/names/{module}/{type}",
1206                ResponseShape::Json,
1207                "C-list",
1208            ),
1209            (
1210                "resource_list",
1211                "GET",
1212                "/data/api/v1/resources/list/{module}/{type}",
1213                ResponseShape::Json,
1214                "C-list",
1215            ),
1216            (
1217                "resource_find",
1218                "GET",
1219                "/data/api/v1/resources/find/{module}/{type}/{name}",
1220                ResponseShape::Json,
1221                "C3a",
1222            ),
1223            (
1224                "resource_singleton",
1225                "GET",
1226                "/data/api/v1/resources/singleton/{module}/{type}",
1227                ResponseShape::Json,
1228                "C-list",
1229            ),
1230            (
1231                "resource_create",
1232                "POST",
1233                "/data/api/v1/resources/{module}/{type}",
1234                ResponseShape::Json,
1235                "C3a",
1236            ),
1237            (
1238                "resource_update",
1239                "PUT",
1240                "/data/api/v1/resources/{module}/{type}",
1241                ResponseShape::Json,
1242                "C3a",
1243            ),
1244            (
1245                "scan_lock_acquire",
1246                "POST",
1247                "/data/api/v1/scan-lock/{kind}",
1248                ResponseShape::EmptyOk,
1249                "C1b",
1250            ),
1251            (
1252                "scan_lock_info",
1253                "GET",
1254                "/data/api/v1/scan-lock/{kind}",
1255                ResponseShape::JsonOrNoContent,
1256                "C1c",
1257            ),
1258            (
1259                "scan_status",
1260                "GET",
1261                "/data/api/v1/scan/{kind}",
1262                ResponseShape::Json,
1263                "C1i",
1264            ),
1265            (
1266                "scan_request",
1267                "POST",
1268                "/data/api/v1/scan/{kind}",
1269                ResponseShape::EmptyOk,
1270                "C1i",
1271            ),
1272            (
1273                "modes",
1274                "GET",
1275                "/data/api/v1/mode",
1276                ResponseShape::Json,
1277                "C-mode",
1278            ),
1279            (
1280                "mode_create",
1281                "POST",
1282                "/data/api/v1/mode",
1283                ResponseShape::EmptyOk,
1284                "C-mode",
1285            ),
1286            (
1287                "deployment_mode",
1288                "GET",
1289                "/data/api/v1/gateway-info",
1290                ResponseShape::Json,
1291                "C-null",
1292            ),
1293            (
1294                "restart_pending",
1295                "GET",
1296                "/data/api/v1/restart-tasks/pending",
1297                ResponseShape::Json,
1298                "C-restart",
1299            ),
1300            (
1301                "redundancy_resync",
1302                "POST",
1303                "/data/api/v1/redundancy/gwaction/resync",
1304                ResponseShape::EmptyOk,
1305                "C-P3",
1306            ),
1307            (
1308                "secret_names",
1309                "GET",
1310                "/data/api/v1/secret-providers/{provider}/secrets",
1311                ResponseShape::Json,
1312                "C-P7",
1313            ),
1314        ];
1315        assert_eq!(DEPLOY_ENDPOINTS.len(), expected.len());
1316        for (id, method, template, shape, capture) in expected {
1317            let row = endpoint_decl(id).unwrap_or_else(|| panic!("row {id}"));
1318            assert_eq!(row.method, method, "{id}");
1319            assert_eq!(row.path_template, template, "{id}");
1320            assert_eq!(row.shape, shape, "{id}");
1321            assert_eq!(row.capture_ref, capture, "{id}");
1322        }
1323        assert!(endpoint_decl("nope").is_none());
1324    }
1325
1326    /// Every id a method of this file asks for resolves.
1327    #[test]
1328    fn every_method_id_is_declared() {
1329        for id in [
1330            "resource_names",
1331            "resource_list",
1332            "resource_find",
1333            "resource_singleton",
1334            "resource_create",
1335            "resource_update",
1336            "scan_lock_acquire",
1337            "scan_lock_info",
1338            "scan_status",
1339            "scan_request",
1340            "modes",
1341            "mode_create",
1342            "deployment_mode",
1343            "restart_pending",
1344            "redundancy_resync",
1345            "secret_names",
1346        ] {
1347            assert!(decl(id).is_ok(), "{id}");
1348        }
1349    }
1350
1351    /// DAPI-06: the table is closed. Sixteen rows, in trait declaration
1352    /// order, unique ids, every row backed by a capture block, every verb
1353    /// one of GET, POST and PUT.
1354    #[test]
1355    fn deploy_endpoints_cover_every_method() {
1356        let ids: Vec<&str> = DEPLOY_ENDPOINTS.iter().map(|row| row.id).collect();
1357        assert_eq!(
1358            ids,
1359            [
1360                "resource_names",
1361                "resource_list",
1362                "resource_find",
1363                "resource_singleton",
1364                "resource_create",
1365                "resource_update",
1366                "scan_lock_acquire",
1367                "scan_lock_info",
1368                "scan_status",
1369                "scan_request",
1370                "modes",
1371                "mode_create",
1372                "deployment_mode",
1373                "restart_pending",
1374                "redundancy_resync",
1375                "secret_names",
1376            ]
1377        );
1378        assert_eq!(DEPLOY_ENDPOINTS.len(), 16);
1379        let unique: std::collections::BTreeSet<&str> = ids.iter().copied().collect();
1380        assert_eq!(unique.len(), ids.len(), "ids are unique");
1381        for row in DEPLOY_ENDPOINTS {
1382            assert!(!row.capture_ref.is_empty(), "{}", row.id);
1383            assert!(
1384                matches!(row.method, "GET" | "POST" | "PUT"),
1385                "{} uses {}",
1386                row.id,
1387                row.method
1388            );
1389        }
1390    }
1391
1392    /// C-restart: the recorded bodies. An empty list, and the entry the rig
1393    /// produced after a module was disabled (`Module <State>: <module id>`).
1394    #[tokio::test]
1395    async fn restart_pending_parses_the_captured_shape() {
1396        use wiremock::matchers::{method, path};
1397        use wiremock::{Mock, MockServer, ResponseTemplate};
1398
1399        let pending = "/data/api/v1/restart-tasks/pending";
1400        let one = r#"{"pending":["Module Disabled: com.inductiveautomation.sfc"]}"#;
1401        let cases = [
1402            (
1403                ResponseTemplate::new(200).set_body_string(r#"{"pending":[]}"#),
1404                Ok(vec![]),
1405            ),
1406            (
1407                ResponseTemplate::new(200).set_body_string(one),
1408                Ok(vec![
1409                    "Module Disabled: com.inductiveautomation.sfc".to_string(),
1410                ]),
1411            ),
1412            (ResponseTemplate::new(403), Err("auth")),
1413            (ResponseTemplate::new(401), Err("auth")),
1414            (
1415                ResponseTemplate::new(200).set_body_string("{}"),
1416                Err("shape"),
1417            ),
1418            (
1419                ResponseTemplate::new(200).set_body_string(r#"{"pending":null}"#),
1420                Err("shape"),
1421            ),
1422        ];
1423        for (answer, want) in cases {
1424            let server = MockServer::start().await;
1425            Mock::given(method("GET"))
1426                .and(path(pending))
1427                .respond_with(answer)
1428                .expect(1)
1429                .mount(&server)
1430                .await;
1431            let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
1432            let got = api.restart_pending().await;
1433            match (got, want) {
1434                (Ok(list), Ok(expected)) => assert_eq!(list, expected),
1435                (Err(CoreError::Auth { .. }), Err("auth")) => {}
1436                (Err(CoreError::Internal(_)), Err("shape")) => {}
1437                (got, want) => panic!("got {got:?}, wanted {want:?}"),
1438            }
1439        }
1440    }
1441
1442    /// A 403 keeps its status in the typed error.
1443    #[tokio::test]
1444    async fn restart_pending_403_is_auth_with_the_status() {
1445        use wiremock::matchers::{method, path};
1446        use wiremock::{Mock, MockServer, ResponseTemplate};
1447
1448        let server = MockServer::start().await;
1449        Mock::given(method("GET"))
1450            .and(path("/data/api/v1/restart-tasks/pending"))
1451            .respond_with(ResponseTemplate::new(403))
1452            .mount(&server)
1453            .await;
1454        let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
1455        let err = api.restart_pending().await.expect_err("403");
1456        assert!(
1457            matches!(err, CoreError::Auth { status: 403, .. }),
1458            "{err:?}"
1459        );
1460    }
1461
1462    /// T-20-15: the client waits at least as long as the gateway may.
1463    #[test]
1464    fn acquire_request_timeout_is_acquire_plus_slack() {
1465        assert_eq!(acquire_request_timeout(10), Duration::from_secs(20));
1466        assert_eq!(acquire_request_timeout(0), ACQUIRE_TIMEOUT_SLACK);
1467        assert_eq!(ACQUIRE_TIMEOUT_SLACK, Duration::from_secs(10));
1468    }
1469}