Skip to main content

greentic_bundle/cli/info/
report.rs

1use anyhow::{Context, Result};
2use greentic_bundle_reader::{
3    BundleLock, BundleManifest, BundleResolvedTargetView, BundleSourceKind, DependencyLock,
4    OpenedBundle,
5};
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9
10use super::pack_probe::{self, PackMetaSlim};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct InfoReport {
14    pub info_schema_version: u32,
15    pub bundle_id: String,
16    pub name: String,
17    pub version: Option<String>,
18    pub description: Option<String>,
19    pub mode: String,
20    pub locale: String,
21    pub app_packs: Vec<PackRef>,
22    pub extension_providers: Vec<PackRef>,
23    pub catalogs: Vec<CatalogRef>,
24    pub access: AccessSummary,
25    pub capabilities: Vec<String>,
26    pub hooks: Vec<String>,
27    pub subscriptions: Vec<String>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PackRef {
32    pub reference: String,
33    pub version: Option<String>,
34    pub digest: Option<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct CatalogRef {
39    pub name: String,
40    pub item_count: u32,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct AccessSummary {
45    pub tenants: u32,
46    pub teams: u32,
47    pub targets: Vec<AccessTarget>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct AccessTarget {
52    pub tenant: String,
53    pub team_count: u32,
54    pub default_policy: String,
55}
56
57impl InfoReport {
58    /// Project a `.gtbundle` artifact's opened bundle into the info report shape.
59    ///
60    /// When the bundle was opened from an artifact (`.gtbundle` SquashFS), we
61    /// additionally probe each inlined `.gtpack` file for its `manifest.cbor`
62    /// and populate the per-pack `version` column. Probing is best-effort:
63    /// missing or unreadable pack manifests leave `version = None` rather than
64    /// failing the whole command.
65    pub fn from_opened_bundle(opened: &OpenedBundle) -> Self {
66        let meta = pack_metadata_for(opened);
67        project(&opened.manifest, &opened.lock, &meta)
68    }
69
70    /// Read a bundle workspace directory and produce the same `InfoReport`.
71    ///
72    /// Workspace inputs don't have inlined packs, so `version` stays None for
73    /// every pack. Users see versions once they build to a `.gtbundle`.
74    pub fn from_workspace(path: &Path) -> Result<Self> {
75        let report = crate::build::inspect_target(Some(path), None)
76            .with_context(|| format!("reading bundle workspace at {}", path.display()))?;
77        Ok(project(&report.manifest, &report.lock, &BTreeMap::new()))
78    }
79}
80
81/// Gather `{ reference -> PackMetaSlim }` for packs referenced by an opened
82/// artifact bundle. Returns empty for non-artifact sources.
83fn pack_metadata_for(opened: &OpenedBundle) -> BTreeMap<String, PackMetaSlim> {
84    if !matches!(opened.source_kind, BundleSourceKind::Artifact) {
85        return BTreeMap::new();
86    }
87    let artifact_path = PathBuf::from(&opened.source_path);
88    let mut refs: Vec<&str> = Vec::new();
89    for lock in &opened.lock.app_packs {
90        refs.push(lock.reference.as_str());
91    }
92    for lock in &opened.lock.extension_providers {
93        refs.push(lock.reference.as_str());
94    }
95    pack_probe::probe_inlined_packs(&artifact_path, &refs)
96}
97
98fn project(
99    manifest: &BundleManifest,
100    lock: &BundleLock,
101    pack_meta: &BTreeMap<String, PackMetaSlim>,
102) -> InfoReport {
103    InfoReport {
104        info_schema_version: 1,
105        bundle_id: manifest.bundle_id.clone(),
106        name: manifest.bundle_name.clone(),
107        version: None,
108        description: None,
109        mode: manifest.requested_mode.clone(),
110        locale: manifest.locale.clone(),
111        app_packs: project_packs(&manifest.app_packs, &lock.app_packs, pack_meta),
112        extension_providers: project_packs(
113            &manifest.extension_providers,
114            &lock.extension_providers,
115            pack_meta,
116        ),
117        catalogs: lock
118            .catalogs
119            .iter()
120            .map(|c| CatalogRef {
121                name: catalog_display_name(&c.resolved_ref, &c.requested_ref),
122                item_count: c.item_count as u32,
123            })
124            .collect(),
125        access: access_summary(&manifest.resolved_targets),
126        capabilities: manifest.capabilities.clone(),
127        hooks: manifest.hooks.clone(),
128        subscriptions: manifest.subscriptions.clone(),
129    }
130}
131
132fn project_packs(
133    names: &[String],
134    locks: &[DependencyLock],
135    pack_meta: &BTreeMap<String, PackMetaSlim>,
136) -> Vec<PackRef> {
137    names
138        .iter()
139        .map(|name| {
140            let digest = locks
141                .iter()
142                .find(|l| l.reference == *name)
143                .and_then(|l| l.digest.clone());
144            // Look up probed metadata by the manifest-declared reference
145            // (matches the key used in `pack_metadata_for`).
146            let version = pack_meta.get(name).and_then(|m| m.version.clone());
147            PackRef {
148                reference: name.clone(),
149                version,
150                digest,
151            }
152        })
153        .collect()
154}
155
156fn catalog_display_name(resolved: &str, requested: &str) -> String {
157    if !resolved.is_empty() {
158        resolved.to_string()
159    } else {
160        requested.to_string()
161    }
162}
163
164fn access_summary(targets: &[BundleResolvedTargetView]) -> AccessSummary {
165    use std::collections::BTreeMap;
166    let mut per_tenant: BTreeMap<String, (u32, Option<String>)> = BTreeMap::new();
167    for t in targets {
168        let entry = per_tenant.entry(t.tenant.clone()).or_insert((0, None));
169        if t.team.is_some() {
170            entry.0 += 1;
171        }
172        if entry.1.is_none() {
173            entry.1 = Some(t.default_policy.clone());
174        }
175    }
176    let teams: u32 = per_tenant.values().map(|(c, _)| *c).sum();
177    AccessSummary {
178        tenants: per_tenant.len() as u32,
179        teams,
180        targets: per_tenant
181            .into_iter()
182            .map(|(tenant, (team_count, pol))| AccessTarget {
183                tenant,
184                team_count,
185                default_policy: pol.unwrap_or_else(|| "public".into()),
186            })
187            .collect(),
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn json_has_schema_version_one() {
197        let report = InfoReport {
198            info_schema_version: 1,
199            bundle_id: "b".into(),
200            name: "b".into(),
201            version: None,
202            description: None,
203            mode: "production".into(),
204            locale: "en".into(),
205            app_packs: vec![],
206            extension_providers: vec![],
207            catalogs: vec![],
208            access: AccessSummary {
209                tenants: 0,
210                teams: 0,
211                targets: vec![],
212            },
213            capabilities: vec![],
214            hooks: vec![],
215            subscriptions: vec![],
216        };
217        let v: serde_json::Value = serde_json::to_value(&report).unwrap();
218        assert_eq!(v["info_schema_version"], 1);
219        assert_eq!(v["mode"], "production");
220        assert_eq!(v["locale"], "en");
221        assert_eq!(v["access"]["tenants"], 0);
222    }
223
224    #[test]
225    fn from_opened_bundle_projects_all_fields() {
226        use greentic_bundle_reader::{
227            BundleLock, BundleManifest, BundleResolvedTargetView, BundleSourceKind,
228            CatalogLockEntry, DependencyLock, OpenedBundle,
229        };
230
231        let manifest = BundleManifest {
232            format_version: "1".into(),
233            bundle_id: "acme".into(),
234            bundle_name: "acme-demo".into(),
235            requested_mode: "production".into(),
236            locale: "en".into(),
237            artifact_extension: "gtbundle".into(),
238            generated_resolved_files: vec![],
239            generated_setup_files: vec![],
240            app_packs: vec!["hello-bot".into(), "support-bot".into()],
241            extension_providers: vec!["slack-provider".into()],
242            catalogs: vec![],
243            hooks: vec!["on_install".into()],
244            subscriptions: vec!["user.created".into()],
245            capabilities: vec!["state.kv".into()],
246            resolved_targets: vec![
247                BundleResolvedTargetView {
248                    path: "tenants/default/default.yaml".into(),
249                    tenant: "default".into(),
250                    team: Some("engineering".into()),
251                    default_policy: "public".into(),
252                    tenant_gmap: "tenants/default/tenant.gmap".into(),
253                    team_gmap: Some("tenants/default/engineering.gmap".into()),
254                    app_pack_policies: vec![],
255                },
256                BundleResolvedTargetView {
257                    path: "tenants/default/marketing.yaml".into(),
258                    tenant: "default".into(),
259                    team: Some("marketing".into()),
260                    default_policy: "public".into(),
261                    tenant_gmap: "tenants/default/tenant.gmap".into(),
262                    team_gmap: Some("tenants/default/marketing.gmap".into()),
263                    app_pack_policies: vec![],
264                },
265                BundleResolvedTargetView {
266                    path: "tenants/acme/tenant.yaml".into(),
267                    tenant: "acme".into(),
268                    team: None,
269                    default_policy: "forbidden".into(),
270                    tenant_gmap: "tenants/acme/tenant.gmap".into(),
271                    team_gmap: None,
272                    app_pack_policies: vec![],
273                },
274            ],
275        };
276
277        let lock = BundleLock {
278            schema_version: 1,
279            bundle_id: "acme".into(),
280            requested_mode: "production".into(),
281            execution: "default".into(),
282            cache_policy: "default".into(),
283            tool_version: "0.0.0".into(),
284            build_format_version: "1".into(),
285            workspace_root: "".into(),
286            lock_file: "".into(),
287            catalogs: vec![CatalogLockEntry {
288                requested_ref: "file://catalog.json".into(),
289                resolved_ref: "catalog.json".into(),
290                digest: "sha256:abc".into(),
291                source: "file".into(),
292                item_count: 12,
293                item_ids: vec!["hello-bot".into()],
294                cache_path: None,
295            }],
296            app_packs: vec![
297                DependencyLock {
298                    reference: "hello-bot".into(),
299                    digest: Some("sha256:aaa".into()),
300                },
301                DependencyLock {
302                    reference: "support-bot".into(),
303                    digest: None,
304                },
305            ],
306            extension_providers: vec![DependencyLock {
307                reference: "slack-provider".into(),
308                digest: Some("sha256:bbb".into()),
309            }],
310            setup_state_files: vec![],
311        };
312
313        let opened = OpenedBundle {
314            source_kind: BundleSourceKind::Artifact,
315            source_path: "/tmp/demo.gtbundle".into(),
316            format_version: "1".into(),
317            manifest,
318            lock,
319        };
320
321        let r = InfoReport::from_opened_bundle(&opened);
322
323        assert_eq!(r.info_schema_version, 1);
324        assert_eq!(r.bundle_id, "acme");
325        assert_eq!(r.name, "acme-demo");
326        assert_eq!(r.version, None);
327        assert_eq!(r.description, None);
328        assert_eq!(r.mode, "production");
329        assert_eq!(r.locale, "en");
330
331        assert_eq!(r.app_packs.len(), 2);
332        assert_eq!(r.app_packs[0].reference, "hello-bot");
333        assert_eq!(r.app_packs[0].digest.as_deref(), Some("sha256:aaa"));
334        assert_eq!(r.app_packs[1].reference, "support-bot");
335        assert_eq!(r.app_packs[1].digest, None);
336
337        assert_eq!(r.extension_providers.len(), 1);
338        assert_eq!(r.extension_providers[0].reference, "slack-provider");
339        assert_eq!(
340            r.extension_providers[0].digest.as_deref(),
341            Some("sha256:bbb")
342        );
343
344        assert_eq!(r.catalogs.len(), 1);
345        assert_eq!(r.catalogs[0].name, "catalog.json");
346        assert_eq!(r.catalogs[0].item_count, 12);
347
348        assert_eq!(r.access.tenants, 2);
349        assert_eq!(r.access.teams, 2);
350        let default_target = r
351            .access
352            .targets
353            .iter()
354            .find(|t| t.tenant == "default")
355            .unwrap();
356        assert_eq!(default_target.team_count, 2);
357        assert_eq!(default_target.default_policy, "public");
358        let acme_target = r
359            .access
360            .targets
361            .iter()
362            .find(|t| t.tenant == "acme")
363            .unwrap();
364        assert_eq!(acme_target.team_count, 0);
365        assert_eq!(acme_target.default_policy, "forbidden");
366
367        assert_eq!(r.capabilities, vec!["state.kv".to_string()]);
368        assert_eq!(r.hooks, vec!["on_install".to_string()]);
369        assert_eq!(r.subscriptions, vec!["user.created".to_string()]);
370    }
371}