1use 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
46pub const ACQUIRE_TIMEOUT_SLACK: Duration = Duration::from_secs(10);
51
52const ACTOR_CAP: usize = 256;
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum ResponseShape {
58 Json,
60 EmptyOk,
62 JsonOrNoContent,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct EndpointDecl {
71 pub id: &'static str,
73 pub method: &'static str,
75 pub path_template: &'static str,
79 pub shape: ResponseShape,
81 pub capture_ref: &'static str,
83}
84
85pub 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#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum SecretNameListing {
209 Listed(BTreeSet<String>),
211 NoProvider,
213 CannotList,
216}
217
218pub fn endpoint_decl(id: &str) -> Option<&'static EndpointDecl> {
220 DEPLOY_ENDPOINTS.iter().find(|decl| decl.id == id)
221}
222
223fn decl(id: &'static str) -> Result<&'static EndpointDecl, CoreError> {
227 endpoint_decl(id).ok_or_else(|| CoreError::Internal(format!("no endpoint declaration {id}")))
228}
229
230fn acquire_request_timeout(acquire_s: u64) -> Duration {
232 Duration::from_secs(acquire_s).saturating_add(ACQUIRE_TIMEOUT_SLACK)
233}
234
235#[async_trait::async_trait]
237pub trait DeployApi: Send + Sync {
238 async fn resource_names(
243 &self,
244 module: &str,
245 ty: &str,
246 ) -> Result<ListEnvelope<ResourceName>, CoreError>;
247
248 async fn resource_list(
256 &self,
257 module: &str,
258 ty: &str,
259 q: &ListQuery,
260 ) -> Result<ListEnvelope<ResourceRecord>, CoreError>;
261
262 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 async fn resource_singleton(
277 &self,
278 module: &str,
279 ty: &str,
280 collection: Option<&str>,
281 ) -> Result<ResourceRecord, CoreError>;
282
283 async fn resource_create(
295 &self,
296 module: &str,
297 ty: &str,
298 items: &[ResourceCreate],
299 ) -> Result<MutationOutcome, CoreError>;
300
301 async fn resource_update(
320 &self,
321 module: &str,
322 ty: &str,
323 items: &[ResourceUpdate],
324 ) -> Result<MutationOutcome, CoreError>;
325
326 async fn scan_lock_acquire(
335 &self,
336 kind: ScanKind,
337 acquire_s: u64,
338 hold_s: u64,
339 ) -> Result<(), CoreError>;
340
341 async fn scan_lock_info(&self, kind: ScanKind) -> Result<LockObservation, CoreError>;
344
345 async fn scan_status(&self, kind: ScanKind) -> Result<ScanStatus, CoreError>;
347
348 async fn scan_request(&self, kind: ScanKind) -> Result<(), CoreError>;
355
356 async fn modes(&self) -> Result<ListEnvelope<ModeRecord>, CoreError>;
361
362 async fn mode_create(&self, body: &ModeCreate) -> Result<ModeRecord, CoreError>;
372
373 async fn deployment_mode(&self) -> Result<Option<String>, CoreError>;
378
379 async fn restart_pending(&self) -> Result<Vec<String>, CoreError>;
390
391 async fn redundancy_resync(&self) -> Result<(), CoreError>;
400
401 async fn secret_names(&self, provider: &str) -> Result<SecretNameListing, CoreError>;
409}
410
411#[derive(Deserialize)]
413struct RestartPendingBody {
414 pending: Vec<String>,
415}
416
417#[derive(Deserialize)]
419struct SecretNamesBody {
420 secrets: Vec<String>,
421}
422
423enum ShapedBody {
425 Json(String),
427 Empty,
429 NoContent,
431}
432
433impl ReqwestGatewayApi {
434 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 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 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 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 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 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
614const SIGNATURE_MARKER: &str = "signature mismatch";
619
620fn 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
629fn 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
642fn 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
658fn 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
674fn 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 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 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 classify::classify(response, url.as_str(), false).await?;
908 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 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 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
1004const REDUNDANCY_RESYNC_PATH: &str = "/data/api/v1/redundancy/gwaction/resync";
1006
1007fn secret_names_path(provider: &str) -> String {
1010 format!("/data/api/v1/secret-providers/{provider}/secrets")
1011}
1012
1013fn 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
1020pub 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#[derive(Clone, Copy)]
1074struct WaitCtx<'a> {
1075 api: &'a dyn DeployApi,
1076 kind: ScanKind,
1077 started: Instant,
1078 budget: Duration,
1079 before: Option<&'a ScanStatus>,
1081}
1082
1083enum Waited {
1085 Reached(ScanStatus),
1087 OutOfBudget(ScanStatus),
1089}
1090
1091pub 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 let mut ctx = WaitCtx {
1123 api,
1124 kind,
1125 started,
1126 budget,
1127 before: None,
1128 };
1129
1130 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 api.scan_request(kind).await?;
1158
1159 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 #[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 #[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 #[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 #[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 #[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 #[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}