Skip to main content

blueprint_client_tangle/
blueprint_metadata.rs

1//! Helpers for reading and writing blueprint execution metadata.
2
3use crate::contracts::ITangleTypes;
4use alloc::borrow::ToOwned;
5use alloc::string::{String, ToString};
6use serde::{Deserialize, Serialize};
7use serde_json::{Map, Value, json};
8use thiserror::Error;
9
10/// Schema marker for structured blueprint metadata payloads.
11pub const METADATA_SCHEMA_V1: &str = "tangle.blueprint.metadata.v1";
12
13const EXECUTION_PROFILE_KEY: &str = "execution_profile";
14const JOB_PROFILES_BLOB_KEY: &str = "job_profiles_b64_gzip";
15
16/// Errors produced while parsing execution profile metadata.
17#[derive(Debug, Error, Clone, PartialEq, Eq)]
18#[non_exhaustive]
19pub enum ExecutionProfileError {
20    /// `profiling_data` is not valid JSON.
21    #[error("profiling_data must be valid JSON: {message}")]
22    InvalidJson {
23        /// Human-readable parser failure detail.
24        message: String,
25    },
26    /// `profiling_data` JSON root is not an object.
27    #[error("profiling_data must be a JSON object")]
28    MetadataNotObject,
29    /// `execution_profile` exists but is not an object.
30    #[error("execution_profile must be an object")]
31    ExecutionProfileNotObject,
32    /// `execution_profile` exists but does not match expected schema.
33    #[error("execution_profile is invalid: {message}")]
34    InvalidExecutionProfile {
35        /// Human-readable schema validation detail.
36        message: String,
37    },
38}
39
40/// Confidentiality policy for execution.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum ConfidentialityPolicy {
44    /// Standard and TEE placement are both valid.
45    Any,
46    /// TEE execution is mandatory (fail closed).
47    TeeRequired,
48    /// Non-TEE execution is mandatory.
49    StandardRequired,
50    /// Prefer TEE, but allow non-TEE fallback.
51    TeePreferred,
52}
53
54impl Default for ConfidentialityPolicy {
55    fn default() -> Self {
56        Self::Any
57    }
58}
59
60/// GPU requirement policy for execution.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum GpuPolicy {
64    /// No GPU required (default).
65    None,
66    /// GPU execution is mandatory (fail closed).
67    Required,
68    /// Prefer GPU, but allow CPU fallback.
69    Preferred,
70}
71
72impl Default for GpuPolicy {
73    fn default() -> Self {
74        Self::None
75    }
76}
77
78/// GPU resource requirements for a blueprint.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
80#[serde(deny_unknown_fields)]
81pub struct GpuRequirements {
82    /// GPU availability policy.
83    #[serde(default)]
84    pub policy: GpuPolicy,
85    /// Minimum number of GPU devices required.
86    #[serde(default)]
87    pub min_count: u32,
88    /// Minimum VRAM per device in GiB.
89    ///
90    /// Note: Kubernetes does not natively expose per-device VRAM as a
91    /// schedulable resource. When `policy` is `Required`, the BPM adds a
92    /// node selector label (`gpu.tangle.tools/vram-gb`) so operators can
93    /// label their nodes accordingly. When `policy` is `Preferred`, this
94    /// field is advisory only.
95    #[serde(default)]
96    pub min_vram_gb: u32,
97}
98
99impl GpuRequirements {
100    /// Normalize requirements: when policy is `Required` or `Preferred`,
101    /// ensure `min_count` is at least 1.
102    #[must_use]
103    pub fn normalized(mut self) -> Self {
104        if !matches!(self.policy, GpuPolicy::None) && self.min_count == 0 {
105            self.min_count = 1;
106        }
107        self
108    }
109}
110
111/// Blueprint deployment policy for execution environments.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
113pub struct ExecutionProfile {
114    /// Confidentiality policy for runtime placement.
115    #[serde(default)]
116    pub confidentiality: ConfidentialityPolicy,
117    /// GPU resource requirements.
118    #[serde(default)]
119    pub gpu: GpuRequirements,
120}
121
122impl ExecutionProfile {
123    /// Whether execution must run in TEE.
124    #[must_use]
125    pub fn tee_required(self) -> bool {
126        matches!(self.confidentiality, ConfidentialityPolicy::TeeRequired)
127    }
128
129    /// Whether execution may run in TEE.
130    #[must_use]
131    pub fn tee_supported(self) -> bool {
132        matches!(
133            self.confidentiality,
134            ConfidentialityPolicy::Any
135                | ConfidentialityPolicy::TeeRequired
136                | ConfidentialityPolicy::TeePreferred
137        )
138    }
139
140    /// Whether non-TEE placement is required.
141    #[must_use]
142    pub fn standard_required(self) -> bool {
143        matches!(
144            self.confidentiality,
145            ConfidentialityPolicy::StandardRequired
146        )
147    }
148
149    /// Whether GPU execution is mandatory.
150    #[must_use]
151    pub fn gpu_required(self) -> bool {
152        matches!(self.gpu.policy, GpuPolicy::Required)
153    }
154
155    /// Whether GPU execution is preferred but not mandatory.
156    #[must_use]
157    pub fn gpu_preferred(self) -> bool {
158        matches!(self.gpu.policy, GpuPolicy::Preferred)
159    }
160
161    /// Whether the blueprint has any GPU requirements.
162    #[must_use]
163    pub fn needs_gpu(self) -> bool {
164        !matches!(self.gpu.policy, GpuPolicy::None)
165    }
166}
167
168#[derive(Debug, Deserialize)]
169#[serde(deny_unknown_fields)]
170struct RawExecutionProfile {
171    confidentiality: Option<ConfidentialityPolicy>,
172    #[serde(default)]
173    gpu: Option<GpuRequirements>,
174}
175
176/// Resolve execution profile from on-chain metadata.
177pub fn resolve_execution_profile(
178    metadata: &ITangleTypes::BlueprintMetadata,
179) -> Result<Option<ExecutionProfile>, ExecutionProfileError> {
180    resolve_execution_profile_from_profiling_data(metadata.profilingData.as_str())
181}
182
183/// Resolve execution profile from `profiling_data` payload.
184///
185/// This parser is strict by design:
186/// - empty payload => `Ok(None)`
187/// - non-empty payload must be structured JSON metadata
188pub fn resolve_execution_profile_from_profiling_data(
189    profiling_data: &str,
190) -> Result<Option<ExecutionProfile>, ExecutionProfileError> {
191    let trimmed = profiling_data.trim();
192    if trimmed.is_empty() {
193        return Ok(None);
194    }
195
196    let root: Value =
197        serde_json::from_str(trimmed).map_err(|e| ExecutionProfileError::InvalidJson {
198            message: e.to_string(),
199        })?;
200    let Some(root_object) = root.as_object() else {
201        return Err(ExecutionProfileError::MetadataNotObject);
202    };
203
204    let Some(raw_profile_value) = root_object.get(EXECUTION_PROFILE_KEY) else {
205        return Ok(None);
206    };
207    let Some(raw_profile_object) = raw_profile_value.as_object() else {
208        return Err(ExecutionProfileError::ExecutionProfileNotObject);
209    };
210
211    let raw_profile: RawExecutionProfile =
212        serde_json::from_value(Value::Object(raw_profile_object.clone())).map_err(|e| {
213            ExecutionProfileError::InvalidExecutionProfile {
214                message: e.to_string(),
215            }
216        })?;
217
218    Ok(Some(ExecutionProfile {
219        confidentiality: raw_profile.confidentiality.unwrap_or_default(),
220        gpu: raw_profile.gpu.unwrap_or_default().normalized(),
221    }))
222}
223
224/// Resolve GPU requirements from metadata.
225pub fn resolve_gpu_requirements(
226    metadata: &ITangleTypes::BlueprintMetadata,
227) -> Result<Option<GpuRequirements>, ExecutionProfileError> {
228    Ok(resolve_execution_profile(metadata)?.map(|profile| profile.gpu))
229}
230
231/// Resolve explicit confidentiality policy from metadata.
232pub fn resolve_confidentiality_policy(
233    metadata: &ITangleTypes::BlueprintMetadata,
234) -> Result<Option<ConfidentialityPolicy>, ExecutionProfileError> {
235    Ok(resolve_execution_profile(metadata)?.map(|profile| profile.confidentiality))
236}
237
238/// Inject or update structured execution profile inside `profiling_data`.
239///
240/// If `profiling_data` currently contains a non-JSON blob, it is preserved
241/// under `job_profiles_b64_gzip`.
242#[must_use]
243pub fn inject_execution_profile(profiling_data: &str, profile: ExecutionProfile) -> String {
244    let trimmed = profiling_data.trim();
245    if trimmed.is_empty() {
246        return default_metadata_payload(profile).to_string();
247    }
248
249    if let Ok(mut value) = serde_json::from_str::<Value>(trimmed)
250        && let Some(root) = value.as_object_mut()
251    {
252        if let Some(schema) = root.get("schema").and_then(Value::as_str)
253            && schema != METADATA_SCHEMA_V1
254        {
255            return json!({
256                "schema": METADATA_SCHEMA_V1,
257                EXECUTION_PROFILE_KEY: profile_to_value(profile),
258                JOB_PROFILES_BLOB_KEY: trimmed,
259            })
260            .to_string();
261        }
262        upsert_execution_profile(root, profile);
263        return value.to_string();
264    }
265
266    json!({
267        "schema": METADATA_SCHEMA_V1,
268        EXECUTION_PROFILE_KEY: profile_to_value(profile),
269        JOB_PROFILES_BLOB_KEY: trimmed,
270    })
271    .to_string()
272}
273
274/// Extract compressed/base64 job profile payload from structured metadata.
275#[must_use]
276pub fn extract_job_profiles_blob(profiling_data: &str) -> Option<String> {
277    let trimmed = profiling_data.trim();
278    if trimmed.is_empty() {
279        return None;
280    }
281
282    let value: Value = serde_json::from_str(trimmed).ok()?;
283    value
284        .as_object()?
285        .get(JOB_PROFILES_BLOB_KEY)
286        .and_then(Value::as_str)
287        .map(ToOwned::to_owned)
288}
289
290fn default_metadata_payload(profile: ExecutionProfile) -> Value {
291    json!({
292        "schema": METADATA_SCHEMA_V1,
293        EXECUTION_PROFILE_KEY: profile_to_value(profile),
294    })
295}
296
297fn profile_to_value(profile: ExecutionProfile) -> Value {
298    let mut obj = Map::new();
299    obj.insert(
300        "confidentiality".to_string(),
301        serde_json::to_value(profile.confidentiality).unwrap_or_default(),
302    );
303    if !matches!(profile.gpu.policy, GpuPolicy::None) {
304        obj.insert(
305            "gpu".to_string(),
306            serde_json::to_value(profile.gpu).unwrap_or_default(),
307        );
308    }
309    Value::Object(obj)
310}
311
312fn upsert_execution_profile(root: &mut Map<String, Value>, profile: ExecutionProfile) {
313    root.insert(
314        "schema".to_string(),
315        Value::String(METADATA_SCHEMA_V1.to_string()),
316    );
317    root.insert(EXECUTION_PROFILE_KEY.to_string(), profile_to_value(profile));
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::contracts::ITangleTypes;
324
325    #[test]
326    fn resolves_required_profile() {
327        let mut metadata: ITangleTypes::BlueprintMetadata = Default::default();
328        metadata.profilingData =
329            r#"{"execution_profile":{"confidentiality":"tee_required"}}"#.into();
330        assert_eq!(
331            resolve_execution_profile(&metadata).unwrap(),
332            Some(ExecutionProfile {
333                confidentiality: ConfidentialityPolicy::TeeRequired,
334                ..Default::default()
335            })
336        );
337    }
338
339    #[test]
340    fn resolves_optional_profile() {
341        let mut metadata: ITangleTypes::BlueprintMetadata = Default::default();
342        metadata.profilingData =
343            r#"{"execution_profile":{"confidentiality":"tee_preferred"}}"#.into();
344        assert_eq!(
345            resolve_execution_profile(&metadata).unwrap(),
346            Some(ExecutionProfile {
347                confidentiality: ConfidentialityPolicy::TeePreferred,
348                ..Default::default()
349            })
350        );
351    }
352
353    #[test]
354    fn strict_parser_errors_on_non_json_payloads() {
355        let err =
356            resolve_execution_profile_from_profiling_data("tee").expect_err("expected parse error");
357        assert!(matches!(err, ExecutionProfileError::InvalidJson { .. }));
358    }
359
360    #[test]
361    fn strict_parser_errors_on_non_object_json_payloads() {
362        let err =
363            resolve_execution_profile_from_profiling_data("[]").expect_err("expected type error");
364        assert!(matches!(err, ExecutionProfileError::MetadataNotObject));
365    }
366
367    #[test]
368    fn strict_parser_errors_on_non_string_confidentiality() {
369        let err = resolve_execution_profile_from_profiling_data(
370            r#"{"execution_profile":{"confidentiality":true}}"#,
371        )
372        .expect_err("expected type error");
373        assert!(matches!(
374            err,
375            ExecutionProfileError::InvalidExecutionProfile { .. }
376        ));
377    }
378
379    #[test]
380    fn strict_parser_errors_on_unknown_fields() {
381        let err = resolve_execution_profile_from_profiling_data(
382            r#"{"execution_profile":{"confidentiality":"tee_required","bad":true}}"#,
383        )
384        .expect_err("expected schema error");
385        assert!(matches!(
386            err,
387            ExecutionProfileError::InvalidExecutionProfile { .. }
388        ));
389    }
390
391    #[test]
392    fn resolves_gpu_required_profile() {
393        let profile = resolve_execution_profile_from_profiling_data(
394            r#"{"execution_profile":{"confidentiality":"any","gpu":{"policy":"required","min_count":2,"min_vram_gb":40}}}"#,
395        )
396        .unwrap()
397        .unwrap();
398        assert!(profile.gpu_required());
399        assert_eq!(profile.gpu.min_count, 2);
400        assert_eq!(profile.gpu.min_vram_gb, 40);
401    }
402
403    #[test]
404    fn resolves_gpu_preferred_profile() {
405        let profile = resolve_execution_profile_from_profiling_data(
406            r#"{"execution_profile":{"gpu":{"policy":"preferred","min_count":1,"min_vram_gb":24}}}"#,
407        )
408        .unwrap()
409        .unwrap();
410        assert!(profile.gpu_preferred());
411        assert!(!profile.gpu_required());
412        assert_eq!(profile.gpu.min_count, 1);
413    }
414
415    #[test]
416    fn defaults_gpu_to_none_when_absent() {
417        let profile = resolve_execution_profile_from_profiling_data(
418            r#"{"execution_profile":{"confidentiality":"tee_required"}}"#,
419        )
420        .unwrap()
421        .unwrap();
422        assert!(!profile.needs_gpu());
423        assert_eq!(profile.gpu.policy, GpuPolicy::None);
424    }
425
426    #[test]
427    fn resolves_combined_tee_and_gpu() {
428        let profile = resolve_execution_profile_from_profiling_data(
429            r#"{"execution_profile":{"confidentiality":"tee_required","gpu":{"policy":"required","min_count":1,"min_vram_gb":80}}}"#,
430        )
431        .unwrap()
432        .unwrap();
433        assert!(profile.tee_required());
434        assert!(profile.gpu_required());
435        assert_eq!(profile.gpu.min_vram_gb, 80);
436    }
437
438    #[test]
439    fn injects_gpu_profile_into_empty_payload() {
440        let payload = inject_execution_profile(
441            "",
442            ExecutionProfile {
443                confidentiality: ConfidentialityPolicy::Any,
444                gpu: GpuRequirements {
445                    policy: GpuPolicy::Required,
446                    min_count: 1,
447                    min_vram_gb: 24,
448                },
449            },
450        );
451        let value: Value = serde_json::from_str(&payload).unwrap();
452        let gpu = value
453            .get(EXECUTION_PROFILE_KEY)
454            .and_then(|v| v.get("gpu"))
455            .expect("gpu field should be present");
456        assert_eq!(gpu.get("policy").and_then(Value::as_str), Some("required"));
457        assert_eq!(gpu.get("min_count").and_then(Value::as_u64), Some(1));
458    }
459
460    #[test]
461    fn omits_gpu_from_profile_when_none() {
462        let payload = inject_execution_profile(
463            "",
464            ExecutionProfile {
465                confidentiality: ConfidentialityPolicy::Any,
466                gpu: GpuRequirements::default(),
467            },
468        );
469        let value: Value = serde_json::from_str(&payload).unwrap();
470        let profile = value.get(EXECUTION_PROFILE_KEY).unwrap();
471        assert!(
472            profile.get("gpu").is_none(),
473            "gpu field should be omitted when policy is none"
474        );
475    }
476
477    #[test]
478    fn injects_into_empty_payload() {
479        let payload = inject_execution_profile(
480            "",
481            ExecutionProfile {
482                confidentiality: ConfidentialityPolicy::Any,
483                ..Default::default()
484            },
485        );
486        let value: Value = serde_json::from_str(&payload).unwrap();
487        assert_eq!(
488            value
489                .get(EXECUTION_PROFILE_KEY)
490                .and_then(|v| v.get("confidentiality"))
491                .and_then(Value::as_str),
492            Some("any")
493        );
494    }
495
496    #[test]
497    fn updates_existing_object_payload() {
498        let payload = inject_execution_profile(
499            r#"{"job_profiles_b64_gzip":"abc"}"#,
500            ExecutionProfile {
501                confidentiality: ConfidentialityPolicy::TeeRequired,
502                ..Default::default()
503            },
504        );
505        let value: Value = serde_json::from_str(&payload).unwrap();
506        assert_eq!(
507            value
508                .get(EXECUTION_PROFILE_KEY)
509                .and_then(|v| v.get("confidentiality"))
510                .and_then(Value::as_str),
511            Some("tee_required")
512        );
513        assert_eq!(
514            value.get(JOB_PROFILES_BLOB_KEY).and_then(Value::as_str),
515            Some("abc")
516        );
517    }
518
519    #[test]
520    fn wraps_non_json_payload_as_job_profiles_blob() {
521        let payload = inject_execution_profile(
522            "H4sIAAAAAAAA/2NgYGBgBGIOAwA6rY+4BQAAAA==",
523            ExecutionProfile {
524                confidentiality: ConfidentialityPolicy::TeeRequired,
525                ..Default::default()
526            },
527        );
528        let value: Value = serde_json::from_str(&payload).unwrap();
529        assert_eq!(
530            value.get(JOB_PROFILES_BLOB_KEY).and_then(Value::as_str),
531            Some("H4sIAAAAAAAA/2NgYGBgBGIOAwA6rY+4BQAAAA==")
532        );
533    }
534
535    #[test]
536    fn extracts_profiles_blob_from_structured_payload() {
537        let payload = r#"{"execution_profile":{"confidentiality":"tee_required"},"job_profiles_b64_gzip":"abc"}"#;
538        assert_eq!(extract_job_profiles_blob(payload), Some("abc".to_string()));
539    }
540
541    #[test]
542    fn extract_profiles_blob_requires_structured_payload() {
543        assert_eq!(extract_job_profiles_blob("H4sIAAAAA..."), None);
544    }
545
546    #[test]
547    fn normalizes_required_with_zero_min_count() {
548        let profile = resolve_execution_profile_from_profiling_data(
549            r#"{"execution_profile":{"gpu":{"policy":"required"}}}"#,
550        )
551        .unwrap()
552        .unwrap();
553        assert!(profile.gpu_required());
554        assert_eq!(
555            profile.gpu.min_count, 1,
556            "Required policy must normalize min_count to at least 1"
557        );
558    }
559
560    #[test]
561    fn normalizes_preferred_with_zero_min_count() {
562        let profile = resolve_execution_profile_from_profiling_data(
563            r#"{"execution_profile":{"gpu":{"policy":"preferred"}}}"#,
564        )
565        .unwrap()
566        .unwrap();
567        assert!(profile.gpu_preferred());
568        assert_eq!(
569            profile.gpu.min_count, 1,
570            "Preferred policy must normalize min_count to at least 1"
571        );
572    }
573
574    #[test]
575    fn gpu_inject_then_resolve_round_trip() {
576        let original = ExecutionProfile {
577            confidentiality: ConfidentialityPolicy::Any,
578            gpu: GpuRequirements {
579                policy: GpuPolicy::Required,
580                min_count: 2,
581                min_vram_gb: 80,
582            },
583        };
584        let payload = inject_execution_profile("", original);
585        let resolved = resolve_execution_profile_from_profiling_data(&payload)
586            .unwrap()
587            .unwrap();
588        assert_eq!(resolved, original, "inject then resolve must round-trip");
589    }
590
591    #[test]
592    fn gpu_rejects_unknown_fields_in_requirements() {
593        let err = resolve_execution_profile_from_profiling_data(
594            r#"{"execution_profile":{"gpu":{"policy":"required","unknown_field":true}}}"#,
595        )
596        .expect_err("expected error for unknown GPU field");
597        assert!(matches!(
598            err,
599            ExecutionProfileError::InvalidExecutionProfile { .. }
600        ));
601    }
602}