use std::collections::BTreeSet;
use std::time::{Duration, Instant};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use crate::client::config_resources::{
MutationOutcome, ResourceCreate, ResourceName, ResourceRecord, ResourceUpdate,
list_query_pairs, resource_find_path, resource_list_path, resource_names_path,
resource_singleton_path, resource_write_path, validate_segment,
};
use crate::client::mode::{
GatewayModeProbe, MODE_PATH, ModeCreate, ModeRecord, normalize_deployment_mode,
};
use crate::client::query::{ListEnvelope, ListQuery};
use crate::client::restart::RESTART_PENDING_PATH;
use crate::client::scan::{
LockObservation, LockRequest, ScanKind, ScanLockInfo, ScanOutcome, ScanStatus, scan_complete,
scan_lock_path, scan_path,
};
use crate::client::{GATEWAY_INFO_PATH, ReqwestGatewayApi, classify};
use crate::error::{CoreError, SignatureReason};
use crate::poll::{self, PollConfig, PollState};
pub const ACQUIRE_TIMEOUT_SLACK: Duration = Duration::from_secs(10);
const ACTOR_CAP: usize = 256;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResponseShape {
Json,
EmptyOk,
JsonOrNoContent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EndpointDecl {
pub id: &'static str,
pub method: &'static str,
pub path_template: &'static str,
pub shape: ResponseShape,
pub capture_ref: &'static str,
}
pub const DEPLOY_ENDPOINTS: &[EndpointDecl] = &[
EndpointDecl {
id: "resource_names",
method: "GET",
path_template: "/data/api/v1/resources/names/{module}/{type}",
shape: ResponseShape::Json,
capture_ref: "C-list",
},
EndpointDecl {
id: "resource_list",
method: "GET",
path_template: "/data/api/v1/resources/list/{module}/{type}",
shape: ResponseShape::Json,
capture_ref: "C-list",
},
EndpointDecl {
id: "resource_find",
method: "GET",
path_template: "/data/api/v1/resources/find/{module}/{type}/{name}",
shape: ResponseShape::Json,
capture_ref: "C3a",
},
EndpointDecl {
id: "resource_singleton",
method: "GET",
path_template: "/data/api/v1/resources/singleton/{module}/{type}",
shape: ResponseShape::Json,
capture_ref: "C-list",
},
EndpointDecl {
id: "resource_create",
method: "POST",
path_template: "/data/api/v1/resources/{module}/{type}",
shape: ResponseShape::Json,
capture_ref: "C3a",
},
EndpointDecl {
id: "resource_update",
method: "PUT",
path_template: "/data/api/v1/resources/{module}/{type}",
shape: ResponseShape::Json,
capture_ref: "C3a",
},
EndpointDecl {
id: "scan_lock_acquire",
method: "POST",
path_template: "/data/api/v1/scan-lock/{kind}",
shape: ResponseShape::EmptyOk,
capture_ref: "C1b",
},
EndpointDecl {
id: "scan_lock_info",
method: "GET",
path_template: "/data/api/v1/scan-lock/{kind}",
shape: ResponseShape::JsonOrNoContent,
capture_ref: "C1c",
},
EndpointDecl {
id: "scan_status",
method: "GET",
path_template: "/data/api/v1/scan/{kind}",
shape: ResponseShape::Json,
capture_ref: "C1i",
},
EndpointDecl {
id: "scan_request",
method: "POST",
path_template: "/data/api/v1/scan/{kind}",
shape: ResponseShape::EmptyOk,
capture_ref: "C1i",
},
EndpointDecl {
id: "modes",
method: "GET",
path_template: "/data/api/v1/mode",
shape: ResponseShape::Json,
capture_ref: "C-mode",
},
EndpointDecl {
id: "mode_create",
method: "POST",
path_template: "/data/api/v1/mode",
shape: ResponseShape::EmptyOk,
capture_ref: "C-mode",
},
EndpointDecl {
id: "deployment_mode",
method: "GET",
path_template: "/data/api/v1/gateway-info",
shape: ResponseShape::Json,
capture_ref: "C-null",
},
EndpointDecl {
id: "restart_pending",
method: "GET",
path_template: "/data/api/v1/restart-tasks/pending",
shape: ResponseShape::Json,
capture_ref: "C-restart",
},
EndpointDecl {
id: "redundancy_resync",
method: "POST",
path_template: "/data/api/v1/redundancy/gwaction/resync",
shape: ResponseShape::EmptyOk,
capture_ref: "C-P3",
},
EndpointDecl {
id: "secret_names",
method: "GET",
path_template: "/data/api/v1/secret-providers/{provider}/secrets",
shape: ResponseShape::Json,
capture_ref: "C-P7",
},
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SecretNameListing {
Listed(BTreeSet<String>),
NoProvider,
CannotList,
}
pub fn endpoint_decl(id: &str) -> Option<&'static EndpointDecl> {
DEPLOY_ENDPOINTS.iter().find(|decl| decl.id == id)
}
fn decl(id: &'static str) -> Result<&'static EndpointDecl, CoreError> {
endpoint_decl(id).ok_or_else(|| CoreError::Internal(format!("no endpoint declaration {id}")))
}
fn acquire_request_timeout(acquire_s: u64) -> Duration {
Duration::from_secs(acquire_s).saturating_add(ACQUIRE_TIMEOUT_SLACK)
}
#[async_trait::async_trait]
pub trait DeployApi: Send + Sync {
async fn resource_names(
&self,
module: &str,
ty: &str,
) -> Result<ListEnvelope<ResourceName>, CoreError>;
async fn resource_list(
&self,
module: &str,
ty: &str,
q: &ListQuery,
) -> Result<ListEnvelope<ResourceRecord>, CoreError>;
async fn resource_find(
&self,
module: &str,
ty: &str,
name: &str,
collection: Option<&str>,
) -> Result<ResourceRecord, CoreError>;
async fn resource_singleton(
&self,
module: &str,
ty: &str,
collection: Option<&str>,
) -> Result<ResourceRecord, CoreError>;
async fn resource_create(
&self,
module: &str,
ty: &str,
items: &[ResourceCreate],
) -> Result<MutationOutcome, CoreError>;
async fn resource_update(
&self,
module: &str,
ty: &str,
items: &[ResourceUpdate],
) -> Result<MutationOutcome, CoreError>;
async fn scan_lock_acquire(
&self,
kind: ScanKind,
acquire_s: u64,
hold_s: u64,
) -> Result<(), CoreError>;
async fn scan_lock_info(&self, kind: ScanKind) -> Result<LockObservation, CoreError>;
async fn scan_status(&self, kind: ScanKind) -> Result<ScanStatus, CoreError>;
async fn scan_request(&self, kind: ScanKind) -> Result<(), CoreError>;
async fn modes(&self) -> Result<ListEnvelope<ModeRecord>, CoreError>;
async fn mode_create(&self, body: &ModeCreate) -> Result<ModeRecord, CoreError>;
async fn deployment_mode(&self) -> Result<Option<String>, CoreError>;
async fn restart_pending(&self) -> Result<Vec<String>, CoreError>;
async fn redundancy_resync(&self) -> Result<(), CoreError>;
async fn secret_names(&self, provider: &str) -> Result<SecretNameListing, CoreError>;
}
#[derive(Deserialize)]
struct RestartPendingBody {
pending: Vec<String>,
}
#[derive(Deserialize)]
struct SecretNamesBody {
secrets: Vec<String>,
}
enum ShapedBody {
Json(String),
Empty,
NoContent,
}
impl ReqwestGatewayApi {
async fn send_shaped(
&self,
decl: &EndpointDecl,
request: reqwest::RequestBuilder,
url: &url::Url,
) -> Result<ShapedBody, CoreError> {
let response = self.send_and_classify(request, url).await?;
let status = response.status();
if decl.shape == ResponseShape::EmptyOk {
return Ok(ShapedBody::Empty);
}
if decl.shape == ResponseShape::JsonOrNoContent && status == reqwest::StatusCode::NO_CONTENT
{
return Ok(ShapedBody::NoContent);
}
let text = response.text().await.map_err(|err| CoreError::Network {
url: url.to_string(),
source: Some(err),
observation: None,
})?;
if text.trim().is_empty() {
return Err(CoreError::Internal(format!(
"endpoint {} answered HTTP {status} from {url} with an empty body, \
but its declared shape is {:?}",
decl.id, decl.shape
)));
}
Ok(ShapedBody::Json(text))
}
async fn read_resource_json<T: DeserializeOwned>(
&self,
id: &'static str,
path: &str,
pairs: &[(String, String)],
) -> Result<T, CoreError> {
let decl = decl(id)?;
let url = self.url_for(path);
let mut request = self.client.get(url.clone());
if !pairs.is_empty() {
request = request.query(pairs);
}
let request = self.apply_auth(request);
match self.send_shaped(decl, request, &url).await? {
ShapedBody::Json(text) => parse_json(decl, &url, &text),
ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
"endpoint {} produced no body",
decl.id
))),
}
}
async fn send_resource_write(
&self,
decl: &EndpointDecl,
request: reqwest::RequestBuilder,
url: &url::Url,
names: &[&str],
) -> Result<MutationOutcome, CoreError> {
let network = |err: reqwest::Error| CoreError::Network {
url: url.to_string(),
source: Some(err),
observation: None,
};
let response = request.send().await.map_err(network)?;
let status = response.status();
if status == reqwest::StatusCode::INTERNAL_SERVER_ERROR {
let body = response.text().await.unwrap_or_default();
if problem_has_marker(&body) {
return Err(signature_rejected(
names,
SignatureReason::Stale,
url,
Some(&body),
));
}
return Err(CoreError::Internal(format!(
"unexpected HTTP {status} from {url} (the gateway's problem text is withheld)"
)));
}
if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY {
let body = response.text().await.unwrap_or_default();
if names_signature_field(&body) {
return Err(signature_rejected(
names,
SignatureReason::Missing,
url,
None,
));
}
return Err(classify::resource_body_rejected(url.as_str(), &body));
}
if status == reqwest::StatusCode::CONFLICT && decl.id == "resource_create" {
let body = response.text().await.unwrap_or_default();
return Err(CoreError::ResourceExists {
name: resource_name_for(names, Some(&body)),
endpoint: Some(url.to_string()),
});
}
let response = classify::classify(response, url.as_str(), false).await?;
let text = response.text().await.map_err(network)?;
if text.trim().is_empty() {
return Err(CoreError::Internal(format!(
"endpoint {} answered HTTP {status} from {url} with an empty body, \
but its declared shape is {:?}",
decl.id, decl.shape
)));
}
let outcome: MutationOutcome = parse_json(decl, url, &text)?;
if outcome.success {
return Ok(outcome);
}
let stale = outcome
.problem
.as_ref()
.is_some_and(|problem| problem.message.contains(SIGNATURE_MARKER));
if stale {
let message = outcome.problem.map(|p| p.message);
return Err(signature_rejected(
names,
SignatureReason::Stale,
url,
message.as_deref(),
));
}
Err(CoreError::Internal(format!(
"endpoint {} answered HTTP {status} from {url} with success:false \
(the gateway's problem text is withheld)",
decl.id
)))
}
}
const SIGNATURE_MARKER: &str = "signature mismatch";
fn problem_has_marker(body: &str) -> bool {
serde_json::from_str::<MutationOutcome>(body)
.ok()
.and_then(|outcome| outcome.problem)
.is_some_and(|problem| problem.message.contains(SIGNATURE_MARKER))
}
fn names_signature_field(body: &str) -> bool {
serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|parsed| parsed["fieldMessages"].as_array().cloned())
.is_some_and(|fields| {
fields
.iter()
.any(|field| field["fieldName"].as_str() == Some("signature"))
})
}
fn resource_name_for(names: &[&str], message: Option<&str>) -> String {
let from_message = message.and_then(|message| {
names
.iter()
.find(|name| message.contains(&format!("/{name},")))
.map(|name| (*name).to_string())
});
from_message.unwrap_or_else(|| match names {
[only] => (*only).to_string(),
_ => format!("<batch of {}>", names.len()),
})
}
fn signature_rejected(
names: &[&str],
reason: SignatureReason,
url: &url::Url,
message: Option<&str>,
) -> CoreError {
CoreError::ResourceSignatureRejected {
name: resource_name_for(names, message),
reason,
endpoint: Some(url.to_string()),
}
}
fn parse_json<T: DeserializeOwned>(
decl: &EndpointDecl,
url: &url::Url,
text: &str,
) -> Result<T, CoreError> {
serde_json::from_str(text).map_err(|err| {
CoreError::Internal(format!(
"endpoint {} answered from {url} with a body that did not match the \
expected shape: {err}",
decl.id
))
})
}
#[async_trait::async_trait]
impl DeployApi for ReqwestGatewayApi {
async fn resource_names(
&self,
module: &str,
ty: &str,
) -> Result<ListEnvelope<ResourceName>, CoreError> {
let path = resource_names_path(module, ty)?;
self.read_resource_json("resource_names", &path, &[]).await
}
async fn resource_list(
&self,
module: &str,
ty: &str,
q: &ListQuery,
) -> Result<ListEnvelope<ResourceRecord>, CoreError> {
let path = resource_list_path(module, ty)?;
let pairs = list_query_pairs(q)?;
self.read_resource_json("resource_list", &path, &pairs)
.await
}
async fn resource_find(
&self,
module: &str,
ty: &str,
name: &str,
collection: Option<&str>,
) -> Result<ResourceRecord, CoreError> {
let path = resource_find_path(module, ty, name)?;
self.read_resource_json("resource_find", &path, &collection_pairs(collection))
.await
}
async fn resource_singleton(
&self,
module: &str,
ty: &str,
collection: Option<&str>,
) -> Result<ResourceRecord, CoreError> {
let path = resource_singleton_path(module, ty)?;
self.read_resource_json("resource_singleton", &path, &collection_pairs(collection))
.await
}
async fn resource_create(
&self,
module: &str,
ty: &str,
items: &[ResourceCreate],
) -> Result<MutationOutcome, CoreError> {
let path = resource_write_path(module, ty)?;
if items.is_empty() {
return Err(CoreError::InvalidInput {
reason: "resource create needs at least one item".to_string(),
});
}
if items.iter().any(|item| item.name.is_empty()) {
return Err(CoreError::InvalidInput {
reason: "resource name must not be empty".to_string(),
});
}
let decl = decl("resource_create")?;
let url = self.url_for(&path);
let request = self.apply_auth(self.client.post(url.clone()).json(items));
let names: Vec<&str> = items.iter().map(|item| item.name.as_str()).collect();
self.send_resource_write(decl, request, &url, &names).await
}
async fn resource_update(
&self,
module: &str,
ty: &str,
items: &[ResourceUpdate],
) -> Result<MutationOutcome, CoreError> {
let path = resource_write_path(module, ty)?;
if items.is_empty() {
return Err(CoreError::InvalidInput {
reason: "resource update needs at least one item".to_string(),
});
}
let decl = decl("resource_update")?;
let url = self.url_for(&path);
let request = self.apply_auth(self.client.put(url.clone()).json(items));
let names: Vec<&str> = items.iter().map(|item| item.name.as_str()).collect();
self.send_resource_write(decl, request, &url, &names).await
}
async fn scan_lock_acquire(
&self,
kind: ScanKind,
acquire_s: u64,
hold_s: u64,
) -> Result<(), CoreError> {
let decl = decl("scan_lock_acquire")?;
let url = self.url_for(&scan_lock_path(kind));
let request = self.apply_auth(
self.client
.post(url.clone())
.json(&LockRequest {
acquire_timeout: acquire_s,
hold_timeout: hold_s,
})
.timeout(acquire_request_timeout(acquire_s)),
);
match self.send_shaped(decl, request, &url).await {
Ok(_) => Ok(()),
Err(CoreError::ScanLockHeld {
actor: None,
kind: held_kind,
endpoint,
}) => {
let actor = match self.scan_lock_info(kind).await {
Ok(LockObservation::Held(info)) => info
.actor
.map(|actor| actor.chars().take(ACTOR_CAP).collect::<String>()),
_ => None,
};
Err(CoreError::ScanLockHeld {
actor,
kind: held_kind,
endpoint,
})
}
Err(other) => Err(other),
}
}
async fn scan_lock_info(&self, kind: ScanKind) -> Result<LockObservation, CoreError> {
let decl = decl("scan_lock_info")?;
let url = self.url_for(&scan_lock_path(kind));
let request = self.apply_auth(self.client.get(url.clone()));
match self.send_shaped(decl, request, &url).await? {
ShapedBody::NoContent => Ok(LockObservation::Free),
ShapedBody::Json(text) => {
parse_json::<ScanLockInfo>(decl, &url, &text).map(LockObservation::Held)
}
ShapedBody::Empty => Err(CoreError::Internal(format!(
"endpoint {} produced no body shape",
decl.id
))),
}
}
async fn scan_status(&self, kind: ScanKind) -> Result<ScanStatus, CoreError> {
let decl = decl("scan_status")?;
let url = self.url_for(&scan_path(kind));
let request = self.apply_auth(self.client.get(url.clone()));
match self.send_shaped(decl, request, &url).await? {
ShapedBody::Json(text) => parse_json(decl, &url, &text),
ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
"endpoint {} produced no body",
decl.id
))),
}
}
async fn scan_request(&self, kind: ScanKind) -> Result<(), CoreError> {
let decl = decl("scan_request")?;
let url = self.url_for(&scan_path(kind));
let request = self.apply_auth(self.client.post(url.clone()));
self.send_shaped(decl, request, &url).await.map(|_| ())
}
async fn modes(&self) -> Result<ListEnvelope<ModeRecord>, CoreError> {
let decl = decl("modes")?;
let url = self.url_for(MODE_PATH);
let request = self.apply_auth(
self.client
.get(url.clone())
.query(&ListQuery::default().to_query_pairs()),
);
match self.send_shaped(decl, request, &url).await? {
ShapedBody::Json(text) => parse_json(decl, &url, &text),
ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
"endpoint {} produced no body",
decl.id
))),
}
}
async fn mode_create(&self, body: &ModeCreate) -> Result<ModeRecord, CoreError> {
validate_segment("mode name", &body.name)?;
decl("mode_create")?;
let url = self.url_for(MODE_PATH);
let request = self.apply_auth(self.client.post(url.clone()).json(body));
let response = request.send().await.map_err(|err| CoreError::Network {
url: url.to_string(),
source: Some(err),
observation: None,
})?;
match response.status() {
reqwest::StatusCode::CONFLICT => {
return Err(CoreError::ModeExists {
name: body.name.clone(),
endpoint: Some(url.to_string()),
});
}
reqwest::StatusCode::BAD_REQUEST => {
return Err(CoreError::InvalidInput {
reason: format!(
"gateway rejected the deployment mode name {:?} (HTTP 400 from {url})",
body.name
),
});
}
_ => {}
}
classify::classify(response, url.as_str(), false).await?;
let listed = self.modes().await?;
listed
.items
.into_iter()
.find(|mode| mode.name == body.name)
.ok_or_else(|| {
CoreError::Internal(format!(
"mode_create answered 2xx from {url} but mode {:?} is not in the \
follow-up mode list",
body.name
))
})
}
async fn deployment_mode(&self) -> Result<Option<String>, CoreError> {
let decl = decl("deployment_mode")?;
let url = self.url_for(GATEWAY_INFO_PATH);
let request = self.apply_auth(self.client.get(url.clone()));
match self.send_shaped(decl, request, &url).await? {
ShapedBody::Json(text) => {
let probe: GatewayModeProbe = parse_json(decl, &url, &text)?;
Ok(normalize_deployment_mode(probe.deployment_mode))
}
ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
"endpoint {} produced no body",
decl.id
))),
}
}
async fn restart_pending(&self) -> Result<Vec<String>, CoreError> {
let decl = decl("restart_pending")?;
let url = self.url_for(RESTART_PENDING_PATH);
let request = self.apply_auth(self.client.get(url.clone()));
match self.send_shaped(decl, request, &url).await? {
ShapedBody::Json(text) => {
let body: RestartPendingBody = parse_json(decl, &url, &text)?;
Ok(body.pending)
}
ShapedBody::Empty | ShapedBody::NoContent => Err(CoreError::Internal(format!(
"endpoint {} produced no body",
decl.id
))),
}
}
async fn redundancy_resync(&self) -> Result<(), CoreError> {
let decl = decl("redundancy_resync")?;
let url = self.url_for(REDUNDANCY_RESYNC_PATH);
let request = self.apply_auth(self.client.post(url.clone()));
self.send_shaped(decl, request, &url).await.map(|_| ())
}
async fn secret_names(&self, provider: &str) -> Result<SecretNameListing, CoreError> {
validate_segment("provider", provider)?;
let decl = decl("secret_names")?;
let url = self.url_for(&secret_names_path(provider));
let request = self.apply_auth(self.client.get(url.clone()));
let response = request.send().await.map_err(|err| CoreError::Network {
url: url.to_string(),
source: Some(err),
observation: None,
})?;
match response.status() {
reqwest::StatusCode::NOT_FOUND => return Ok(SecretNameListing::NoProvider),
reqwest::StatusCode::INTERNAL_SERVER_ERROR | reqwest::StatusCode::NOT_IMPLEMENTED => {
return Ok(SecretNameListing::CannotList);
}
_ => {}
}
let response = classify::classify(response, url.as_str(), false).await?;
let text = response.text().await.map_err(|err| CoreError::Network {
url: url.to_string(),
source: Some(err),
observation: None,
})?;
if text.trim().is_empty() {
return Err(CoreError::Internal(format!(
"endpoint {} answered from {url} with an empty body, but its declared \
shape is {:?}",
decl.id, decl.shape
)));
}
let body: SecretNamesBody = parse_json(decl, &url, &text)?;
Ok(SecretNameListing::Listed(
body.secrets.into_iter().collect(),
))
}
}
const REDUNDANCY_RESYNC_PATH: &str = "/data/api/v1/redundancy/gwaction/resync";
fn secret_names_path(provider: &str) -> String {
format!("/data/api/v1/secret-providers/{provider}/secrets")
}
fn collection_pairs(collection: Option<&str>) -> Vec<(String, String)> {
collection
.map(|c| vec![("collection".to_string(), c.to_string())])
.unwrap_or_default()
}
pub async fn resource_list_in_collection(
api: &dyn DeployApi,
module: &str,
ty: &str,
collection: &str,
) -> Result<Vec<ResourceRecord>, CoreError> {
if collection.is_empty() {
return Err(CoreError::InvalidInput {
reason: "resource collection must not be empty".to_string(),
});
}
let names = api.resource_names(module, ty).await?;
let mut records = Vec::new();
for item in names.items {
if !item.modes.is_empty() && !item.modes.iter().any(|m| m == collection) {
continue;
}
match api
.resource_find(module, ty, &item.name, Some(collection))
.await
{
Ok(record) => {
if record
.collection
.as_deref()
.is_none_or(|held| held == collection)
{
records.push(record);
}
}
Err(CoreError::NotFound { .. }) => {}
Err(other) => return Err(other),
}
}
Ok(records)
}
#[derive(Clone, Copy)]
struct WaitCtx<'a> {
api: &'a dyn DeployApi,
kind: ScanKind,
started: Instant,
budget: Duration,
before: Option<&'a ScanStatus>,
}
enum Waited {
Reached(ScanStatus),
OutOfBudget(ScanStatus),
}
pub async fn scan_and_wait(
api: &dyn DeployApi,
kind: ScanKind,
cfg: PollConfig,
) -> Result<ScanOutcome, CoreError> {
let started = Instant::now();
let budget = cfg.deadline;
let backstop = budget.saturating_add(cfg.interval.saturating_mul(2));
let poll_cfg = PollConfig {
deadline: backstop,
..cfg
};
let mut ctx = WaitCtx {
api,
kind,
started,
budget,
before: None,
};
let before = match poll::poll(poll_cfg.clone(), ctx, |ctx| {
Box::pin(async move {
let status = ctx.api.scan_status(ctx.kind).await?;
if !status.scan_active {
return Ok(PollState::Done(Waited::Reached(status)));
}
if ctx.started.elapsed() >= ctx.budget {
return Ok(PollState::Done(Waited::OutOfBudget(status)));
}
Ok(PollState::Pending(Some(
"a scan is already active — waiting it out before triggering".to_string(),
)))
})
})
.await?
{
Waited::Reached(before) => before,
Waited::OutOfBudget(last) => {
return Ok(ScanOutcome::NotComplete {
last,
waited: started.elapsed(),
});
}
};
api.scan_request(kind).await?;
ctx = WaitCtx {
before: Some(&before),
..ctx
};
let outcome = poll::poll(poll_cfg, ctx, |ctx| {
Box::pin(async move {
let now = ctx.api.scan_status(ctx.kind).await?;
let Some(before) = ctx.before else {
return Err(CoreError::Internal(
"scan wait lost its pre-trigger reading".to_string(),
));
};
if scan_complete(before, &now) {
return Ok(PollState::Done(Waited::Reached(now)));
}
if ctx.started.elapsed() >= ctx.budget {
return Ok(PollState::Done(Waited::OutOfBudget(now)));
}
Ok(PollState::Pending(Some(format!(
"scanActive {} lastScanTimestamp {} (before {})",
now.scan_active, now.last_scan_timestamp, before.last_scan_timestamp
))))
})
})
.await?;
Ok(match outcome {
Waited::Reached(now) => ScanOutcome::Complete(now),
Waited::OutOfBudget(last) => ScanOutcome::NotComplete {
last,
waited: started.elapsed(),
},
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn endpoint_rows_match_the_capture_table() {
let expected: [(&str, &str, &str, ResponseShape, &str); 16] = [
(
"resource_names",
"GET",
"/data/api/v1/resources/names/{module}/{type}",
ResponseShape::Json,
"C-list",
),
(
"resource_list",
"GET",
"/data/api/v1/resources/list/{module}/{type}",
ResponseShape::Json,
"C-list",
),
(
"resource_find",
"GET",
"/data/api/v1/resources/find/{module}/{type}/{name}",
ResponseShape::Json,
"C3a",
),
(
"resource_singleton",
"GET",
"/data/api/v1/resources/singleton/{module}/{type}",
ResponseShape::Json,
"C-list",
),
(
"resource_create",
"POST",
"/data/api/v1/resources/{module}/{type}",
ResponseShape::Json,
"C3a",
),
(
"resource_update",
"PUT",
"/data/api/v1/resources/{module}/{type}",
ResponseShape::Json,
"C3a",
),
(
"scan_lock_acquire",
"POST",
"/data/api/v1/scan-lock/{kind}",
ResponseShape::EmptyOk,
"C1b",
),
(
"scan_lock_info",
"GET",
"/data/api/v1/scan-lock/{kind}",
ResponseShape::JsonOrNoContent,
"C1c",
),
(
"scan_status",
"GET",
"/data/api/v1/scan/{kind}",
ResponseShape::Json,
"C1i",
),
(
"scan_request",
"POST",
"/data/api/v1/scan/{kind}",
ResponseShape::EmptyOk,
"C1i",
),
(
"modes",
"GET",
"/data/api/v1/mode",
ResponseShape::Json,
"C-mode",
),
(
"mode_create",
"POST",
"/data/api/v1/mode",
ResponseShape::EmptyOk,
"C-mode",
),
(
"deployment_mode",
"GET",
"/data/api/v1/gateway-info",
ResponseShape::Json,
"C-null",
),
(
"restart_pending",
"GET",
"/data/api/v1/restart-tasks/pending",
ResponseShape::Json,
"C-restart",
),
(
"redundancy_resync",
"POST",
"/data/api/v1/redundancy/gwaction/resync",
ResponseShape::EmptyOk,
"C-P3",
),
(
"secret_names",
"GET",
"/data/api/v1/secret-providers/{provider}/secrets",
ResponseShape::Json,
"C-P7",
),
];
assert_eq!(DEPLOY_ENDPOINTS.len(), expected.len());
for (id, method, template, shape, capture) in expected {
let row = endpoint_decl(id).unwrap_or_else(|| panic!("row {id}"));
assert_eq!(row.method, method, "{id}");
assert_eq!(row.path_template, template, "{id}");
assert_eq!(row.shape, shape, "{id}");
assert_eq!(row.capture_ref, capture, "{id}");
}
assert!(endpoint_decl("nope").is_none());
}
#[test]
fn every_method_id_is_declared() {
for id in [
"resource_names",
"resource_list",
"resource_find",
"resource_singleton",
"resource_create",
"resource_update",
"scan_lock_acquire",
"scan_lock_info",
"scan_status",
"scan_request",
"modes",
"mode_create",
"deployment_mode",
"restart_pending",
"redundancy_resync",
"secret_names",
] {
assert!(decl(id).is_ok(), "{id}");
}
}
#[test]
fn deploy_endpoints_cover_every_method() {
let ids: Vec<&str> = DEPLOY_ENDPOINTS.iter().map(|row| row.id).collect();
assert_eq!(
ids,
[
"resource_names",
"resource_list",
"resource_find",
"resource_singleton",
"resource_create",
"resource_update",
"scan_lock_acquire",
"scan_lock_info",
"scan_status",
"scan_request",
"modes",
"mode_create",
"deployment_mode",
"restart_pending",
"redundancy_resync",
"secret_names",
]
);
assert_eq!(DEPLOY_ENDPOINTS.len(), 16);
let unique: std::collections::BTreeSet<&str> = ids.iter().copied().collect();
assert_eq!(unique.len(), ids.len(), "ids are unique");
for row in DEPLOY_ENDPOINTS {
assert!(!row.capture_ref.is_empty(), "{}", row.id);
assert!(
matches!(row.method, "GET" | "POST" | "PUT"),
"{} uses {}",
row.id,
row.method
);
}
}
#[tokio::test]
async fn restart_pending_parses_the_captured_shape() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let pending = "/data/api/v1/restart-tasks/pending";
let one = r#"{"pending":["Module Disabled: com.inductiveautomation.sfc"]}"#;
let cases = [
(
ResponseTemplate::new(200).set_body_string(r#"{"pending":[]}"#),
Ok(vec![]),
),
(
ResponseTemplate::new(200).set_body_string(one),
Ok(vec![
"Module Disabled: com.inductiveautomation.sfc".to_string(),
]),
),
(ResponseTemplate::new(403), Err("auth")),
(ResponseTemplate::new(401), Err("auth")),
(
ResponseTemplate::new(200).set_body_string("{}"),
Err("shape"),
),
(
ResponseTemplate::new(200).set_body_string(r#"{"pending":null}"#),
Err("shape"),
),
];
for (answer, want) in cases {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(pending))
.respond_with(answer)
.expect(1)
.mount(&server)
.await;
let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
let got = api.restart_pending().await;
match (got, want) {
(Ok(list), Ok(expected)) => assert_eq!(list, expected),
(Err(CoreError::Auth { .. }), Err("auth")) => {}
(Err(CoreError::Internal(_)), Err("shape")) => {}
(got, want) => panic!("got {got:?}, wanted {want:?}"),
}
}
}
#[tokio::test]
async fn restart_pending_403_is_auth_with_the_status() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/data/api/v1/restart-tasks/pending"))
.respond_with(ResponseTemplate::new(403))
.mount(&server)
.await;
let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
let err = api.restart_pending().await.expect_err("403");
assert!(
matches!(err, CoreError::Auth { status: 403, .. }),
"{err:?}"
);
}
#[test]
fn acquire_request_timeout_is_acquire_plus_slack() {
assert_eq!(acquire_request_timeout(10), Duration::from_secs(20));
assert_eq!(acquire_request_timeout(0), ACQUIRE_TIMEOUT_SLACK);
assert_eq!(ACQUIRE_TIMEOUT_SLACK, Duration::from_secs(10));
}
}