use crate::version::observed_input_is_supported;
use crate::{
CapabilityCatalogueEntry, Diagnostic, DiagnosticCode, LibpodHeaders, LibpodMethod, LibpodPath, LibpodRequest,
LibpodTransport, ObservedApiVersion, ObservedPodmanVersion, PodmanLensResult, TargetProfile,
};
pub const MAX_PROBE_JSON_BYTES: usize = 65_536;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ServiceObservation {
engine_version: ObservedPodmanVersion,
api_version: ObservedApiVersion,
input_capability: CapabilityCatalogueEntry,
output_target_profile: Option<TargetProfile>,
}
impl ServiceObservation {
#[must_use]
pub fn engine_version(&self) -> &ObservedPodmanVersion {
&self.engine_version
}
#[must_use]
pub fn api_version(&self) -> &ObservedApiVersion {
&self.api_version
}
#[must_use]
pub fn input_capability(&self) -> &CapabilityCatalogueEntry {
&self.input_capability
}
#[must_use]
pub fn output_target_profile(&self) -> Option<&TargetProfile> {
self.output_target_profile.as_ref()
}
}
pub async fn probe_libpod_service(transport: &dyn LibpodTransport) -> PodmanLensResult<ServiceObservation> {
let ping = request(LibpodPath::parse("/libpod/_ping")?)?;
let ping_response = transport
.send(&ping)
.await
.map_err(|error| error.diagnostic().clone())?;
require_ok_status(ping_response.status())?;
let api_version = ping_api_version(ping_response.headers())?;
let version = request(version_probe_path(&api_version)?)?;
let version_response = transport
.send(&version)
.await
.map_err(|error| error.diagnostic().clone())?;
require_ok_status(version_response.status())?;
require_json_content_type(version_response.headers())?;
let engine_version = engine_version(version_response.body())?;
let input_capability = observed_input_is_supported(&engine_version, &api_version)?;
let output_target_profile = TargetProfile::new(engine_version.clone(), api_version.clone()).ok();
Ok(ServiceObservation {
engine_version,
api_version,
input_capability,
output_target_profile,
})
}
fn version_probe_path(api_version: &ObservedApiVersion) -> PodmanLensResult<LibpodPath> {
let version = if api_version.as_semver().major == 3 {
api_version.original()
} else {
"4.0.0"
};
LibpodPath::parse(format!("/v{version}/libpod/version"))
}
fn request(path: LibpodPath) -> PodmanLensResult<LibpodRequest> {
LibpodRequest::new(LibpodMethod::Get, path, Vec::new())
}
fn require_ok_status(status: u16) -> PodmanLensResult<()> {
if status == 200 {
Ok(())
} else {
Err(Diagnostic::new(DiagnosticCode::ProbeHttpStatus))
}
}
fn ping_api_version(headers: &LibpodHeaders) -> PodmanLensResult<ObservedApiVersion> {
let values = headers.values("libpod-api-version").collect::<Vec<_>>();
let [value] = values.as_slice() else {
return Err(Diagnostic::new(DiagnosticCode::ProbeHeader));
};
ObservedApiVersion::parse_reported(value).map_err(|_| Diagnostic::new(DiagnosticCode::ProbeHeader))
}
fn require_json_content_type(headers: &LibpodHeaders) -> PodmanLensResult<()> {
let values = headers.values("content-type").collect::<Vec<_>>();
let [value] = values.as_slice() else {
return Err(Diagnostic::new(DiagnosticCode::ProbeHeader));
};
let Some(media_type) = value.split(';').next() else {
return Err(Diagnostic::new(DiagnosticCode::ProbeHeader));
};
if media_type.trim().eq_ignore_ascii_case("application/json") {
Ok(())
} else {
Err(Diagnostic::new(DiagnosticCode::ProbeHeader))
}
}
fn engine_version(body: &[u8]) -> PodmanLensResult<ObservedPodmanVersion> {
if body.len() > MAX_PROBE_JSON_BYTES {
return Err(Diagnostic::new(DiagnosticCode::ProbeJson));
}
let value: serde_json::Value =
serde_json::from_slice(body).map_err(|_| Diagnostic::new(DiagnosticCode::ProbeJson))?;
let Some(root) = value.as_object() else {
return Err(Diagnostic::new(DiagnosticCode::ProbeShape));
};
let Some(components) = root.get("Components") else {
return Err(Diagnostic::new(DiagnosticCode::ProbeShape));
};
let Some(components) = components.as_array() else {
return Err(Diagnostic::new(DiagnosticCode::ProbeShape));
};
let mut engine = None;
for component in components {
let Some(component) = component.as_object() else {
continue;
};
if component.get("Name").and_then(serde_json::Value::as_str) != Some("Podman Engine") {
continue;
}
let Some(version) = component.get("Version").and_then(serde_json::Value::as_str) else {
return Err(Diagnostic::new(DiagnosticCode::ProbeComponent));
};
if engine.replace(version).is_some() {
return Err(Diagnostic::new(DiagnosticCode::ProbeComponent));
}
}
let Some(engine) = engine else {
return Err(Diagnostic::new(DiagnosticCode::ProbeComponent));
};
ObservedPodmanVersion::parse_reported(engine).map_err(|_| Diagnostic::new(DiagnosticCode::ProbeComponent))
}
#[cfg(test)]
mod tests {
use super::{MAX_PROBE_JSON_BYTES, engine_version, require_json_content_type, version_probe_path};
use crate::{DiagnosticCode, LibpodHeader, LibpodHeaders, ObservedApiVersion};
#[test]
fn probe_json_is_bounded_before_decode() -> Result<(), Box<dyn std::error::Error>> {
let body = vec![b' '; MAX_PROBE_JSON_BYTES + 1];
let error = engine_version(&body)
.err()
.ok_or_else(|| std::io::Error::other("oversized probe JSON was unexpectedly accepted"))?;
assert_eq!(error.code(), DiagnosticCode::ProbeJson);
Ok(())
}
#[test]
fn content_type_is_case_insensitive_and_allows_parameters() -> Result<(), Box<dyn std::error::Error>> {
let headers = LibpodHeaders::new(vec![LibpodHeader::new(
"Content-Type",
"Application/Json; charset=utf-8",
)?]);
assert!(require_json_content_type(&headers).is_ok());
Ok(())
}
#[test]
fn legacy_probe_uses_the_advertised_podman_three_api_path() -> Result<(), Box<dyn std::error::Error>> {
assert_eq!(
version_probe_path(&ObservedApiVersion::parse("3.0.0")?)?.as_str(),
"/v3.0.0/libpod/version"
);
assert_eq!(
version_probe_path(&ObservedApiVersion::parse("3.4.4")?)?.as_str(),
"/v3.4.4/libpod/version"
);
assert_eq!(
version_probe_path(&ObservedApiVersion::parse("4.9.4")?)?.as_str(),
"/v4.0.0/libpod/version"
);
Ok(())
}
}