Skip to main content

greentic_operator/offers/
registry.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::io::Read;
3use std::path::{Path, PathBuf};
4
5use anyhow::Context;
6use serde_json::Value as JsonValue;
7use zip::ZipArchive;
8use zip::result::ZipError;
9
10pub const HOOK_STAGE_POST_INGRESS: &str = "post_ingress";
11pub const HOOK_CONTRACT_CONTROL_V1: &str = "greentic.hook.control.v1";
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
14pub enum OfferKind {
15    Hook,
16    Subs,
17    Capability,
18}
19
20impl OfferKind {
21    fn parse(raw: &str) -> Option<Self> {
22        match raw.trim().to_ascii_lowercase().as_str() {
23            "hook" => Some(Self::Hook),
24            "subs" => Some(Self::Subs),
25            "capability" => Some(Self::Capability),
26            _ => None,
27        }
28    }
29
30    pub fn as_str(&self) -> &'static str {
31        match self {
32            Self::Hook => "hook",
33            Self::Subs => "subs",
34            Self::Capability => "capability",
35        }
36    }
37}
38
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct Offer {
41    pub offer_key: String,
42    pub pack_id: String,
43    pub pack_ref: PathBuf,
44    pub id: String,
45    pub kind: OfferKind,
46    pub priority: i32,
47    pub provider_op: String,
48    pub stage: Option<String>,
49    pub contract: Option<String>,
50}
51
52#[derive(Clone, Debug)]
53pub struct PackOffers {
54    pub pack_id: String,
55    pub pack_ref: PathBuf,
56    pub offers: Vec<PackOffer>,
57}
58
59#[derive(Clone, Debug)]
60pub struct PackOffer {
61    pub id: String,
62    pub kind: OfferKind,
63    pub priority: i32,
64    pub provider_op: String,
65    pub stage: Option<String>,
66    pub contract: Option<String>,
67}
68
69#[derive(Clone, Debug, Default)]
70pub struct OfferRegistry {
71    by_key: BTreeMap<String, Offer>,
72    pack_refs: BTreeMap<String, PathBuf>,
73}
74
75impl OfferRegistry {
76    pub fn from_pack_refs(pack_refs: &[PathBuf]) -> anyhow::Result<Self> {
77        let mut registry = Self::default();
78        for pack_ref in pack_refs {
79            let parsed = load_pack_offers(pack_ref)?;
80            registry.register_pack(parsed)?;
81        }
82        Ok(registry)
83    }
84
85    pub fn register_pack(&mut self, pack: PackOffers) -> anyhow::Result<()> {
86        if let Some(existing_ref) = self.pack_refs.get(&pack.pack_id)
87            && existing_ref != &pack.pack_ref
88        {
89            anyhow::bail!(
90                "duplicate pack_id {} across packs: {} and {}",
91                pack.pack_id,
92                existing_ref.display(),
93                pack.pack_ref.display()
94            );
95        }
96        self.pack_refs
97            .entry(pack.pack_id.clone())
98            .or_insert_with(|| pack.pack_ref.clone());
99
100        for offer in pack.offers {
101            let offer_key = offer_key(&pack.pack_id, &offer.id);
102            let record = Offer {
103                offer_key: offer_key.clone(),
104                pack_id: pack.pack_id.clone(),
105                pack_ref: pack.pack_ref.clone(),
106                id: offer.id,
107                kind: offer.kind,
108                priority: offer.priority,
109                provider_op: offer.provider_op,
110                stage: offer.stage,
111                contract: offer.contract,
112            };
113            self.by_key.insert(offer_key, record);
114        }
115        Ok(())
116    }
117
118    pub fn offers_total(&self) -> usize {
119        self.by_key.len()
120    }
121
122    pub fn packs_total(&self) -> usize {
123        self.pack_refs.len()
124    }
125
126    pub fn kind_counts(&self) -> BTreeMap<&'static str, usize> {
127        let mut counts = BTreeMap::new();
128        for offer in self.by_key.values() {
129            *counts.entry(offer.kind.as_str()).or_insert(0) += 1;
130        }
131        counts
132    }
133
134    pub fn hook_counts_by_stage_contract(&self) -> Vec<(String, String, usize)> {
135        let mut counts: BTreeMap<(String, String), usize> = BTreeMap::new();
136        for offer in self.by_key.values() {
137            if offer.kind != OfferKind::Hook {
138                continue;
139            }
140            let Some(stage) = offer.stage.clone() else {
141                continue;
142            };
143            let Some(contract) = offer.contract.clone() else {
144                continue;
145            };
146            *counts.entry((stage, contract)).or_insert(0) += 1;
147        }
148        counts
149            .into_iter()
150            .map(|((stage, contract), count)| (stage, contract, count))
151            .collect()
152    }
153
154    pub fn subs_counts_by_contract(&self) -> Vec<(String, usize)> {
155        let mut counts: BTreeMap<String, usize> = BTreeMap::new();
156        for offer in self.by_key.values() {
157            if offer.kind != OfferKind::Subs {
158                continue;
159            }
160            let contract = offer
161                .contract
162                .clone()
163                .unwrap_or_else(|| "<none>".to_string());
164            *counts.entry(contract).or_insert(0) += 1;
165        }
166        counts.into_iter().collect()
167    }
168
169    pub fn select_hooks(&self, stage: &str, contract: &str) -> Vec<&Offer> {
170        let mut selected = self
171            .by_key
172            .values()
173            .filter(|offer| {
174                offer.kind == OfferKind::Hook
175                    && offer.stage.as_deref() == Some(stage)
176                    && offer.contract.as_deref() == Some(contract)
177            })
178            .collect::<Vec<_>>();
179        selected.sort_by(|a, b| {
180            a.priority
181                .cmp(&b.priority)
182                .then_with(|| a.offer_key.cmp(&b.offer_key))
183        });
184        selected
185    }
186
187    pub fn select_subs(&self, contract: Option<&str>) -> Vec<&Offer> {
188        let mut selected = self
189            .by_key
190            .values()
191            .filter(|offer| {
192                offer.kind == OfferKind::Subs
193                    && contract
194                        .map(|expected| offer.contract.as_deref() == Some(expected))
195                        .unwrap_or(true)
196            })
197            .collect::<Vec<_>>();
198        selected.sort_by(|a, b| {
199            a.priority
200                .cmp(&b.priority)
201                .then_with(|| a.offer_key.cmp(&b.offer_key))
202        });
203        selected
204    }
205}
206
207pub fn offer_key(pack_id: &str, offer_id: &str) -> String {
208    format!("{pack_id}::{offer_id}")
209}
210
211pub fn discover_gtpacks(root: &Path) -> anyhow::Result<Vec<PathBuf>> {
212    let mut files = Vec::new();
213    let mut stack = vec![root.to_path_buf()];
214    while let Some(dir) = stack.pop() {
215        for entry in std::fs::read_dir(&dir)? {
216            let entry = entry?;
217            let path = entry.path();
218            if entry.file_type()?.is_dir() {
219                stack.push(path);
220                continue;
221            }
222            if path.extension().and_then(|ext| ext.to_str()) == Some("gtpack") {
223                files.push(path);
224            }
225        }
226    }
227    files.sort();
228    Ok(files)
229}
230
231pub fn load_pack_offers(pack_ref: &Path) -> anyhow::Result<PackOffers> {
232    let file = std::fs::File::open(pack_ref)?;
233    let mut archive = ZipArchive::new(file)?;
234    let mut manifest_entry = archive.by_name("manifest.cbor").map_err(|err| match err {
235        ZipError::FileNotFound => {
236            anyhow::anyhow!("manifest.cbor missing in {}", pack_ref.display())
237        }
238        other => anyhow::anyhow!(
239            "failed to read manifest.cbor in {}: {other}",
240            pack_ref.display()
241        ),
242    })?;
243    let mut bytes = Vec::new();
244    manifest_entry.read_to_end(&mut bytes)?;
245    let manifest: JsonValue = serde_cbor::from_slice(&bytes)
246        .with_context(|| format!("decode manifest.cbor {}", pack_ref.display()))?;
247
248    let pack_id = manifest_pack_id(&manifest).ok_or_else(|| {
249        anyhow::anyhow!("pack manifest missing pack id in {}", pack_ref.display())
250    })?;
251    let offers = parse_pack_offers(&manifest, pack_ref)?;
252    Ok(PackOffers {
253        pack_id,
254        pack_ref: pack_ref.to_path_buf(),
255        offers,
256    })
257}
258
259fn parse_pack_offers(manifest: &JsonValue, pack_ref: &Path) -> anyhow::Result<Vec<PackOffer>> {
260    let mut offers_raw: Vec<&JsonValue> = Vec::new();
261    if let Some(array) = manifest.get("offers").and_then(JsonValue::as_array) {
262        offers_raw.extend(array);
263    }
264    if let Some(extensions) = manifest.get("extensions").and_then(JsonValue::as_object) {
265        for ext in extensions.values() {
266            if let Some(array) = ext.get("offers").and_then(JsonValue::as_array) {
267                offers_raw.extend(array);
268            }
269            if let Some(array) = ext
270                .get("inline")
271                .and_then(|value| value.get("offers"))
272                .and_then(JsonValue::as_array)
273            {
274                offers_raw.extend(array);
275            }
276        }
277    }
278
279    let mut parsed = Vec::new();
280    let mut seen_ids = BTreeSet::new();
281    for raw in offers_raw {
282        let Some(raw_obj) = raw.as_object() else {
283            continue;
284        };
285        let candidate = raw_obj.contains_key("id")
286            || raw_obj.contains_key("offer_id")
287            || raw_obj.contains_key("kind")
288            || raw_obj.contains_key("cap_id");
289        if !candidate {
290            continue;
291        }
292
293        let id = raw_obj
294            .get("id")
295            .or_else(|| raw_obj.get("offer_id"))
296            .and_then(JsonValue::as_str)
297            .map(str::trim)
298            .filter(|value| !value.is_empty())
299            .ok_or_else(|| anyhow::anyhow!("offer id missing in {}", pack_ref.display()))?
300            .to_string();
301        if !seen_ids.insert(id.clone()) {
302            anyhow::bail!("duplicate offer id {} in {}", id, pack_ref.display());
303        }
304
305        let kind = raw_obj
306            .get("kind")
307            .and_then(JsonValue::as_str)
308            .and_then(OfferKind::parse)
309            .or_else(|| {
310                if raw_obj.contains_key("cap_id") {
311                    Some(OfferKind::Capability)
312                } else {
313                    None
314                }
315            })
316            .ok_or_else(|| {
317                anyhow::anyhow!(
318                    "offer kind missing/invalid for {} in {}",
319                    id,
320                    pack_ref.display()
321                )
322            })?;
323
324        let provider_op = raw_obj
325            .get("provider")
326            .and_then(|value| value.get("op"))
327            .and_then(JsonValue::as_str)
328            .map(str::trim)
329            .filter(|value| !value.is_empty())
330            .ok_or_else(|| {
331                anyhow::anyhow!(
332                    "provider.op missing for offer {} in {}",
333                    id,
334                    pack_ref.display()
335                )
336            })?
337            .to_string();
338
339        let priority = raw_obj
340            .get("priority")
341            .and_then(JsonValue::as_i64)
342            .map(|value| value as i32)
343            .unwrap_or(100);
344
345        let stage = raw_obj
346            .get("stage")
347            .and_then(JsonValue::as_str)
348            .map(str::trim)
349            .filter(|value| !value.is_empty())
350            .map(ToString::to_string);
351        let contract = raw_obj
352            .get("contract")
353            .and_then(JsonValue::as_str)
354            .map(str::trim)
355            .filter(|value| !value.is_empty())
356            .map(ToString::to_string);
357
358        parsed.push(PackOffer {
359            id,
360            kind,
361            priority,
362            provider_op,
363            stage,
364            contract,
365        });
366    }
367
368    Ok(parsed)
369}
370
371fn manifest_pack_id(manifest: &JsonValue) -> Option<String> {
372    manifest
373        .get("meta")
374        .and_then(|value| value.get("pack_id"))
375        .and_then(JsonValue::as_str)
376        .or_else(|| manifest.get("pack_id").and_then(JsonValue::as_str))
377        .map(str::trim)
378        .filter(|value| !value.is_empty())
379        .map(ToString::to_string)
380        .or_else(|| resolve_symbol_pack_id(manifest))
381}
382
383/// Resolve pack_id when stored as a symbol index (integer) referencing the
384/// symbols.pack_ids array in the manifest.
385fn resolve_symbol_pack_id(manifest: &JsonValue) -> Option<String> {
386    let idx = manifest
387        .get("meta")
388        .and_then(|m| m.get("pack_id"))
389        .or_else(|| manifest.get("pack_id"))
390        .and_then(JsonValue::as_u64)? as usize;
391    manifest
392        .get("symbols")
393        .and_then(|s| s.get("pack_ids"))
394        .and_then(JsonValue::as_array)
395        .and_then(|arr| arr.get(idx))
396        .and_then(JsonValue::as_str)
397        .map(ToString::to_string)
398}
399
400#[cfg(test)]
401mod tests {
402    use std::io::Write;
403
404    use serde_json::json;
405    use tempfile::tempdir;
406    use zip::write::FileOptions;
407
408    use super::*;
409
410    #[test]
411    fn duplicate_offer_ids_within_pack_fail() {
412        let tmp = tempdir().expect("tempdir");
413        let pack_path = tmp.path().join("dup.gtpack");
414        write_manifest_pack(
415            &pack_path,
416            &json!({
417                "meta": { "pack_id": "pack-a" },
418                "extensions": {
419                    "greentic.ext.offers.v1": {
420                        "inline": {
421                            "offers": [
422                                { "id": "x", "kind": "hook", "provider": { "op": "hook_a" } },
423                                { "id": "x", "kind": "hook", "provider": { "op": "hook_b" } }
424                            ]
425                        }
426                    }
427                }
428            }),
429        );
430
431        let err = load_pack_offers(&pack_path).unwrap_err().to_string();
432        assert!(err.contains("duplicate offer id"));
433    }
434
435    #[test]
436    fn duplicate_pack_id_across_packs_fail() {
437        let tmp = tempdir().expect("tempdir");
438        let pack_a = tmp.path().join("a.gtpack");
439        let pack_b = tmp.path().join("b.gtpack");
440        write_manifest_pack(
441            &pack_a,
442            &json!({
443                "meta": { "pack_id": "pack-a" },
444                "offers": [
445                    { "id": "one", "kind": "capability", "provider": { "op": "op_a" } }
446                ]
447            }),
448        );
449        write_manifest_pack(
450            &pack_b,
451            &json!({
452                "meta": { "pack_id": "pack-a" },
453                "offers": [
454                    { "id": "two", "kind": "capability", "provider": { "op": "op_b" } }
455                ]
456            }),
457        );
458
459        let err = OfferRegistry::from_pack_refs(&[pack_a, pack_b])
460            .unwrap_err()
461            .to_string();
462        assert!(err.contains("duplicate pack_id"));
463    }
464
465    #[test]
466    fn hook_selection_is_priority_then_offer_key() {
467        let mut registry = OfferRegistry::default();
468        registry
469            .register_pack(PackOffers {
470                pack_id: "pack-b".to_string(),
471                pack_ref: PathBuf::from("/tmp/pack-b.gtpack"),
472                offers: vec![PackOffer {
473                    id: "offer-b".to_string(),
474                    kind: OfferKind::Hook,
475                    priority: 100,
476                    provider_op: "hook_b".to_string(),
477                    stage: Some(HOOK_STAGE_POST_INGRESS.to_string()),
478                    contract: Some(HOOK_CONTRACT_CONTROL_V1.to_string()),
479                }],
480            })
481            .expect("register b");
482        registry
483            .register_pack(PackOffers {
484                pack_id: "pack-a".to_string(),
485                pack_ref: PathBuf::from("/tmp/pack-a.gtpack"),
486                offers: vec![
487                    PackOffer {
488                        id: "offer-a".to_string(),
489                        kind: OfferKind::Hook,
490                        priority: 100,
491                        provider_op: "hook_a".to_string(),
492                        stage: Some(HOOK_STAGE_POST_INGRESS.to_string()),
493                        contract: Some(HOOK_CONTRACT_CONTROL_V1.to_string()),
494                    },
495                    PackOffer {
496                        id: "offer-c".to_string(),
497                        kind: OfferKind::Hook,
498                        priority: 10,
499                        provider_op: "hook_c".to_string(),
500                        stage: Some(HOOK_STAGE_POST_INGRESS.to_string()),
501                        contract: Some(HOOK_CONTRACT_CONTROL_V1.to_string()),
502                    },
503                ],
504            })
505            .expect("register a");
506
507        let selected = registry.select_hooks(HOOK_STAGE_POST_INGRESS, HOOK_CONTRACT_CONTROL_V1);
508        let keys = selected
509            .iter()
510            .map(|offer| offer.offer_key.clone())
511            .collect::<Vec<_>>();
512        assert_eq!(
513            keys,
514            vec![
515                "pack-a::offer-c".to_string(),
516                "pack-a::offer-a".to_string(),
517                "pack-b::offer-b".to_string()
518            ]
519        );
520    }
521
522    #[test]
523    fn subs_selection_filters_and_sorts() {
524        let mut registry = OfferRegistry::default();
525        registry
526            .register_pack(PackOffers {
527                pack_id: "pack-s".to_string(),
528                pack_ref: PathBuf::from("/tmp/pack-s.gtpack"),
529                offers: vec![
530                    PackOffer {
531                        id: "subs-a".to_string(),
532                        kind: OfferKind::Subs,
533                        priority: 100,
534                        provider_op: "subs_a".to_string(),
535                        stage: Some("post_ingress".to_string()),
536                        contract: Some("contract-a".to_string()),
537                    },
538                    PackOffer {
539                        id: "subs-b".to_string(),
540                        kind: OfferKind::Subs,
541                        priority: 10,
542                        provider_op: "subs_b".to_string(),
543                        stage: Some("post_ingress".to_string()),
544                        contract: Some("contract-b".to_string()),
545                    },
546                ],
547            })
548            .expect("register subs");
549
550        let filtered = registry.select_subs(Some("contract-a"));
551        assert_eq!(filtered.len(), 1);
552        assert_eq!(filtered[0].offer_key, "pack-s::subs-a");
553
554        let all = registry.select_subs(None);
555        let keys = all
556            .iter()
557            .map(|offer| offer.offer_key.clone())
558            .collect::<Vec<_>>();
559        assert_eq!(
560            keys,
561            vec!["pack-s::subs-b".to_string(), "pack-s::subs-a".to_string()]
562        );
563    }
564
565    fn write_manifest_pack(path: &Path, manifest: &JsonValue) {
566        let file = std::fs::File::create(path).expect("create gtpack");
567        let mut zip = zip::ZipWriter::new(file);
568        zip.start_file("manifest.cbor", FileOptions::<()>::default())
569            .expect("start manifest");
570        let bytes = serde_cbor::to_vec(manifest).expect("manifest cbor");
571        zip.write_all(&bytes).expect("write manifest");
572        zip.finish().expect("finish zip");
573    }
574}