use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::boundary::Condition;
use crate::crd::Process;
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ProcessCondition {
#[serde(rename = "type")]
pub type_: String,
pub status: String,
pub last_transition_time: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
impl ProcessCondition {
pub fn ready(reason: impl Into<String>, message: Option<String>) -> Self {
Self {
type_: "Ready".into(),
status: "True".into(),
last_transition_time: Utc::now(),
reason: Some(reason.into()),
message,
}
}
pub fn not_ready(reason: impl Into<String>, message: impl Into<String>) -> Self {
Self {
type_: "Ready".into(),
status: "False".into(),
last_transition_time: Utc::now(),
reason: Some(reason.into()),
message: Some(message.into()),
}
}
pub fn attested(root: &str) -> Self {
Self {
type_: "Attested".into(),
status: "True".into(),
last_transition_time: Utc::now(),
reason: Some("AttestationWritten".into()),
message: Some(format!("composed_root={root}")),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FluxResourceRef {
pub api_version: String,
pub kind: String,
pub name: String,
pub namespace: String,
#[serde(default)]
pub ready: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_check: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RenderedResourceCoords {
pub api_version: String,
pub kind: String,
pub name: String,
pub namespace: Option<String>,
}
impl RenderedResourceCoords {
pub fn from_json(res: &Value) -> anyhow::Result<Self> {
let api_version = res
.get("apiVersion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("rendered resource missing apiVersion"))?
.to_string();
let kind = res
.get("kind")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("rendered resource missing kind"))?
.to_string();
let metadata = res.get("metadata");
let name = metadata
.and_then(|m| m.get("name"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("rendered resource missing metadata.name"))?
.to_string();
let namespace = metadata
.and_then(|m| m.get("namespace"))
.and_then(|v| v.as_str())
.map(str::to_string);
Ok(Self {
api_version,
kind,
name,
namespace,
})
}
pub fn namespace_or_default(&self) -> &str {
self.namespace
.as_deref()
.unwrap_or(Process::DEFAULT_NAMESPACE)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct CheckedCondition {
#[serde(flatten)]
pub condition: Condition,
pub satisfied: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_check: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct BoundaryStatus {
#[serde(default)]
pub preconditions: Vec<CheckedCondition>,
#[serde(default)]
pub postconditions: Vec<CheckedCondition>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deadline: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ComplianceStatus {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub baseline: Option<String>,
pub satisfied: u32,
pub violated: u32,
pub total: u32,
#[serde(default)]
pub violations: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn rendered_resource_coords_from_json_extracts_all_four_slots_when_present() {
let res = json!({
"apiVersion": "kustomize.toolkit.fluxcd.io/v1",
"kind": "Kustomization",
"metadata": {
"name": "observability-stack",
"namespace": "flux-system",
},
});
let c = RenderedResourceCoords::from_json(&res).expect("extract");
assert_eq!(c.api_version, "kustomize.toolkit.fluxcd.io/v1");
assert_eq!(c.kind, "Kustomization");
assert_eq!(c.name, "observability-stack");
assert_eq!(c.namespace.as_deref(), Some("flux-system"));
}
#[test]
fn rendered_resource_coords_from_json_captures_absent_namespace_as_none() {
let res = json!({
"apiVersion": "v1",
"kind": "Namespace",
"metadata": {"name": "demo-test"},
});
let c = RenderedResourceCoords::from_json(&res).expect("extract");
assert_eq!(c.namespace, None);
assert_eq!(c.name, "demo-test");
}
#[test]
fn rendered_resource_coords_from_json_errors_on_missing_api_version() {
let res = json!({"kind": "K", "metadata": {"name": "n"}});
let e = RenderedResourceCoords::from_json(&res).expect_err("must error");
assert_eq!(e.to_string(), "rendered resource missing apiVersion");
}
#[test]
fn rendered_resource_coords_from_json_errors_on_missing_kind() {
let res = json!({"apiVersion": "v1", "metadata": {"name": "n"}});
let e = RenderedResourceCoords::from_json(&res).expect_err("must error");
assert_eq!(e.to_string(), "rendered resource missing kind");
}
#[test]
fn rendered_resource_coords_from_json_errors_on_missing_metadata_name() {
let res = json!({"apiVersion": "v1", "kind": "K", "metadata": {}});
let e = RenderedResourceCoords::from_json(&res).expect_err("must error");
assert_eq!(e.to_string(), "rendered resource missing metadata.name");
}
#[test]
fn rendered_resource_coords_from_json_errors_on_missing_metadata_object() {
let res = json!({"apiVersion": "v1", "kind": "K"});
let e = RenderedResourceCoords::from_json(&res).expect_err("must error");
assert_eq!(e.to_string(), "rendered resource missing metadata.name");
}
#[test]
fn rendered_resource_coords_from_json_errors_on_non_string_slot() {
let res = json!({
"apiVersion": 42,
"kind": "K",
"metadata": {"name": "n"},
});
let e = RenderedResourceCoords::from_json(&res).expect_err("must error");
assert_eq!(e.to_string(), "rendered resource missing apiVersion");
}
#[test]
fn rendered_resource_coords_error_wording_is_canonical() {
let cases = [
(
"apiVersion",
json!({"kind": "K", "metadata": {"name": "n"}}),
),
(
"kind",
json!({"apiVersion": "v1", "metadata": {"name": "n"}}),
),
(
"metadata.name",
json!({"apiVersion": "v1", "kind": "K", "metadata": {}}),
),
];
for (slot, res) in cases {
let e = RenderedResourceCoords::from_json(&res).expect_err("must error");
assert_eq!(
e.to_string(),
format!("rendered resource missing {slot}"),
"slot {slot} error must be canonical"
);
}
}
#[test]
fn rendered_resource_coords_namespace_or_default_returns_slice_when_some() {
let c = RenderedResourceCoords {
api_version: "v1".into(),
kind: "K".into(),
name: "n".into(),
namespace: Some("prod".into()),
};
assert_eq!(c.namespace_or_default(), "prod");
}
#[test]
fn rendered_resource_coords_namespace_or_default_falls_back_when_none() {
let c = RenderedResourceCoords {
api_version: "v1".into(),
kind: "K".into(),
name: "n".into(),
namespace: None,
};
assert_eq!(c.namespace_or_default(), Process::DEFAULT_NAMESPACE);
assert_eq!(c.namespace_or_default(), "default");
}
#[test]
fn rendered_resource_coords_namespace_fallback_shares_process_default_const() {
let c = RenderedResourceCoords {
api_version: "v1".into(),
kind: "K".into(),
name: "n".into(),
namespace: None,
};
assert_eq!(c.namespace_or_default(), Process::DEFAULT_NAMESPACE);
}
}