use anyhow::{Context, Result};
use greentic_bundle_reader::{
BundleLock, BundleManifest, BundleResolvedTargetView, BundleSourceKind, DependencyLock,
OpenedBundle,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use super::pack_probe::{self, PackMetaSlim};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InfoReport {
pub info_schema_version: u32,
pub bundle_id: String,
pub name: String,
pub version: Option<String>,
pub description: Option<String>,
pub mode: String,
pub locale: String,
pub app_packs: Vec<PackRef>,
pub extension_providers: Vec<PackRef>,
pub catalogs: Vec<CatalogRef>,
pub access: AccessSummary,
pub capabilities: Vec<String>,
pub hooks: Vec<String>,
pub subscriptions: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackRef {
pub reference: String,
pub version: Option<String>,
pub digest: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CatalogRef {
pub name: String,
pub item_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessSummary {
pub tenants: u32,
pub teams: u32,
pub targets: Vec<AccessTarget>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessTarget {
pub tenant: String,
pub team_count: u32,
pub default_policy: String,
}
impl InfoReport {
pub fn from_opened_bundle(opened: &OpenedBundle) -> Self {
let meta = pack_metadata_for(opened);
project(&opened.manifest, &opened.lock, &meta)
}
pub fn from_workspace(path: &Path) -> Result<Self> {
let report = crate::build::inspect_target(Some(path), None)
.with_context(|| format!("reading bundle workspace at {}", path.display()))?;
Ok(project(&report.manifest, &report.lock, &BTreeMap::new()))
}
}
fn pack_metadata_for(opened: &OpenedBundle) -> BTreeMap<String, PackMetaSlim> {
if !matches!(opened.source_kind, BundleSourceKind::Artifact) {
return BTreeMap::new();
}
let artifact_path = PathBuf::from(&opened.source_path);
let mut refs: Vec<&str> = Vec::new();
for lock in &opened.lock.app_packs {
refs.push(lock.reference.as_str());
}
for lock in &opened.lock.extension_providers {
refs.push(lock.reference.as_str());
}
pack_probe::probe_inlined_packs(&artifact_path, &refs)
}
fn project(
manifest: &BundleManifest,
lock: &BundleLock,
pack_meta: &BTreeMap<String, PackMetaSlim>,
) -> InfoReport {
InfoReport {
info_schema_version: 1,
bundle_id: manifest.bundle_id.clone(),
name: manifest.bundle_name.clone(),
version: None,
description: None,
mode: manifest.requested_mode.clone(),
locale: manifest.locale.clone(),
app_packs: project_packs(&manifest.app_packs, &lock.app_packs, pack_meta),
extension_providers: project_packs(
&manifest.extension_providers,
&lock.extension_providers,
pack_meta,
),
catalogs: lock
.catalogs
.iter()
.map(|c| CatalogRef {
name: catalog_display_name(&c.resolved_ref, &c.requested_ref),
item_count: c.item_count as u32,
})
.collect(),
access: access_summary(&manifest.resolved_targets),
capabilities: manifest.capabilities.clone(),
hooks: manifest.hooks.clone(),
subscriptions: manifest.subscriptions.clone(),
}
}
fn project_packs(
names: &[String],
locks: &[DependencyLock],
pack_meta: &BTreeMap<String, PackMetaSlim>,
) -> Vec<PackRef> {
names
.iter()
.map(|name| {
let digest = locks
.iter()
.find(|l| l.reference == *name)
.and_then(|l| l.digest.clone());
let version = pack_meta.get(name).and_then(|m| m.version.clone());
PackRef {
reference: name.clone(),
version,
digest,
}
})
.collect()
}
fn catalog_display_name(resolved: &str, requested: &str) -> String {
if !resolved.is_empty() {
resolved.to_string()
} else {
requested.to_string()
}
}
fn access_summary(targets: &[BundleResolvedTargetView]) -> AccessSummary {
use std::collections::BTreeMap;
let mut per_tenant: BTreeMap<String, (u32, Option<String>)> = BTreeMap::new();
for t in targets {
let entry = per_tenant.entry(t.tenant.clone()).or_insert((0, None));
if t.team.is_some() {
entry.0 += 1;
}
if entry.1.is_none() {
entry.1 = Some(t.default_policy.clone());
}
}
let teams: u32 = per_tenant.values().map(|(c, _)| *c).sum();
AccessSummary {
tenants: per_tenant.len() as u32,
teams,
targets: per_tenant
.into_iter()
.map(|(tenant, (team_count, pol))| AccessTarget {
tenant,
team_count,
default_policy: pol.unwrap_or_else(|| "public".into()),
})
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_has_schema_version_one() {
let report = InfoReport {
info_schema_version: 1,
bundle_id: "b".into(),
name: "b".into(),
version: None,
description: None,
mode: "production".into(),
locale: "en".into(),
app_packs: vec![],
extension_providers: vec![],
catalogs: vec![],
access: AccessSummary {
tenants: 0,
teams: 0,
targets: vec![],
},
capabilities: vec![],
hooks: vec![],
subscriptions: vec![],
};
let v: serde_json::Value = serde_json::to_value(&report).unwrap();
assert_eq!(v["info_schema_version"], 1);
assert_eq!(v["mode"], "production");
assert_eq!(v["locale"], "en");
assert_eq!(v["access"]["tenants"], 0);
}
#[test]
fn from_opened_bundle_projects_all_fields() {
use greentic_bundle_reader::{
BundleLock, BundleManifest, BundleResolvedTargetView, BundleSourceKind,
CatalogLockEntry, DependencyLock, OpenedBundle,
};
let manifest = BundleManifest {
format_version: "1".into(),
bundle_id: "acme".into(),
bundle_name: "acme-demo".into(),
requested_mode: "production".into(),
locale: "en".into(),
artifact_extension: "gtbundle".into(),
generated_resolved_files: vec![],
generated_setup_files: vec![],
app_packs: vec!["hello-bot".into(), "support-bot".into()],
extension_providers: vec!["slack-provider".into()],
catalogs: vec![],
hooks: vec!["on_install".into()],
subscriptions: vec!["user.created".into()],
capabilities: vec!["state.kv".into()],
resolved_targets: vec![
BundleResolvedTargetView {
path: "tenants/default/default.yaml".into(),
tenant: "default".into(),
team: Some("engineering".into()),
default_policy: "public".into(),
tenant_gmap: "tenants/default/tenant.gmap".into(),
team_gmap: Some("tenants/default/engineering.gmap".into()),
app_pack_policies: vec![],
},
BundleResolvedTargetView {
path: "tenants/default/marketing.yaml".into(),
tenant: "default".into(),
team: Some("marketing".into()),
default_policy: "public".into(),
tenant_gmap: "tenants/default/tenant.gmap".into(),
team_gmap: Some("tenants/default/marketing.gmap".into()),
app_pack_policies: vec![],
},
BundleResolvedTargetView {
path: "tenants/acme/tenant.yaml".into(),
tenant: "acme".into(),
team: None,
default_policy: "forbidden".into(),
tenant_gmap: "tenants/acme/tenant.gmap".into(),
team_gmap: None,
app_pack_policies: vec![],
},
],
};
let lock = BundleLock {
schema_version: 1,
bundle_id: "acme".into(),
requested_mode: "production".into(),
execution: "default".into(),
cache_policy: "default".into(),
tool_version: "0.0.0".into(),
build_format_version: "1".into(),
workspace_root: "".into(),
lock_file: "".into(),
catalogs: vec![CatalogLockEntry {
requested_ref: "file://catalog.json".into(),
resolved_ref: "catalog.json".into(),
digest: "sha256:abc".into(),
source: "file".into(),
item_count: 12,
item_ids: vec!["hello-bot".into()],
cache_path: None,
}],
app_packs: vec![
DependencyLock {
reference: "hello-bot".into(),
digest: Some("sha256:aaa".into()),
},
DependencyLock {
reference: "support-bot".into(),
digest: None,
},
],
extension_providers: vec![DependencyLock {
reference: "slack-provider".into(),
digest: Some("sha256:bbb".into()),
}],
setup_state_files: vec![],
};
let opened = OpenedBundle {
source_kind: BundleSourceKind::Artifact,
source_path: "/tmp/demo.gtbundle".into(),
format_version: "1".into(),
manifest,
lock,
};
let r = InfoReport::from_opened_bundle(&opened);
assert_eq!(r.info_schema_version, 1);
assert_eq!(r.bundle_id, "acme");
assert_eq!(r.name, "acme-demo");
assert_eq!(r.version, None);
assert_eq!(r.description, None);
assert_eq!(r.mode, "production");
assert_eq!(r.locale, "en");
assert_eq!(r.app_packs.len(), 2);
assert_eq!(r.app_packs[0].reference, "hello-bot");
assert_eq!(r.app_packs[0].digest.as_deref(), Some("sha256:aaa"));
assert_eq!(r.app_packs[1].reference, "support-bot");
assert_eq!(r.app_packs[1].digest, None);
assert_eq!(r.extension_providers.len(), 1);
assert_eq!(r.extension_providers[0].reference, "slack-provider");
assert_eq!(
r.extension_providers[0].digest.as_deref(),
Some("sha256:bbb")
);
assert_eq!(r.catalogs.len(), 1);
assert_eq!(r.catalogs[0].name, "catalog.json");
assert_eq!(r.catalogs[0].item_count, 12);
assert_eq!(r.access.tenants, 2);
assert_eq!(r.access.teams, 2);
let default_target = r
.access
.targets
.iter()
.find(|t| t.tenant == "default")
.unwrap();
assert_eq!(default_target.team_count, 2);
assert_eq!(default_target.default_policy, "public");
let acme_target = r
.access
.targets
.iter()
.find(|t| t.tenant == "acme")
.unwrap();
assert_eq!(acme_target.team_count, 0);
assert_eq!(acme_target.default_policy, "forbidden");
assert_eq!(r.capabilities, vec!["state.kv".to_string()]);
assert_eq!(r.hooks, vec!["on_install".to_string()]);
assert_eq!(r.subscriptions, vec!["user.created".to_string()]);
}
}