Skip to main content

function_sdk_rust/
resource.rs

1//! Helpers for working with composite and composed resources.
2
3use pbjson_types::{ListValue, Struct, Value, value::Kind};
4use serde::Serialize;
5use sha2::{Digest, Sha256};
6
7use crate::Error;
8use crate::proto::v1::{Ready, Resource};
9
10/// Converts a protobuf Struct to a JSON value.
11///
12/// The conversion is total: a number that JSON cannot represent (NaN or
13/// infinity) becomes null. Struct numbers are f64, so integers above 2^53
14/// have already lost precision on the wire. Integral numbers are emitted as
15/// JSON integers (see [`pb_to_json`]), so the result deserializes cleanly
16/// into typed structs with integer fields.
17pub fn struct_to_json(s: &Struct) -> serde_json::Value {
18    serde_json::Value::Object(
19        s.fields
20            .iter()
21            .map(|(k, v)| (k.clone(), pb_to_json(v)))
22            .collect(),
23    )
24}
25
26/// Deserializes a resource's JSON representation into a typed value.
27///
28/// Works for both the composite resource (XR) and composed resources - both
29/// are represented by [`Resource`]. Pass
30/// [`request::observed_composite`](crate::request::observed_composite) for
31/// the observed XR, or
32/// [`request::observed_composed`](crate::request::observed_composed) for an
33/// observed composed resource; desired resources work the same way. Returns
34/// [`Error::MissingResource`] when `resource` is `None` or has no JSON
35/// representation set, for example a composed resource Crossplane has not
36/// yet observed.
37pub fn get<T: serde::de::DeserializeOwned>(resource: Option<&Resource>) -> Result<T, Error> {
38    let s = resource
39        .and_then(|r| r.resource.as_ref())
40        .ok_or(Error::MissingResource)?;
41    Ok(serde_json::from_value(struct_to_json(s))?)
42}
43
44/// Converts a JSON object to a protobuf Struct.
45pub fn json_to_struct(m: &serde_json::Map<String, serde_json::Value>) -> Struct {
46    Struct {
47        fields: m.iter().map(|(k, v)| (k.clone(), json_to_pb(v))).collect(),
48    }
49}
50
51/// Converts a protobuf Value to a JSON value. NaN and infinity become null.
52///
53/// Struct erases the integer-ness of every number: Crossplane converts
54/// resources through structpb, which stores all numbers as f64, so an
55/// observed `replicas: 3` arrives as 3.0. Serde refuses to deserialize a
56/// float into an integer field, so integral numbers are restored to JSON
57/// integers here. K8s resources have no genuinely float-typed fields this
58/// could misrepresent.
59pub fn pb_to_json(v: &Value) -> serde_json::Value {
60    match &v.kind {
61        None | Some(Kind::NullValue(_)) => serde_json::Value::Null,
62        Some(Kind::NumberValue(n)) => number_to_json(*n),
63        Some(Kind::StringValue(s)) => serde_json::Value::String(s.clone()),
64        Some(Kind::BoolValue(b)) => serde_json::Value::Bool(*b),
65        Some(Kind::StructValue(s)) => struct_to_json(s),
66        Some(Kind::ListValue(l)) => {
67            serde_json::Value::Array(l.values.iter().map(pb_to_json).collect())
68        }
69    }
70}
71
72fn number_to_json(n: f64) -> serde_json::Value {
73    const SAFE_INT: f64 = (1i64 << 53) as f64;
74    if n.fract() == 0.0 && n.abs() <= SAFE_INT {
75        return serde_json::Value::Number(serde_json::Number::from(n as i64));
76    }
77    serde_json::Number::from_f64(n)
78        .map(serde_json::Value::Number)
79        .unwrap_or(serde_json::Value::Null)
80}
81
82/// Converts a JSON value to a protobuf Value. Numbers become f64.
83pub fn json_to_pb(v: &serde_json::Value) -> Value {
84    let kind = match v {
85        serde_json::Value::Null => Kind::NullValue(0),
86        serde_json::Value::Bool(b) => Kind::BoolValue(*b),
87        serde_json::Value::Number(n) => Kind::NumberValue(n.as_f64().unwrap_or(f64::NAN)),
88        serde_json::Value::String(s) => Kind::StringValue(s.clone()),
89        serde_json::Value::Array(a) => Kind::ListValue(ListValue {
90            values: a.iter().map(json_to_pb).collect(),
91        }),
92        serde_json::Value::Object(m) => Kind::StructValue(json_to_struct(m)),
93    };
94    Value { kind: Some(kind) }
95}
96
97/// Updates a composite or composed resource from any serializable source.
98///
99/// The source must serialize to a JSON object, for example a
100/// `serde_json::json!` literal or a typed struct deriving Serialize. Top
101/// level fields that already exist are overwritten and fields that do not
102/// exist are added, like a map update. Include only the fields this function
103/// has an opinion about: Crossplane treats desired state as server-side
104/// apply intent.
105pub fn update<T: Serialize + ?Sized>(r: &mut Resource, source: &T) -> Result<(), Error> {
106    let value = serde_json::to_value(source)?;
107    let serde_json::Value::Object(map) = value else {
108        return Err(Error::NotAnObject);
109    };
110    let target = r.resource.get_or_insert_default();
111    for (k, v) in &map {
112        target.fields.insert(k.clone(), json_to_pb(v));
113    }
114    Ok(())
115}
116
117/// Updates a resource's status from any serializable source.
118///
119/// Equivalent to calling [`update`] with `{"status": source}`.
120pub fn update_status<T: Serialize + ?Sized>(r: &mut Resource, status: &T) -> Result<(), Error> {
121    update(
122        r,
123        &serde_json::json!({"status": serde_json::to_value(status)?}),
124    )
125}
126
127/// Sets whether a desired resource should be considered ready.
128///
129/// Set `Ready::True` on a desired composed resource to mark it ready, or on
130/// the desired XR to override Crossplane's standard readiness detection.
131pub fn set_ready(r: &mut Resource, ready: Ready) {
132    r.ready = ready as i32;
133}
134
135/// Adds a connection detail to a resource.
136///
137/// Only meaningful on the desired XR of legacy (v1) XRs; Crossplane ignores
138/// desired connection details everywhere else.
139pub fn add_connection_detail(r: &mut Resource, key: impl Into<String>, value: impl Into<Vec<u8>>) {
140    r.connection_details.insert(key.into(), value.into());
141}
142
143/// A status condition of a resource.
144#[derive(Clone, Debug, PartialEq, Eq)]
145pub struct Condition {
146    /// Type of the condition, for example Ready.
147    pub typ: String,
148    /// Status of the condition: True, False, or Unknown.
149    pub status: String,
150    /// Machine-readable reason for the condition status, typically PascalCase.
151    pub reason: Option<String>,
152    /// Human-readable message.
153    pub message: Option<String>,
154    /// RFC 3339 time of the last status transition.
155    pub last_transition_time: Option<String>,
156}
157
158impl Condition {
159    fn unknown(typ: &str) -> Self {
160        Self {
161            typ: typ.to_string(),
162            status: "Unknown".to_string(),
163            reason: None,
164            message: None,
165            last_transition_time: None,
166        }
167    }
168}
169
170/// Gets the supplied status condition of the supplied resource.
171///
172/// A condition is always returned: if the resource is None or the condition
173/// is not present, a condition with status Unknown is returned. Accepting an
174/// Option makes it safe to pass the result of a map `get` directly, for
175/// example `get_condition(req.observed.resources.get("bucket"), "Ready")`.
176pub fn get_condition(resource: Option<&Resource>, typ: &str) -> Condition {
177    let Some(s) = resource.and_then(|r| r.resource.as_ref()) else {
178        return Condition::unknown(typ);
179    };
180    let Some(Kind::StructValue(status)) = s.fields.get("status").and_then(|v| v.kind.as_ref())
181    else {
182        return Condition::unknown(typ);
183    };
184    let Some(Kind::ListValue(conditions)) = status
185        .fields
186        .get("conditions")
187        .and_then(|v| v.kind.as_ref())
188    else {
189        return Condition::unknown(typ);
190    };
191
192    for value in &conditions.values {
193        let Some(Kind::StructValue(c)) = value.kind.as_ref() else {
194            continue;
195        };
196        if get_str(c, "type") != Some(typ) {
197            continue;
198        }
199        return Condition {
200            typ: typ.to_string(),
201            status: get_str(c, "status").unwrap_or("Unknown").to_string(),
202            reason: get_str(c, "reason").map(String::from),
203            message: get_str(c, "message").map(String::from),
204            last_transition_time: get_str(c, "lastTransitionTime").map(String::from),
205        };
206    }
207
208    Condition::unknown(typ)
209}
210
211fn get_str<'a>(s: &'a Struct, key: &str) -> Option<&'a str> {
212    match s.fields.get(key)?.kind.as_ref()? {
213        Kind::StringValue(v) => Some(v),
214        _ => None,
215    }
216}
217
218const DNS_LABEL_MAX: usize = 63;
219const HASH_LEN: usize = 5;
220
221/// Builds a deterministic, DNS-label-safe name for a child resource.
222///
223/// Joins the parts with the separator, appends a deterministic 5-character
224/// hash suffix for uniqueness, and truncates the prefix so the result is at
225/// most 63 characters. The hash is always appended, even for short names, so
226/// names are visually consistent regardless of length.
227pub fn child_name(parts: &[&str], sep: &str) -> String {
228    let full = parts.join(sep);
229    let digest = Sha256::digest(full.as_bytes());
230    let hash: String = digest
231        .iter()
232        .take(HASH_LEN.div_ceil(2))
233        .map(|b| format!("{b:02x}"))
234        .collect();
235    let hash = &hash[..HASH_LEN];
236    let max_prefix = DNS_LABEL_MAX - HASH_LEN - sep.len();
237    let prefix: String = full.chars().take(max_prefix).collect();
238    let prefix = prefix.trim_end_matches(sep);
239    format!("{prefix}{sep}{hash}")
240}