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 async fn send_shaped(
438 &self,
439 decl: &EndpointDecl,
440 request: reqwest::RequestBuilder,
441 url: &url::Url,
442 ) -> Result<ShapedBody, CoreError> {
443 let response = self.send_and_classify(request, url).await?;
444 let status = response.status();
445 if decl.shape == ResponseShape::EmptyOk {
446 return Ok(ShapedBody::Empty);
447 }
448 if decl.shape == ResponseShape::JsonOrNoContent && status == reqwest::StatusCode::NO_CONTENT
450 {
451 return Ok(ShapedBody::NoContent);
452 }
453 let text = response.text().await.map_err(|err| CoreError::Network {
454 url: url.to_string(),
455 source: Some(err),
456 observation: None,
457 })?;
458 if text.trim().is_empty() {
459 return Err(CoreError::Internal(format!(
460 "endpoint {} answered HTTP {status} from {url} with an empty body, \
461 but its declared shape is {:?}",
462 decl.id, decl.shape
463 )));
464 }
465 Ok(ShapedBody::Json(text))
466 }
467
468 async fn read_resource_json<T: DeserializeOwned>(
471 &self,
472 id: &'static str,
473 path: &str,
474 pairs: &[(String, String)],
475 ) -> Result<T, CoreError> {
476 let decl = decl(id)?;
477 let url = self.url_for(path);
478 let mut request = self.client.get(url.clone());
479 if !pairs.is_empty() {
480 request = request.query(pairs);
481 }
482 let request = self.apply_auth(request);
483 match self.send_shaped(decl, request, &url).await? {
484 ShapedBody::Json(text) => parse_json(decl, &url, &text),
485 ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
486 "endpoint {} produced no body",
487 decl.id
488 ))),
489 }
490 }
491
492 async fn send_resource_write(
511 &self,
512 decl: &EndpointDecl,
513 request: reqwest::RequestBuilder,
514 url: &url::Url,
515 names: &[&str],
516 ) -> Result<MutationOutcome, CoreError> {
517 let network = |err: reqwest::Error| CoreError::Network {
518 url: url.to_string(),
519 source: Some(err),
520 observation: None,
521 };
522 let response = request.send().await.map_err(network)?;
523 let status = response.status();
524 if status == reqwest::StatusCode::INTERNAL_SERVER_ERROR {
525 let body = response.text().await.unwrap_or_default();
526 if problem_has_marker(&body) {
527 return Err(signature_rejected(
528 names,
529 SignatureReason::Stale,
530 url,
531 Some(&body),
532 ));
533 }
534 return Err(CoreError::Internal(format!(
535 "unexpected HTTP {status} from {url} (the gateway's problem text is withheld)"
536 )));
537 }
538 if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
539 let body = response.text().await.unwrap_or_default();
540 if names_signature_field(&body) {
541 return Err(signature_rejected(
542 names,
543 SignatureReason::Missing,
544 url,
545 None,
546 ));
547 }
548 return Err(classify::resource_body_rejected(url.as_str(), &body));
549 }
550 if status == reqwest::StatusCode::CONFLICT && decl.id == "resource_create" {
554 let body = response.text().await.unwrap_or_default();
555 return Err(CoreError::ResourceExists {
556 name: resource_name_for(names, Some(&body)),
557 endpoint: Some(url.to_string()),
558 });
559 }
560 let response = classify::classify(response, url.as_str(), false).await?;
561 let text = response.text().await.map_err(network)?;
562 if text.trim().is_empty() {
563 return Err(CoreError::Internal(format!(
564 "endpoint {} answered HTTP {status} from {url} with an empty body, \
565 but its declared shape is {:?}",
566 decl.id, decl.shape
567 )));
568 }
569 let outcome: MutationOutcome = parse_json(decl, url, &text)?;
570 if outcome.success {
571 return Ok(outcome);
572 }
573 let stale = outcome
574 .problem
575 .as_ref()
576 .is_some_and(|problem| problem.message.contains(SIGNATURE_MARKER));
577 if stale {
578 let message = outcome.problem.map(|p| p.message);
579 return Err(signature_rejected(
580 names,
581 SignatureReason::Stale,
582 url,
583 message.as_deref(),
584 ));
585 }
586 Err(CoreError::Internal(format!(
587 "endpoint {} answered HTTP {status} from {url} with success:false \
588 (the gateway's problem text is withheld)",
589 decl.id
590 )))
591 }
592}
593
594const SIGNATURE_MARKER: &str = "signature mismatch";
599
600fn problem_has_marker(body: &str) -> bool {
603 serde_json::from_str::<MutationOutcome>(body)
604 .ok()
605 .and_then(|outcome| outcome.problem)
606 .is_some_and(|problem| problem.message.contains(SIGNATURE_MARKER))
607}
608
609fn names_signature_field(body: &str) -> bool {
612 serde_json::from_str::<serde_json::Value>(body)
613 .ok()
614 .and_then(|parsed| parsed["fieldMessages"].as_array().cloned())
615 .is_some_and(|fields| {
616 fields
617 .iter()
618 .any(|field| field["fieldName"].as_str() == Some("signature"))
619 })
620}
621
622fn resource_name_for(names: &[&str], message: Option<&str>) -> String {
626 let from_message = message.and_then(|message| {
627 names
628 .iter()
629 .find(|name| message.contains(&format!("/{name},")))
630 .map(|name| (*name).to_string())
631 });
632 from_message.unwrap_or_else(|| match names {
633 [only] => (*only).to_string(),
634 _ => format!("<batch of {}>", names.len()),
635 })
636}
637
638fn signature_rejected(
642 names: &[&str],
643 reason: SignatureReason,
644 url: &url::Url,
645 message: Option<&str>,
646) -> CoreError {
647 CoreError::ResourceSignatureRejected {
648 name: resource_name_for(names, message),
649 reason,
650 endpoint: Some(url.to_string()),
651 }
652}
653
654fn parse_json<T: DeserializeOwned>(
657 decl: &EndpointDecl,
658 url: &url::Url,
659 text: &str,
660) -> Result<T, CoreError> {
661 serde_json::from_str(text).map_err(|err| {
662 CoreError::Internal(format!(
663 "endpoint {} answered from {url} with a body that did not match the \
664 expected shape: {err}",
665 decl.id
666 ))
667 })
668}
669
670#[async_trait::async_trait]
671impl DeployApi for ReqwestGatewayApi {
672 async fn resource_names(
673 &self,
674 module: &str,
675 ty: &str,
676 ) -> Result<ListEnvelope<ResourceName>, CoreError> {
677 let path = resource_names_path(module, ty)?;
678 self.read_resource_json("resource_names", &path, &[]).await
679 }
680
681 async fn resource_list(
682 &self,
683 module: &str,
684 ty: &str,
685 q: &ListQuery,
686 ) -> Result<ListEnvelope<ResourceRecord>, CoreError> {
687 let path = resource_list_path(module, ty)?;
688 let pairs = list_query_pairs(q)?;
689 self.read_resource_json("resource_list", &path, &pairs)
690 .await
691 }
692
693 async fn resource_find(
694 &self,
695 module: &str,
696 ty: &str,
697 name: &str,
698 collection: Option<&str>,
699 ) -> Result<ResourceRecord, CoreError> {
700 let path = resource_find_path(module, ty, name)?;
701 self.read_resource_json("resource_find", &path, &collection_pairs(collection))
702 .await
703 }
704
705 async fn resource_singleton(
706 &self,
707 module: &str,
708 ty: &str,
709 collection: Option<&str>,
710 ) -> Result<ResourceRecord, CoreError> {
711 let path = resource_singleton_path(module, ty)?;
712 self.read_resource_json("resource_singleton", &path, &collection_pairs(collection))
713 .await
714 }
715
716 async fn resource_create(
717 &self,
718 module: &str,
719 ty: &str,
720 items: &[ResourceCreate],
721 ) -> Result<MutationOutcome, CoreError> {
722 let path = resource_write_path(module, ty)?;
723 if items.is_empty() {
724 return Err(CoreError::InvalidInput {
725 reason: "resource create needs at least one item".to_string(),
726 });
727 }
728 if items.iter().any(|item| item.name.is_empty()) {
729 return Err(CoreError::InvalidInput {
730 reason: "resource name must not be empty".to_string(),
731 });
732 }
733 let decl = decl("resource_create")?;
734 let url = self.url_for(&path);
735 let request = self.apply_auth(self.client.post(url.clone()).json(items));
736 let names: Vec<&str> = items.iter().map(|item| item.name.as_str()).collect();
737 self.send_resource_write(decl, request, &url, &names).await
738 }
739
740 async fn resource_update(
741 &self,
742 module: &str,
743 ty: &str,
744 items: &[ResourceUpdate],
745 ) -> Result<MutationOutcome, CoreError> {
746 let path = resource_write_path(module, ty)?;
747 if items.is_empty() {
748 return Err(CoreError::InvalidInput {
749 reason: "resource update needs at least one item".to_string(),
750 });
751 }
752 let decl = decl("resource_update")?;
753 let url = self.url_for(&path);
754 let request = self.apply_auth(self.client.put(url.clone()).json(items));
755 let names: Vec<&str> = items.iter().map(|item| item.name.as_str()).collect();
756 self.send_resource_write(decl, request, &url, &names).await
757 }
758
759 async fn scan_lock_acquire(
760 &self,
761 kind: ScanKind,
762 acquire_s: u64,
763 hold_s: u64,
764 ) -> Result<(), CoreError> {
765 let decl = decl("scan_lock_acquire")?;
766 let url = self.url_for(&scan_lock_path(kind));
767 let request = self.apply_auth(
768 self.client
769 .post(url.clone())
770 .json(&LockRequest {
771 acquire_timeout: acquire_s,
772 hold_timeout: hold_s,
773 })
774 .timeout(acquire_request_timeout(acquire_s)),
775 );
776 match self.send_shaped(decl, request, &url).await {
777 Ok(_) => Ok(()),
778 Err(CoreError::ScanLockHeld {
782 actor: None,
783 kind: held_kind,
784 endpoint,
785 }) => {
786 let actor = match self.scan_lock_info(kind).await {
787 Ok(LockObservation::Held(info)) => info
788 .actor
789 .map(|actor| actor.chars().take(ACTOR_CAP).collect::<String>()),
790 _ => None,
791 };
792 Err(CoreError::ScanLockHeld {
793 actor,
794 kind: held_kind,
795 endpoint,
796 })
797 }
798 Err(other) => Err(other),
799 }
800 }
801
802 async fn scan_lock_info(&self, kind: ScanKind) -> Result<LockObservation, CoreError> {
803 let decl = decl("scan_lock_info")?;
804 let url = self.url_for(&scan_lock_path(kind));
805 let request = self.apply_auth(self.client.get(url.clone()));
806 match self.send_shaped(decl, request, &url).await? {
807 ShapedBody::NoContent => Ok(LockObservation::Free),
808 ShapedBody::Json(text) => {
809 parse_json::<ScanLockInfo>(decl, &url, &text).map(LockObservation::Held)
810 }
811 ShapedBody::Empty => Err(CoreError::Internal(format!(
812 "endpoint {} produced no body shape",
813 decl.id
814 ))),
815 }
816 }
817
818 async fn scan_status(&self, kind: ScanKind) -> Result<ScanStatus, CoreError> {
819 let decl = decl("scan_status")?;
820 let url = self.url_for(&scan_path(kind));
821 let request = self.apply_auth(self.client.get(url.clone()));
822 match self.send_shaped(decl, request, &url).await? {
823 ShapedBody::Json(text) => parse_json(decl, &url, &text),
824 ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
825 "endpoint {} produced no body",
826 decl.id
827 ))),
828 }
829 }
830
831 async fn scan_request(&self, kind: ScanKind) -> Result<(), CoreError> {
832 let decl = decl("scan_request")?;
833 let url = self.url_for(&scan_path(kind));
834 let request = self.apply_auth(self.client.post(url.clone()));
835 self.send_shaped(decl, request, &url).await.map(|_| ())
836 }
837
838 async fn modes(&self) -> Result<ListEnvelope<ModeRecord>, CoreError> {
839 let decl = decl("modes")?;
840 let url = self.url_for(MODE_PATH);
841 let request = self.apply_auth(
842 self.client
843 .get(url.clone())
844 .query(&ListQuery::default().to_query_pairs()),
845 );
846 match self.send_shaped(decl, request, &url).await? {
847 ShapedBody::Json(text) => parse_json(decl, &url, &text),
848 ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
849 "endpoint {} produced no body",
850 decl.id
851 ))),
852 }
853 }
854
855 async fn mode_create(&self, body: &ModeCreate) -> Result<ModeRecord, CoreError> {
856 validate_segment("mode name", &body.name)?;
857 decl("mode_create")?;
858 let url = self.url_for(MODE_PATH);
859 let request = self.apply_auth(self.client.post(url.clone()).json(body));
860 let response = request.send().await.map_err(|err| CoreError::Network {
865 url: url.to_string(),
866 source: Some(err),
867 observation: None,
868 })?;
869 match response.status() {
870 reqwest::StatusCode::CONFLICT => {
871 return Err(CoreError::ModeExists {
872 name: body.name.clone(),
873 endpoint: Some(url.to_string()),
874 });
875 }
876 reqwest::StatusCode::BAD_REQUEST => {
877 return Err(CoreError::InvalidInput {
878 reason: format!(
879 "gateway rejected the deployment mode name {:?} (HTTP 400 from {url})",
880 body.name
881 ),
882 });
883 }
884 _ => {}
885 }
886 classify::classify(response, url.as_str(), false).await?;
888 let listed = self.modes().await?;
890 listed
891 .items
892 .into_iter()
893 .find(|mode| mode.name == body.name)
894 .ok_or_else(|| {
895 CoreError::Internal(format!(
896 "mode_create answered 2xx from {url} but mode {:?} is not in the \
897 follow-up mode list",
898 body.name
899 ))
900 })
901 }
902
903 async fn deployment_mode(&self) -> Result<Option<String>, CoreError> {
904 let decl = decl("deployment_mode")?;
905 let url = self.url_for(GATEWAY_INFO_PATH);
906 let request = self.apply_auth(self.client.get(url.clone()));
907 match self.send_shaped(decl, request, &url).await? {
908 ShapedBody::Json(text) => {
909 let probe: GatewayModeProbe = parse_json(decl, &url, &text)?;
910 Ok(normalize_deployment_mode(probe.deployment_mode))
911 }
912 ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
913 "endpoint {} produced no body",
914 decl.id
915 ))),
916 }
917 }
918
919 async fn restart_pending(&self) -> Result<Vec<String>, CoreError> {
920 let decl = decl("restart_pending")?;
921 let url = self.url_for(RESTART_PENDING_PATH);
922 let request = self.apply_auth(self.client.get(url.clone()));
923 match self.send_shaped(decl, request, &url).await? {
924 ShapedBody::Json(text) => {
925 let body: RestartPendingBody = parse_json(decl, &url, &text)?;
926 Ok(body.pending)
927 }
928 ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
929 "endpoint {} produced no body",
930 decl.id
931 ))),
932 }
933 }
934
935 async fn redundancy_resync(&self) -> Result<(), CoreError> {
936 let decl = decl("redundancy_resync")?;
937 let url = self.url_for(REDUNDANCY_RESYNC_PATH);
938 let request = self.apply_auth(self.client.post(url.clone()));
940 self.send_shaped(decl, request, &url).await.map(|_| ())
941 }
942
943 async fn secret_names(&self, provider: &str) -> Result<SecretNameListing, CoreError> {
944 validate_segment("provider", provider)?;
945 let decl = decl("secret_names")?;
946 let url = self.url_for(&secret_names_path(provider));
947 let request = self.apply_auth(self.client.get(url.clone()));
948 let response = request.send().await.map_err(|err| CoreError::Network {
949 url: url.to_string(),
950 source: Some(err),
951 observation: None,
952 })?;
953 match response.status() {
958 reqwest::StatusCode::NOT_FOUND => return Ok(SecretNameListing::NoProvider),
959 reqwest::StatusCode::INTERNAL_SERVER_ERROR | reqwest::StatusCode::NOT_IMPLEMENTED => {
960 return Ok(SecretNameListing::CannotList);
961 }
962 _ => {}
963 }
964 let response = classify::classify(response, url.as_str(), false).await?;
965 let text = response.text().await.map_err(|err| CoreError::Network {
966 url: url.to_string(),
967 source: Some(err),
968 observation: None,
969 })?;
970 if text.trim().is_empty() {
971 return Err(CoreError::Internal(format!(
972 "endpoint {} answered from {url} with an empty body, but its declared \
973 shape is {:?}",
974 decl.id, decl.shape
975 )));
976 }
977 let body: SecretNamesBody = parse_json(decl, &url, &text)?;
978 Ok(SecretNameListing::Listed(
979 body.secrets.into_iter().collect(),
980 ))
981 }
982}
983
984const REDUNDANCY_RESYNC_PATH: &str = "/data/api/v1/redundancy/gwaction/resync";
986
987fn secret_names_path(provider: &str) -> String {
990 format!("/data/api/v1/secret-providers/{provider}/secrets")
991}
992
993fn collection_pairs(collection: Option<&str>) -> Vec<(String, String)> {
995 collection
996 .map(|c| vec![("collection".to_string(), c.to_string())])
997 .unwrap_or_default()
998}
999
1000pub async fn resource_list_in_collection(
1016 api: &dyn DeployApi,
1017 module: &str,
1018 ty: &str,
1019 collection: &str,
1020) -> Result<Vec<ResourceRecord>, CoreError> {
1021 if collection.is_empty() {
1022 return Err(CoreError::InvalidInput {
1023 reason: "resource collection must not be empty".to_string(),
1024 });
1025 }
1026 let names = api.resource_names(module, ty).await?;
1027 let mut records = Vec::new();
1028 for item in names.items {
1029 if !item.modes.is_empty() && !item.modes.iter().any(|m| m == collection) {
1030 continue;
1031 }
1032 match api
1033 .resource_find(module, ty, &item.name, Some(collection))
1034 .await
1035 {
1036 Ok(record) => {
1037 if record
1038 .collection
1039 .as_deref()
1040 .is_none_or(|held| held == collection)
1041 {
1042 records.push(record);
1043 }
1044 }
1045 Err(CoreError::NotFound { .. }) => {}
1046 Err(other) => return Err(other),
1047 }
1048 }
1049 Ok(records)
1050}
1051
1052#[derive(Clone, Copy)]
1054struct WaitCtx<'a> {
1055 api: &'a dyn DeployApi,
1056 kind: ScanKind,
1057 started: Instant,
1058 budget: Duration,
1059 before: Option<&'a ScanStatus>,
1061}
1062
1063enum Waited {
1065 Reached(ScanStatus),
1067 OutOfBudget(ScanStatus),
1069}
1070
1071pub async fn scan_and_wait(
1088 api: &dyn DeployApi,
1089 kind: ScanKind,
1090 cfg: PollConfig,
1091) -> Result<ScanOutcome, CoreError> {
1092 let started = Instant::now();
1093 let budget = cfg.deadline;
1094 let backstop = budget.saturating_add(cfg.interval.saturating_mul(2));
1095 let poll_cfg = PollConfig {
1096 deadline: backstop,
1097 ..cfg
1098 };
1099
1100 let mut ctx = WaitCtx {
1103 api,
1104 kind,
1105 started,
1106 budget,
1107 before: None,
1108 };
1109
1110 let before = match poll::poll(poll_cfg.clone(), ctx, |ctx| {
1112 Box::pin(async move {
1113 let status = ctx.api.scan_status(ctx.kind).await?;
1114 if !status.scan_active {
1115 return Ok(PollState::Done(Waited::Reached(status)));
1116 }
1117 if ctx.started.elapsed() >= ctx.budget {
1118 return Ok(PollState::Done(Waited::OutOfBudget(status)));
1119 }
1120 Ok(PollState::Pending(Some(
1121 "a scan is already active — waiting it out before triggering".to_string(),
1122 )))
1123 })
1124 })
1125 .await?
1126 {
1127 Waited::Reached(before) => before,
1128 Waited::OutOfBudget(last) => {
1129 return Ok(ScanOutcome::NotComplete {
1130 last,
1131 waited: started.elapsed(),
1132 });
1133 }
1134 };
1135
1136 api.scan_request(kind).await?;
1138
1139 ctx = WaitCtx {
1141 before: Some(&before),
1142 ..ctx
1143 };
1144 let outcome = poll::poll(poll_cfg, ctx, |ctx| {
1145 Box::pin(async move {
1146 let now = ctx.api.scan_status(ctx.kind).await?;
1147 let Some(before) = ctx.before else {
1148 return Err(CoreError::Internal(
1149 "scan wait lost its pre-trigger reading".to_string(),
1150 ));
1151 };
1152 if scan_complete(before, &now) {
1153 return Ok(PollState::Done(Waited::Reached(now)));
1154 }
1155 if ctx.started.elapsed() >= ctx.budget {
1156 return Ok(PollState::Done(Waited::OutOfBudget(now)));
1157 }
1158 Ok(PollState::Pending(Some(format!(
1159 "scanActive {} lastScanTimestamp {} (before {})",
1160 now.scan_active, now.last_scan_timestamp, before.last_scan_timestamp
1161 ))))
1162 })
1163 })
1164 .await?;
1165 Ok(match outcome {
1166 Waited::Reached(now) => ScanOutcome::Complete(now),
1167 Waited::OutOfBudget(last) => ScanOutcome::NotComplete {
1168 last,
1169 waited: started.elapsed(),
1170 },
1171 })
1172}
1173
1174#[cfg(test)]
1175mod tests {
1176 use super::*;
1177
1178 #[test]
1180 fn endpoint_rows_match_the_capture_table() {
1181 let expected: [(&str, &str, &str, ResponseShape, &str); 16] = [
1182 (
1183 "resource_names",
1184 "GET",
1185 "/data/api/v1/resources/names/{module}/{type}",
1186 ResponseShape::Json,
1187 "C-list",
1188 ),
1189 (
1190 "resource_list",
1191 "GET",
1192 "/data/api/v1/resources/list/{module}/{type}",
1193 ResponseShape::Json,
1194 "C-list",
1195 ),
1196 (
1197 "resource_find",
1198 "GET",
1199 "/data/api/v1/resources/find/{module}/{type}/{name}",
1200 ResponseShape::Json,
1201 "C3a",
1202 ),
1203 (
1204 "resource_singleton",
1205 "GET",
1206 "/data/api/v1/resources/singleton/{module}/{type}",
1207 ResponseShape::Json,
1208 "C-list",
1209 ),
1210 (
1211 "resource_create",
1212 "POST",
1213 "/data/api/v1/resources/{module}/{type}",
1214 ResponseShape::Json,
1215 "C3a",
1216 ),
1217 (
1218 "resource_update",
1219 "PUT",
1220 "/data/api/v1/resources/{module}/{type}",
1221 ResponseShape::Json,
1222 "C3a",
1223 ),
1224 (
1225 "scan_lock_acquire",
1226 "POST",
1227 "/data/api/v1/scan-lock/{kind}",
1228 ResponseShape::EmptyOk,
1229 "C1b",
1230 ),
1231 (
1232 "scan_lock_info",
1233 "GET",
1234 "/data/api/v1/scan-lock/{kind}",
1235 ResponseShape::JsonOrNoContent,
1236 "C1c",
1237 ),
1238 (
1239 "scan_status",
1240 "GET",
1241 "/data/api/v1/scan/{kind}",
1242 ResponseShape::Json,
1243 "C1i",
1244 ),
1245 (
1246 "scan_request",
1247 "POST",
1248 "/data/api/v1/scan/{kind}",
1249 ResponseShape::EmptyOk,
1250 "C1i",
1251 ),
1252 (
1253 "modes",
1254 "GET",
1255 "/data/api/v1/mode",
1256 ResponseShape::Json,
1257 "C-mode",
1258 ),
1259 (
1260 "mode_create",
1261 "POST",
1262 "/data/api/v1/mode",
1263 ResponseShape::EmptyOk,
1264 "C-mode",
1265 ),
1266 (
1267 "deployment_mode",
1268 "GET",
1269 "/data/api/v1/gateway-info",
1270 ResponseShape::Json,
1271 "C-null",
1272 ),
1273 (
1274 "restart_pending",
1275 "GET",
1276 "/data/api/v1/restart-tasks/pending",
1277 ResponseShape::Json,
1278 "C-restart",
1279 ),
1280 (
1281 "redundancy_resync",
1282 "POST",
1283 "/data/api/v1/redundancy/gwaction/resync",
1284 ResponseShape::EmptyOk,
1285 "C-P3",
1286 ),
1287 (
1288 "secret_names",
1289 "GET",
1290 "/data/api/v1/secret-providers/{provider}/secrets",
1291 ResponseShape::Json,
1292 "C-P7",
1293 ),
1294 ];
1295 assert_eq!(DEPLOY_ENDPOINTS.len(), expected.len());
1296 for (id, method, template, shape, capture) in expected {
1297 let row = endpoint_decl(id).unwrap_or_else(|| panic!("row {id}"));
1298 assert_eq!(row.method, method, "{id}");
1299 assert_eq!(row.path_template, template, "{id}");
1300 assert_eq!(row.shape, shape, "{id}");
1301 assert_eq!(row.capture_ref, capture, "{id}");
1302 }
1303 assert!(endpoint_decl("nope").is_none());
1304 }
1305
1306 #[test]
1308 fn every_method_id_is_declared() {
1309 for id in [
1310 "resource_names",
1311 "resource_list",
1312 "resource_find",
1313 "resource_singleton",
1314 "resource_create",
1315 "resource_update",
1316 "scan_lock_acquire",
1317 "scan_lock_info",
1318 "scan_status",
1319 "scan_request",
1320 "modes",
1321 "mode_create",
1322 "deployment_mode",
1323 "restart_pending",
1324 "redundancy_resync",
1325 "secret_names",
1326 ] {
1327 assert!(decl(id).is_ok(), "{id}");
1328 }
1329 }
1330
1331 #[test]
1335 fn deploy_endpoints_cover_every_method() {
1336 let ids: Vec<&str> = DEPLOY_ENDPOINTS.iter().map(|row| row.id).collect();
1337 assert_eq!(
1338 ids,
1339 [
1340 "resource_names",
1341 "resource_list",
1342 "resource_find",
1343 "resource_singleton",
1344 "resource_create",
1345 "resource_update",
1346 "scan_lock_acquire",
1347 "scan_lock_info",
1348 "scan_status",
1349 "scan_request",
1350 "modes",
1351 "mode_create",
1352 "deployment_mode",
1353 "restart_pending",
1354 "redundancy_resync",
1355 "secret_names",
1356 ]
1357 );
1358 assert_eq!(DEPLOY_ENDPOINTS.len(), 16);
1359 let unique: std::collections::BTreeSet<&str> = ids.iter().copied().collect();
1360 assert_eq!(unique.len(), ids.len(), "ids are unique");
1361 for row in DEPLOY_ENDPOINTS {
1362 assert!(!row.capture_ref.is_empty(), "{}", row.id);
1363 assert!(
1364 matches!(row.method, "GET" | "POST" | "PUT"),
1365 "{} uses {}",
1366 row.id,
1367 row.method
1368 );
1369 }
1370 }
1371
1372 #[tokio::test]
1375 async fn restart_pending_parses_the_captured_shape() {
1376 use wiremock::matchers::{method, path};
1377 use wiremock::{Mock, MockServer, ResponseTemplate};
1378
1379 let pending = "/data/api/v1/restart-tasks/pending";
1380 let one = r#"{"pending":["Module Disabled: com.inductiveautomation.sfc"]}"#;
1381 let cases = [
1382 (
1383 ResponseTemplate::new(200).set_body_string(r#"{"pending":[]}"#),
1384 Ok(vec![]),
1385 ),
1386 (
1387 ResponseTemplate::new(200).set_body_string(one),
1388 Ok(vec![
1389 "Module Disabled: com.inductiveautomation.sfc".to_string(),
1390 ]),
1391 ),
1392 (ResponseTemplate::new(403), Err("auth")),
1393 (ResponseTemplate::new(401), Err("auth")),
1394 (
1395 ResponseTemplate::new(200).set_body_string("{}"),
1396 Err("shape"),
1397 ),
1398 (
1399 ResponseTemplate::new(200).set_body_string(r#"{"pending":null}"#),
1400 Err("shape"),
1401 ),
1402 ];
1403 for (answer, want) in cases {
1404 let server = MockServer::start().await;
1405 Mock::given(method("GET"))
1406 .and(path(pending))
1407 .respond_with(answer)
1408 .expect(1)
1409 .mount(&server)
1410 .await;
1411 let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
1412 let got = api.restart_pending().await;
1413 match (got, want) {
1414 (Ok(list), Ok(expected)) => assert_eq!(list, expected),
1415 (Err(CoreError::Auth { .. }), Err("auth")) => {}
1416 (Err(CoreError::Internal(_)), Err("shape")) => {}
1417 (got, want) => panic!("got {got:?}, wanted {want:?}"),
1418 }
1419 }
1420 }
1421
1422 #[tokio::test]
1424 async fn restart_pending_403_is_auth_with_the_status() {
1425 use wiremock::matchers::{method, path};
1426 use wiremock::{Mock, MockServer, ResponseTemplate};
1427
1428 let server = MockServer::start().await;
1429 Mock::given(method("GET"))
1430 .and(path("/data/api/v1/restart-tasks/pending"))
1431 .respond_with(ResponseTemplate::new(403))
1432 .mount(&server)
1433 .await;
1434 let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
1435 let err = api.restart_pending().await.expect_err("403");
1436 assert!(
1437 matches!(err, CoreError::Auth { status: 403, .. }),
1438 "{err:?}"
1439 );
1440 }
1441
1442 #[test]
1444 fn acquire_request_timeout_is_acquire_plus_slack() {
1445 assert_eq!(acquire_request_timeout(10), Duration::from_secs(20));
1446 assert_eq!(acquire_request_timeout(0), ACQUIRE_TIMEOUT_SLACK);
1447 assert_eq!(ACQUIRE_TIMEOUT_SLACK, Duration::from_secs(10));
1448 }
1449}