-
Notifications
You must be signed in to change notification settings - Fork 16
feat(kvp): add ProvisioningReport abstraction over KvpPoolStore #310
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
peytonr18
wants to merge
1
commit into
Azure:main
Choose a base branch
from
peytonr18:probertson-kvp-provisioning-report
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,370 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| //! Structured provisioning report abstraction layered over the raw | ||
| //! [`KvpPoolStore`](crate::KvpPoolStore) key/value API. | ||
| //! | ||
| //! [`ProvisioningReport`] is a strongly-typed representation of a | ||
| //! provisioning health report. Instead of building ad-hoc key/value | ||
| //! strings at the call site, callers construct a report and convert it | ||
| //! into ordered KVP entries via [`ToKvp::to_kvp`], then persist it with | ||
| //! [`write_report`]. | ||
| //! | ||
| //! The [`ToKvp`] trait is the shared seam intended for future layering: | ||
| //! a diagnostics report can implement the same trait and reuse | ||
| //! [`write_report`] without changing this module. | ||
|
|
||
| use chrono::Utc; | ||
|
|
||
| use crate::{KvpError, KvpPoolStore}; | ||
|
|
||
| /// Default value for the `pps_type` field when none is specified. | ||
| const DEFAULT_PPS_TYPE: &str = "None"; | ||
|
|
||
| /// The current time formatted as an RFC 3339 string. | ||
| fn now_rfc3339() -> String { | ||
| Utc::now().to_rfc3339() | ||
| } | ||
| /// Conversion into ordered KVP entries. | ||
| /// | ||
| /// Implementors return key/value pairs in a deterministic order so the | ||
| /// resulting KVP records are stable and easy to assert against. This is | ||
| /// the shared seam that future report types (e.g. diagnostics) can | ||
| /// implement to reuse [`write_report`]. | ||
| pub trait ToKvp { | ||
| /// Return the report as ordered `(key, value)` pairs. | ||
| fn to_kvp(&self) -> Vec<(String, String)>; | ||
| } | ||
|
|
||
| /// Outcome of a provisioning attempt. | ||
| #[derive(Clone, Copy, Debug, PartialEq, Eq)] | ||
| enum ReportResult { | ||
| /// Provisioning completed successfully. | ||
| Success, | ||
| /// Provisioning failed. | ||
| Error, | ||
| } | ||
|
|
||
| impl ReportResult { | ||
| /// The wire string used in the `result` KVP field. | ||
| fn as_str(self) -> &'static str { | ||
| match self { | ||
| Self::Success => "success", | ||
| Self::Error => "error", | ||
| } | ||
| } | ||
| } | ||
|
|
||
| impl std::fmt::Display for ReportResult { | ||
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| f.write_str(self.as_str()) | ||
| } | ||
| } | ||
|
|
||
| /// A strongly-typed provisioning health report. | ||
| /// | ||
| /// Construct one with [`ProvisioningReport::success`] or | ||
| /// [`ProvisioningReport::error`], optionally attach extra context with | ||
| /// the builder methods, then convert to KVP entries with | ||
| /// [`ToKvp::to_kvp`] or persist with [`write_report`]. | ||
| /// | ||
| /// # Example | ||
| /// ```no_run | ||
| /// use libazureinit_kvp::{ | ||
| /// write_report, KvpPool, KvpPoolStore, PoolMode, ProvisioningReport, | ||
| /// }; | ||
| /// | ||
| /// # fn main() -> Result<(), libazureinit_kvp::KvpError> { | ||
| /// let store = KvpPoolStore::new(KvpPool::Guest, PoolMode::Safe)?; | ||
| /// | ||
| /// let report = ProvisioningReport::success( | ||
| /// format!("Azure-Init/{}", env!("CARGO_PKG_VERSION")), | ||
| /// "00000000-0000-0000-0000-000000000abc", | ||
| /// ) | ||
| /// .with_extra("build", "test-123"); | ||
| /// | ||
| /// write_report(&store, &report)?; | ||
| /// # Ok(()) | ||
| /// # } | ||
| /// ``` | ||
| #[derive(Clone, Debug, PartialEq, Eq)] | ||
| pub struct ProvisioningReport { | ||
| /// Provisioning outcome (`result` field). | ||
| result: ReportResult, | ||
| /// Reporting agent identifier (`agent` field). | ||
| agent: String, | ||
| /// Virtual machine identifier (`vm_id` field). | ||
| vm_id: String, | ||
| /// Report timestamp (`timestamp` field), set to the current time | ||
| /// (RFC 3339) when the report is constructed. | ||
| timestamp: String, | ||
| /// Pre-provisioning type (`pps_type` field). Defaults to `None`. | ||
| pps_type: String, | ||
| /// Failure reason (`reason` field). Present for error reports. | ||
| reason: Option<String>, | ||
| /// Documentation URL (`documentation_url` field), if applicable. | ||
| documentation_url: Option<String>, | ||
| /// Additional ordered key/value context (e.g. supporting data). | ||
| extra: Vec<(String, String)>, | ||
| } | ||
|
|
||
| impl ProvisioningReport { | ||
| /// Create a successful provisioning report. | ||
| pub fn success(agent: impl Into<String>, vm_id: impl Into<String>) -> Self { | ||
| Self { | ||
| result: ReportResult::Success, | ||
| agent: agent.into(), | ||
| vm_id: vm_id.into(), | ||
| timestamp: now_rfc3339(), | ||
| pps_type: DEFAULT_PPS_TYPE.to_string(), | ||
| reason: None, | ||
| documentation_url: None, | ||
| extra: Vec::new(), | ||
| } | ||
| } | ||
|
|
||
| /// Create a failed provisioning report with a failure reason. | ||
| pub fn error( | ||
| agent: impl Into<String>, | ||
| vm_id: impl Into<String>, | ||
| reason: impl Into<String>, | ||
| ) -> Self { | ||
| Self { | ||
| result: ReportResult::Error, | ||
| agent: agent.into(), | ||
| vm_id: vm_id.into(), | ||
| timestamp: now_rfc3339(), | ||
| pps_type: DEFAULT_PPS_TYPE.to_string(), | ||
| reason: Some(reason.into()), | ||
| documentation_url: None, | ||
| extra: Vec::new(), | ||
| } | ||
| } | ||
|
|
||
| /// Override the `pps_type` field (defaults to `None`). | ||
| pub fn with_pps_type(mut self, pps_type: impl Into<String>) -> Self { | ||
| self.pps_type = pps_type.into(); | ||
| self | ||
| } | ||
|
|
||
| /// Attach a documentation URL. | ||
| pub fn with_documentation_url(mut self, url: impl Into<String>) -> Self { | ||
| self.documentation_url = Some(url.into()); | ||
| self | ||
| } | ||
|
|
||
| /// Append an additional key/value pair. Extras are emitted in the | ||
| /// order they were added. | ||
| pub fn with_extra( | ||
| mut self, | ||
| key: impl Into<String>, | ||
| value: impl Into<String>, | ||
| ) -> Self { | ||
| self.extra.push((key.into(), value.into())); | ||
| self | ||
| } | ||
| } | ||
|
|
||
| impl ToKvp for ProvisioningReport { | ||
| /// Emit entries in a deterministic order: | ||
| /// `result`, `reason` (if any), `agent`, extras (in order), | ||
| /// `pps_type`, `vm_id`, `timestamp`, `documentation_url` (if any). | ||
| fn to_kvp(&self) -> Vec<(String, String)> { | ||
| let mut entries = Vec::with_capacity(6 + self.extra.len()); | ||
|
|
||
| entries.push(("result".to_string(), self.result.to_string())); | ||
| if let Some(reason) = &self.reason { | ||
| entries.push(("reason".to_string(), reason.clone())); | ||
| } | ||
| entries.push(("agent".to_string(), self.agent.clone())); | ||
| for (key, value) in &self.extra { | ||
| entries.push((key.clone(), value.clone())); | ||
| } | ||
| entries.push(("pps_type".to_string(), self.pps_type.clone())); | ||
| entries.push(("vm_id".to_string(), self.vm_id.clone())); | ||
| entries.push(("timestamp".to_string(), self.timestamp.clone())); | ||
| if let Some(url) = &self.documentation_url { | ||
| entries.push(("documentation_url".to_string(), url.clone())); | ||
| } | ||
|
|
||
| entries | ||
| } | ||
| } | ||
|
|
||
| /// Persist a report to the KVP store. | ||
| /// | ||
| /// Each entry from [`ToKvp::to_kvp`] is written with | ||
| /// [`KvpPoolStore::insert`] (upsert / last-write-wins), so re-emitting a | ||
| /// report collapses to a single record per key rather than accumulating | ||
| /// duplicates. The inserts are not transactional: an I/O error partway | ||
| /// through can leave entries written before the failure in the store. | ||
| pub fn write_report( | ||
| store: &KvpPoolStore, | ||
| report: &impl ToKvp, | ||
| ) -> Result<(), KvpError> { | ||
| for (key, value) in report.to_kvp() { | ||
| store.insert(&key, &value)?; | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use crate::{KvpPool, PoolMode}; | ||
| use rstest::rstest; | ||
| use tempfile::TempDir; | ||
|
|
||
| const VM_ID: &str = "00000000-0000-0000-0000-000000000abc"; | ||
| const AGENT: &str = "Azure-Init/0.0.0"; | ||
| const TS: &str = "2026-06-17T00:00:00+00:00"; | ||
|
|
||
| fn safe_store(dir: &TempDir) -> KvpPoolStore { | ||
| KvpPoolStore::new_in(KvpPool::Guest, dir.path(), PoolMode::Safe) | ||
| .unwrap() | ||
| } | ||
|
|
||
| /// Pin a report's timestamp so generated entries are deterministic. | ||
| fn with_ts(mut report: ProvisioningReport) -> ProvisioningReport { | ||
| report.timestamp = TS.to_string(); | ||
| report | ||
| } | ||
|
|
||
| #[rstest] | ||
| #[case::success( | ||
| with_ts(ProvisioningReport::success(AGENT, VM_ID)), | ||
| vec![ | ||
| ("result", "success"), | ||
| ("agent", AGENT), | ||
| ("pps_type", "None"), | ||
| ("vm_id", VM_ID), | ||
| ("timestamp", TS), | ||
| ], | ||
| )] | ||
| #[case::success_with_extras( | ||
| with_ts( | ||
| ProvisioningReport::success(AGENT, VM_ID) | ||
| .with_extra("endpoint", "http://example.com") | ||
| .with_extra("status", "404"), | ||
| ), | ||
| vec![ | ||
| ("result", "success"), | ||
| ("agent", AGENT), | ||
| ("endpoint", "http://example.com"), | ||
| ("status", "404"), | ||
| ("pps_type", "None"), | ||
| ("vm_id", VM_ID), | ||
| ("timestamp", TS), | ||
| ], | ||
| )] | ||
| #[case::custom_pps_type( | ||
| with_ts( | ||
| ProvisioningReport::success(AGENT, VM_ID) | ||
| .with_pps_type("Savable"), | ||
| ), | ||
| vec![ | ||
| ("result", "success"), | ||
| ("agent", AGENT), | ||
| ("pps_type", "Savable"), | ||
| ("vm_id", VM_ID), | ||
| ("timestamp", TS), | ||
| ], | ||
| )] | ||
| #[case::error_with_documentation_url( | ||
| with_ts( | ||
| ProvisioningReport::error(AGENT, VM_ID, "failed to load sshd config") | ||
| .with_documentation_url("https://aka.ms/linuxprovisioningerror"), | ||
| ), | ||
| vec![ | ||
| ("result", "error"), | ||
| ("reason", "failed to load sshd config"), | ||
| ("agent", AGENT), | ||
| ("pps_type", "None"), | ||
| ("vm_id", VM_ID), | ||
| ("timestamp", TS), | ||
| ("documentation_url", "https://aka.ms/linuxprovisioningerror"), | ||
| ], | ||
| )] | ||
| #[case::error_without_documentation_url( | ||
| with_ts(ProvisioningReport::error(AGENT, VM_ID, "boom")), | ||
| vec![ | ||
| ("result", "error"), | ||
| ("reason", "boom"), | ||
| ("agent", AGENT), | ||
| ("pps_type", "None"), | ||
| ("vm_id", VM_ID), | ||
| ("timestamp", TS), | ||
| ], | ||
| )] | ||
| fn to_kvp_emits_expected_entries( | ||
| #[case] report: ProvisioningReport, | ||
| #[case] expected: Vec<(&str, &str)>, | ||
| ) { | ||
| let expected: Vec<(String, String)> = expected | ||
| .into_iter() | ||
| .map(|(k, v)| (k.to_string(), v.to_string())) | ||
| .collect(); | ||
| assert_eq!(report.to_kvp(), expected); | ||
| } | ||
|
|
||
| #[test] | ||
| fn default_timestamp_is_populated() { | ||
| let report = ProvisioningReport::success(AGENT, VM_ID); | ||
| assert!(!report.timestamp.is_empty()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn write_report_round_trips_through_store() { | ||
| let dir = TempDir::new().unwrap(); | ||
| let store = safe_store(&dir); | ||
|
|
||
| let report = with_ts( | ||
| ProvisioningReport::error(AGENT, VM_ID, "boom") | ||
| .with_extra("details", "bad config") | ||
| .with_documentation_url( | ||
| "https://aka.ms/linuxprovisioningerror", | ||
| ), | ||
| ); | ||
|
|
||
| write_report(&store, &report).unwrap(); | ||
|
|
||
| let entries = store.entries().unwrap(); | ||
| assert_eq!(entries.get("result").map(String::as_str), Some("error")); | ||
| assert_eq!(entries.get("reason").map(String::as_str), Some("boom")); | ||
| assert_eq!( | ||
| entries.get("details").map(String::as_str), | ||
| Some("bad config") | ||
| ); | ||
| assert_eq!(entries.get("vm_id").map(String::as_str), Some(VM_ID)); | ||
| assert_eq!(entries.get("timestamp").map(String::as_str), Some(TS)); | ||
| assert_eq!( | ||
| entries.get("documentation_url").map(String::as_str), | ||
| Some("https://aka.ms/linuxprovisioningerror") | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn write_report_is_idempotent_upsert() { | ||
| let dir = TempDir::new().unwrap(); | ||
| let store = safe_store(&dir); | ||
|
|
||
| let report = with_ts(ProvisioningReport::success(AGENT, VM_ID)); | ||
| write_report(&store, &report).unwrap(); | ||
| write_report(&store, &report).unwrap(); | ||
|
|
||
| assert_eq!(store.len().unwrap(), report.to_kvp().len()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn write_report_propagates_store_error() { | ||
| let dir = TempDir::new().unwrap(); | ||
| let store = safe_store(&dir); | ||
|
|
||
| let oversized = "x".repeat(store.max_value_size() + 1); | ||
| let report = ProvisioningReport::success(AGENT, VM_ID) | ||
| .with_extra("big", oversized); | ||
|
|
||
| let result = write_report(&store, &report); | ||
| assert!(result.is_err()); | ||
| } | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let's skip the default and just require it. caller can default to "None". but also, let's maintain the types here, e.g. ReportPpsType