use crate::error::Error;
use crate::source::{Probe, Source, SourceKind};
use crate::sources::util::{normalize, trim_trailing_slashes};
use crate::transport::HttpTransport;
const DEFAULT_BASE_URL: &str = "http://169.254.169.254";
const METADATA_PATH: &str = "/openstack/2018-08-27/meta_data.json";
pub struct OpenStackMetadata<T> {
transport: T,
base_url: String,
}
impl<T> OpenStackMetadata<T> {
pub fn new(transport: T) -> Self {
Self::with_base_url(transport, DEFAULT_BASE_URL)
}
pub fn with_base_url(transport: T, base_url: impl Into<String>) -> Self {
Self {
transport,
base_url: trim_trailing_slashes(base_url),
}
}
}
impl<T> std::fmt::Debug for OpenStackMetadata<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OpenStackMetadata")
.field("base_url", &self.base_url)
.finish_non_exhaustive()
}
}
impl<T: HttpTransport + 'static> Source for OpenStackMetadata<T> {
fn kind(&self) -> SourceKind {
SourceKind::OpenStackMetadata
}
fn probe(&self) -> Result<Option<Probe>, Error> {
let Some(body) = fetch_metadata(&self.transport, &self.base_url) else {
return Ok(None);
};
let uuid = extract_uuid(&body).ok_or_else(|| Error::Platform {
source_kind: SourceKind::OpenStackMetadata,
reason: "meta_data.json missing top-level `uuid` field".to_owned(),
})?;
Ok(normalize(&uuid).map(|v| Probe::new(SourceKind::OpenStackMetadata, v)))
}
}
fn fetch_metadata<T: HttpTransport>(transport: &T, base_url: &str) -> Option<String> {
let request = http::Request::builder()
.method(http::Method::GET)
.uri(format!("{base_url}{METADATA_PATH}"))
.body(Vec::new())
.ok()?;
let response = transport.send(request).ok()?;
if !response.status().is_success() {
log::debug!(
"openstack-metadata: endpoint returned {}",
response.status()
);
return None;
}
std::str::from_utf8(response.body()).ok().map(str::to_owned)
}
fn extract_uuid(json: &str) -> Option<String> {
let key = "\"uuid\"";
let mut cursor = 0;
let start = loop {
let rel = json[cursor..].find(key)?;
let abs = cursor + rel;
if is_at_top_level_boundary(json, abs) {
break abs;
}
cursor = abs + key.len();
};
let after_key = &json[start + key.len()..];
let colon = after_key.find(':')?;
let after_colon = after_key[colon + 1..].trim_start();
let after_open_quote = after_colon.strip_prefix('"')?;
let close = after_open_quote.find('"')?;
Some(after_open_quote[..close].to_owned())
}
fn is_at_top_level_boundary(json: &str, key_start: usize) -> bool {
json[..key_start]
.bytes()
.rev()
.find(|b| !b.is_ascii_whitespace())
.is_some_and(|b| b == b'{' || b == b',')
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sources::cloud::test_support::{
StubTransport, ok as ok_response, status as status_response,
};
const NOVA_DOC: &str = r#"{
"random_seed": "lkWdGuiWmMWrh7ox1mQpFH1w",
"uuid": "d8e02d56-2648-49a3-bf97-6be8f1204f38",
"availability_zone": "nova",
"hostname": "test.novalocal",
"launch_index": 0,
"devices": [],
"project_id": "f7ac731cc11f40efbc03a9f9e1d1d21f",
"name": "test"
}"#;
#[test]
fn happy_path_returns_uuid_from_json() {
let stub = StubTransport::new(vec![ok_response(NOVA_DOC)]);
let source = OpenStackMetadata::new(stub);
let probe = source.probe().unwrap().expect("should produce a probe");
assert_eq!(probe.kind(), SourceKind::OpenStackMetadata);
assert_eq!(probe.value(), "d8e02d56-2648-49a3-bf97-6be8f1204f38");
}
#[test]
fn hits_expected_path() {
let (stub, transport) = StubTransport::shared(vec![ok_response(NOVA_DOC)]);
let source = OpenStackMetadata::with_base_url(transport, "http://md.test");
assert!(source.probe().unwrap().is_some());
let requests = stub.requests();
assert_eq!(requests.len(), 1);
let (ref method, ref uri, ref headers) = requests[0];
assert_eq!(method, http::Method::GET);
assert_eq!(uri, "http://md.test/openstack/2018-08-27/meta_data.json");
assert!(headers.is_empty());
}
#[test]
fn non_2xx_returns_none() {
let stub = StubTransport::new(vec![status_response(503)]);
let source = OpenStackMetadata::new(stub);
assert!(source.probe().unwrap().is_none());
}
#[test]
fn not_found_returns_none() {
let stub = StubTransport::new(vec![status_response(404)]);
let source = OpenStackMetadata::new(stub);
assert!(source.probe().unwrap().is_none());
}
#[test]
fn transport_error_returns_none() {
#[derive(Debug)]
struct FakeErr;
impl std::fmt::Display for FakeErr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("nope")
}
}
impl std::error::Error for FakeErr {}
let transport = |_req: http::Request<Vec<u8>>| -> Result<http::Response<Vec<u8>>, FakeErr> {
Err(FakeErr)
};
let source = OpenStackMetadata::new(transport);
assert!(source.probe().unwrap().is_none());
}
#[test]
fn non_utf8_body_returns_none() {
let response = http::Response::builder()
.status(200)
.body(vec![0xff, 0xfe, 0xfd])
.unwrap();
let stub = StubTransport::new(vec![response]);
let source = OpenStackMetadata::new(stub);
assert!(source.probe().unwrap().is_none());
}
#[test]
fn empty_uuid_returns_none() {
let stub = StubTransport::new(vec![ok_response(r#"{"uuid":""}"#)]);
let source = OpenStackMetadata::new(stub);
assert!(source.probe().unwrap().is_none());
}
#[test]
fn whitespace_uuid_returns_none() {
let stub = StubTransport::new(vec![ok_response(r#"{"uuid":" "}"#)]);
let source = OpenStackMetadata::new(stub);
assert!(source.probe().unwrap().is_none());
}
#[test]
fn missing_uuid_field_is_platform_error() {
let stub = StubTransport::new(vec![ok_response(r#"{"hostname":"x"}"#)]);
let source = OpenStackMetadata::new(stub);
let err = source.probe().expect_err("missing field must error");
assert!(matches!(
&err,
Error::Platform { source_kind, reason }
if *source_kind == SourceKind::OpenStackMetadata && reason.contains("uuid")
));
}
#[test]
fn extract_uuid_parses_nova_document() {
assert_eq!(
extract_uuid(NOVA_DOC).as_deref(),
Some("d8e02d56-2648-49a3-bf97-6be8f1204f38")
);
}
#[test]
fn extract_uuid_returns_none_when_field_absent() {
assert_eq!(extract_uuid(r#"{"hostname":"x"}"#), None);
}
#[test]
fn extract_uuid_skips_key_embedded_in_string_value() {
let doc = r#"{"note": "x\"uuid\":\"fake\"", "uuid": "real"}"#;
assert_eq!(extract_uuid(doc).as_deref(), Some("real"));
}
#[test]
fn extract_uuid_rejects_when_only_match_is_embedded() {
let doc = r#"{"note": "x,\"uuid\":\"fake\""}"#;
assert_eq!(extract_uuid(doc), None);
}
#[test]
fn extract_uuid_tolerates_whitespace_around_colon() {
let doc = r#"{"uuid" : "xyz"}"#;
assert_eq!(extract_uuid(doc).as_deref(), Some("xyz"));
}
#[test]
fn extract_uuid_skips_uuid_suffix_in_other_key() {
let doc = r#"{"project_uuid":"proj-123","uuid":"real"}"#;
assert_eq!(extract_uuid(doc).as_deref(), Some("real"));
}
#[test]
fn extract_uuid_handles_nested_devices_array() {
let doc = r#"{"uuid":"REAL","devices":[{"uuid":"DEVICE"}]}"#;
assert_eq!(extract_uuid(doc).as_deref(), Some("REAL"));
}
#[test]
fn extract_uuid_rejects_uuid_appearing_as_value_string() {
let doc = r#"{"x":"uuid","y":"real"}"#;
assert_eq!(extract_uuid(doc), None);
}
#[test]
fn extract_uuid_rejects_malformed_value() {
let doc = r#"{"uuid":"#;
assert_eq!(extract_uuid(doc), None);
}
}