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