greentic_dev/
pack_cli.rs

1use std::collections::{HashMap, HashSet};
2use std::fs::{self, File};
3use std::io::{Read, Write};
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6
7use anyhow::{Context, Result, anyhow};
8use greentic_pack::builder::PackManifest;
9use greentic_pack::events::EventProviderSpec;
10use greentic_pack::plan::infer_base_deployment_plan;
11use greentic_pack::reader::{SigningPolicy, open_pack};
12use greentic_types::ExtensionRef;
13use greentic_types::SecretRequirement;
14use greentic_types::component::ComponentManifest;
15use greentic_types::provider::{
16    ProviderDecl, ProviderExtensionInline, ProviderManifest, ProviderRuntimeRef,
17};
18use greentic_types::{EnvId, TenantCtx, TenantId};
19use serde_json::json;
20use zip::ZipArchive;
21
22use crate::cli::{
23    PackEventsFormatArg, PackEventsListArgs, PackNewProviderArgs, PackPlanArgs, PackPolicyArg,
24};
25use crate::pack_init::slugify;
26use crate::pack_temp::materialize_pack_path;
27
28const PROVIDER_EXTENSION_ID: &str = "greentic.provider-extension.v1";
29
30#[derive(Copy, Clone, Debug)]
31pub enum PackEventsFormat {
32    Table,
33    Json,
34    Yaml,
35}
36
37impl From<PackEventsFormatArg> for PackEventsFormat {
38    fn from(value: PackEventsFormatArg) -> Self {
39        match value {
40            PackEventsFormatArg::Table => PackEventsFormat::Table,
41            PackEventsFormatArg::Json => PackEventsFormat::Json,
42            PackEventsFormatArg::Yaml => PackEventsFormat::Yaml,
43        }
44    }
45}
46
47impl From<PackPolicyArg> for SigningPolicy {
48    fn from(value: PackPolicyArg) -> Self {
49        match value {
50            PackPolicyArg::Devok => SigningPolicy::DevOk,
51            PackPolicyArg::Strict => SigningPolicy::Strict,
52        }
53    }
54}
55
56pub fn pack_inspect(path: &Path, policy: PackPolicyArg, json: bool) -> Result<()> {
57    let (temp, pack_path) = materialize_pack_path(path, false)?;
58    let load = open_pack(&pack_path, policy.into()).map_err(|err| anyhow!(err.message))?;
59    if json {
60        print_inspect_json(&load.manifest, &load.report, &load.sbom)?;
61    } else {
62        print_inspect_human(&load.manifest, &load.report, &load.sbom);
63    }
64    drop(temp);
65    Ok(())
66}
67
68pub fn pack_plan(args: &PackPlanArgs) -> Result<()> {
69    let (temp, pack_path) = materialize_pack_path(&args.input, args.verbose)?;
70    let tenant_ctx = build_tenant_ctx(&args.environment, &args.tenant)?;
71    let plan = plan_for_pack(&pack_path, &tenant_ctx, &args.environment)?;
72
73    if args.json {
74        println!("{}", serde_json::to_string(&plan)?);
75    } else {
76        println!("{}", serde_json::to_string_pretty(&plan)?);
77    }
78
79    drop(temp);
80    Ok(())
81}
82
83pub fn pack_new_provider(args: &PackNewProviderArgs) -> Result<()> {
84    let (mut manifest, location, pack_root) = load_manifest(&args.pack)?;
85
86    let runtime = parse_runtime_ref(&args.runtime)?;
87    let config_ref = args
88        .manifest
89        .as_ref()
90        .map(|p| p.display().to_string())
91        .unwrap_or_else(|| format!("providers/{}/provider.yaml", slugify(&args.id)));
92
93    let mut decl = ProviderDecl {
94        provider_type: args.id.clone(),
95        capabilities: Vec::new(),
96        ops: Vec::new(),
97        config_schema_ref: config_ref.clone(),
98        state_schema_ref: None,
99        runtime,
100        docs_ref: None,
101    };
102    if let Some(kind) = &args.kind {
103        decl.capabilities.push(kind.clone());
104    }
105
106    let mut inline = load_provider_extension(&manifest)?;
107    if let Some(existing) = inline
108        .providers
109        .iter()
110        .position(|p| p.provider_type == args.id)
111    {
112        if !args.force {
113            anyhow::bail!(
114                "provider `{}` already exists; pass --force to update",
115                args.id
116            );
117        }
118        inline.providers.remove(existing);
119    }
120    inline.providers.push(decl.clone());
121    inline
122        .providers
123        .sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
124    validate_provider_extension(&inline)?;
125
126    if args.json {
127        println!("{}", serde_json::to_string_pretty(&decl)?);
128    }
129
130    if !args.dry_run {
131        set_provider_extension(&mut manifest, &inline)?;
132        write_manifest(location, &manifest)?;
133        if args.scaffold_files {
134            scaffold_provider_manifest(&pack_root, &config_ref, &decl)?;
135        }
136    }
137
138    Ok(())
139}
140
141fn scaffold_provider_manifest(
142    pack_root: &Path,
143    manifest_ref: &str,
144    decl: &ProviderDecl,
145) -> Result<()> {
146    let path = pack_root.join(manifest_ref);
147    let parent = path
148        .parent()
149        .with_context(|| format!("cannot derive parent for {}", path.display()))?;
150    fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
151    let provider_manifest = ProviderManifest {
152        provider_type: decl.provider_type.clone(),
153        capabilities: decl.capabilities.clone(),
154        ops: decl.ops.clone(),
155        config_schema_ref: Some(decl.config_schema_ref.clone()),
156        state_schema_ref: decl.state_schema_ref.clone(),
157    };
158    let serialized = serde_yaml_bw::to_string(&provider_manifest)?;
159    fs::write(&path, serialized).with_context(|| format!("failed to write {}", path.display()))?;
160    Ok(())
161}
162
163fn parse_runtime_ref(input: &str) -> Result<ProviderRuntimeRef> {
164    let (left, world) = input
165        .rsplit_once('@')
166        .context("runtime must be in form component_ref::export@world")?;
167    let (component_ref, export) = left
168        .split_once("::")
169        .context("runtime must be in form component_ref::export@world")?;
170    Ok(ProviderRuntimeRef {
171        component_ref: component_ref.to_string(),
172        export: export.to_string(),
173        world: world.to_string(),
174    })
175}
176
177fn load_provider_extension(
178    manifest: &greentic_types::PackManifest,
179) -> Result<ProviderExtensionInline> {
180    let mut inline = ProviderExtensionInline::default();
181    if let Some(inline_ref) = manifest
182        .extensions
183        .as_ref()
184        .and_then(|exts| exts.get(PROVIDER_EXTENSION_ID))
185        .and_then(|ext| ext.inline.as_ref())
186    {
187        inline = match inline_ref {
188            greentic_types::pack_manifest::ExtensionInline::Provider(value) => value.clone(),
189            greentic_types::pack_manifest::ExtensionInline::Other(value) => {
190                serde_json::from_value(value.clone()).unwrap_or_default()
191            }
192        };
193    }
194    Ok(inline)
195}
196
197fn set_provider_extension(
198    manifest: &mut greentic_types::PackManifest,
199    inline: &ProviderExtensionInline,
200) -> Result<()> {
201    let extensions = manifest.extensions.get_or_insert_with(Default::default);
202    let entry = extensions
203        .entry(PROVIDER_EXTENSION_ID.to_string())
204        .or_insert_with(|| ExtensionRef {
205            kind: PROVIDER_EXTENSION_ID.to_string(),
206            version: "1.0.0".to_string(),
207            digest: None,
208            location: None,
209            inline: None,
210        });
211    entry.inline = Some(greentic_types::pack_manifest::ExtensionInline::Provider(
212        inline.clone(),
213    ));
214    Ok(())
215}
216
217fn validate_provider_extension(inline: &ProviderExtensionInline) -> Result<()> {
218    let mut seen = HashSet::new();
219    for provider in &inline.providers {
220        if provider.provider_type.trim().is_empty() {
221            anyhow::bail!("provider_type must not be empty");
222        }
223        if !seen.insert(provider.provider_type.as_str()) {
224            anyhow::bail!("duplicate provider_type `{}`", provider.provider_type);
225        }
226        if provider.runtime.component_ref.trim().is_empty()
227            || provider.runtime.export.trim().is_empty()
228            || provider.runtime.world.trim().is_empty()
229        {
230            anyhow::bail!(
231                "runtime fields must be set for provider `{}`",
232                provider.provider_type
233            );
234        }
235    }
236    Ok(())
237}
238
239enum ManifestLocation {
240    File(PathBuf),
241    Gtpack(PathBuf),
242}
243
244fn load_manifest(path: &Path) -> Result<(greentic_types::PackManifest, ManifestLocation, PathBuf)> {
245    if path.is_dir() {
246        let dist = path.join("dist/manifest.cbor");
247        let root_manifest = path.join("manifest.cbor");
248        let target = if dist.exists() {
249            dist
250        } else if root_manifest.exists() {
251            root_manifest
252        } else {
253            anyhow::bail!(
254                "pack path {} is a directory but manifest.cbor not found (looked in ./dist/ and root)",
255                path.display()
256            );
257        };
258        let bytes = fs::read(&target)
259            .with_context(|| format!("failed to read manifest {}", target.display()))?;
260        let manifest = greentic_types::decode_pack_manifest(&bytes)?;
261        return Ok((manifest, ManifestLocation::File(target), path.to_path_buf()));
262    }
263
264    if path.extension().is_some_and(|ext| ext == "gtpack") {
265        let mut archive = zip::ZipArchive::new(File::open(path).context("open gtpack")?)
266            .context("read gtpack zip")?;
267        let mut manifest_bytes = Vec::new();
268        archive
269            .by_name("manifest.cbor")
270            .context("manifest.cbor missing in gtpack")?
271            .read_to_end(&mut manifest_bytes)
272            .context("read manifest.cbor")?;
273        let manifest = greentic_types::decode_pack_manifest(&manifest_bytes)?;
274        return Ok((
275            manifest,
276            ManifestLocation::Gtpack(path.to_path_buf()),
277            path.parent()
278                .map(Path::to_path_buf)
279                .unwrap_or_else(|| PathBuf::from(".")),
280        ));
281    }
282
283    let bytes =
284        fs::read(path).with_context(|| format!("failed to read manifest {}", path.display()))?;
285    let manifest = greentic_types::decode_pack_manifest(&bytes)?;
286    let parent = path
287        .parent()
288        .map(Path::to_path_buf)
289        .unwrap_or_else(|| PathBuf::from("."));
290    Ok((manifest, ManifestLocation::File(path.to_path_buf()), parent))
291}
292
293fn write_manifest(
294    location: ManifestLocation,
295    manifest: &greentic_types::PackManifest,
296) -> Result<()> {
297    let encoded = greentic_types::encode_pack_manifest(manifest)?;
298    match location {
299        ManifestLocation::File(path) => {
300            fs::write(&path, encoded).with_context(|| format!("write {}", path.display()))?;
301        }
302        ManifestLocation::Gtpack(path) => {
303            let mut archive =
304                zip::ZipArchive::new(File::open(&path).context("open gtpack for write")?)
305                    .context("read gtpack zip")?;
306            let mut entries = Vec::new();
307            for i in 0..archive.len() {
308                let mut file = archive.by_index(i).context("gtpack entry")?;
309                let mut buf = Vec::new();
310                file.read_to_end(&mut buf)
311                    .with_context(|| format!("read {}", file.name()))?;
312                entries.push((file.name().to_string(), buf, file.compression()));
313            }
314            let temp_path = path.with_extension("gtpack.tmp");
315            {
316                let temp_file = File::create(&temp_path)
317                    .with_context(|| format!("create {}", temp_path.display()))?;
318                let mut writer = zip::ZipWriter::new(temp_file);
319                let opts = zip::write::SimpleFileOptions::default();
320                for (name, data, method) in entries {
321                    let mut entry_opts = opts;
322                    entry_opts = entry_opts.compression_method(method);
323                    if name == "manifest.cbor" {
324                        writer
325                            .start_file(name, entry_opts)
326                            .context("start manifest entry")?;
327                        writer.write_all(&encoded).context("write manifest.cbor")?;
328                    } else {
329                        writer
330                            .start_file(name, entry_opts)
331                            .with_context(|| "start entry")?;
332                        writer.write_all(&data).with_context(|| "write entry")?;
333                    }
334                }
335                writer.finish().context("finish gtpack rewrite")?;
336            }
337            fs::rename(&temp_path, &path).with_context(|| format!("replace {}", path.display()))?;
338        }
339    }
340    Ok(())
341}
342pub fn pack_events_list(args: &PackEventsListArgs) -> Result<()> {
343    let (temp, pack_path) = materialize_pack_path(&args.path, args.verbose)?;
344    let load = open_pack(&pack_path, SigningPolicy::DevOk).map_err(|err| anyhow!(err.message))?;
345    let providers: Vec<EventProviderSpec> = load
346        .manifest
347        .meta
348        .events
349        .as_ref()
350        .map(|events| events.providers.clone())
351        .unwrap_or_default();
352
353    match PackEventsFormat::from(args.format) {
354        PackEventsFormat::Table => print_table(&providers),
355        PackEventsFormat::Json => print_json(&providers)?,
356        PackEventsFormat::Yaml => print_yaml(&providers)?,
357    }
358
359    drop(temp);
360    Ok(())
361}
362
363fn plan_for_pack(
364    path: &Path,
365    tenant: &TenantCtx,
366    environment: &str,
367) -> Result<greentic_types::deployment::DeploymentPlan> {
368    let load = open_pack(path, SigningPolicy::DevOk).map_err(|err| anyhow!(err.message))?;
369    let connectors = load.manifest.meta.annotations.get("connectors");
370    let components = load_component_manifests(path, &load.manifest)?;
371    let secret_requirements = load_secret_requirements(path)?;
372
373    Ok(infer_base_deployment_plan(
374        &load.manifest.meta,
375        &load.manifest.flows,
376        connectors,
377        &components,
378        secret_requirements,
379        tenant,
380        environment,
381    ))
382}
383
384fn build_tenant_ctx(environment: &str, tenant: &str) -> Result<TenantCtx> {
385    let env_id = EnvId::from_str(environment)
386        .with_context(|| format!("invalid environment id `{}`", environment))?;
387    let tenant_id =
388        TenantId::from_str(tenant).with_context(|| format!("invalid tenant id `{}`", tenant))?;
389    Ok(TenantCtx::new(env_id, tenant_id))
390}
391
392fn load_component_manifests(
393    pack_path: &Path,
394    pack_manifest: &PackManifest,
395) -> Result<HashMap<String, ComponentManifest>> {
396    let file =
397        File::open(pack_path).with_context(|| format!("failed to open {}", pack_path.display()))?;
398    let mut archive = ZipArchive::new(file)
399        .with_context(|| format!("{} is not a valid gtpack archive", pack_path.display()))?;
400
401    let mut manifests = HashMap::new();
402    for component in &pack_manifest.components {
403        if let Some(manifest_path) = component.manifest_file.as_deref() {
404            let mut entry = archive
405                .by_name(manifest_path)
406                .with_context(|| format!("component manifest `{}` missing", manifest_path))?;
407            let manifest: ComponentManifest =
408                serde_json::from_reader(&mut entry).with_context(|| {
409                    format!("failed to parse component manifest `{}`", manifest_path)
410                })?;
411            manifests.insert(component.name.clone(), manifest);
412        }
413    }
414
415    Ok(manifests)
416}
417
418fn load_secret_requirements(path: &Path) -> Result<Option<Vec<SecretRequirement>>> {
419    let file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
420    let mut archive = ZipArchive::new(file)
421        .with_context(|| format!("{} is not a valid gtpack archive", path.display()))?;
422
423    for name in [
424        "assets/secret-requirements.json",
425        "secret-requirements.json",
426    ] {
427        if let Ok(mut entry) = archive.by_name(name) {
428            let mut buf = String::new();
429            entry
430                .read_to_string(&mut buf)
431                .context("failed to read secret requirements file")?;
432            let reqs: Vec<SecretRequirement> =
433                serde_json::from_str(&buf).context("secret requirements file is invalid JSON")?;
434            return Ok(Some(reqs));
435        }
436    }
437
438    Ok(None)
439}
440
441fn print_inspect_human(
442    manifest: &PackManifest,
443    report: &greentic_pack::reader::VerifyReport,
444    sbom: &[greentic_pack::builder::SbomEntry],
445) {
446    println!(
447        "Pack: {} ({})",
448        manifest.meta.pack_id, manifest.meta.version
449    );
450    println!("Flows: {}", manifest.flows.len());
451    println!("Components: {}", manifest.components.len());
452    println!("SBOM entries: {}", sbom.len());
453    println!("Signature OK: {}", report.signature_ok);
454    println!("SBOM OK: {}", report.sbom_ok);
455    if report.warnings.is_empty() {
456        println!("Warnings: none");
457    } else {
458        println!("Warnings:");
459        for warning in &report.warnings {
460            println!("  - {}", warning);
461        }
462    }
463}
464
465fn print_inspect_json(
466    manifest: &PackManifest,
467    report: &greentic_pack::reader::VerifyReport,
468    sbom: &[greentic_pack::builder::SbomEntry],
469) -> Result<()> {
470    let payload = json!({
471        "manifest": {
472            "pack_id": manifest.meta.pack_id,
473            "version": manifest.meta.version,
474            "flows": manifest.flows.len(),
475            "components": manifest.components.len(),
476        },
477        "report": {
478            "signature_ok": report.signature_ok,
479            "sbom_ok": report.sbom_ok,
480            "warnings": report.warnings,
481        },
482        "sbom": sbom,
483    });
484    println!("{}", serde_json::to_string_pretty(&payload)?);
485    Ok(())
486}
487
488fn print_table(providers: &[EventProviderSpec]) {
489    if providers.is_empty() {
490        println!("No events providers declared.");
491        return;
492    }
493
494    println!(
495        "{:<20} {:<8} {:<28} {:<12} TOPICS",
496        "NAME", "KIND", "COMPONENT", "TRANSPORT"
497    );
498    for provider in providers {
499        let transport = provider
500            .capabilities
501            .transport
502            .as_ref()
503            .map(|t| t.to_string())
504            .unwrap_or_else(|| "-".to_string());
505        let topics = summarize_topics(&provider.capabilities.topics);
506        println!(
507            "{:<20} {:<8} {:<28} {:<12} {}",
508            provider.name, provider.kind, provider.component, transport, topics
509        );
510    }
511}
512
513fn print_json(providers: &[EventProviderSpec]) -> Result<()> {
514    let payload = json!(providers);
515    println!("{}", serde_json::to_string_pretty(&payload)?);
516    Ok(())
517}
518
519fn print_yaml(providers: &[EventProviderSpec]) -> Result<()> {
520    let doc = serde_yaml_bw::to_string(providers)?;
521    println!("{doc}");
522    Ok(())
523}
524
525fn summarize_topics(topics: &[String]) -> String {
526    if topics.is_empty() {
527        return "-".to_string();
528    }
529    let combined = topics.join(", ");
530    if combined.len() > 60 {
531        format!("{}...", &combined[..57])
532    } else {
533        combined
534    }
535}