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