Skip to main content

greentic_deployer/
pack_introspect.rs

1use std::fs::{self, File};
2use std::io::Read;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::time::Duration;
6
7use greentic_config_types::{NetworkConfig, TlsMode};
8use greentic_distributor_client::PackId;
9use greentic_distributor_client::source::DistributorSource;
10use greentic_types::ConnectionKind;
11use greentic_types::cbor::decode_pack_manifest;
12use greentic_types::component::ComponentManifest;
13use greentic_types::deployment::{
14    ChannelPlan, DeploymentPlan, MessagingPlan, RunnerPlan, TelemetryPlan,
15};
16use greentic_types::flow::FlowKind;
17use greentic_types::pack::PackRef;
18use greentic_types::pack_manifest::{PackFlowEntry, PackKind, PackManifest};
19use greentic_types::secrets::{SecretRequirement, SecretScope};
20use semver::Version;
21use serde_json::{Value as JsonValue, json};
22use tar::Archive;
23use zip::result::ZipError;
24
25use crate::config::DeployerConfig;
26use crate::error::{DeployerError, Result};
27use crate::path_safety::normalize_under_root;
28use crate::plan::{
29    ComponentRole, DeploymentHints, DeploymentProfile, InferenceNotes, InfraPlan, PlanContext,
30    PlannedComponent, Target, assemble_plan,
31};
32
33/// Load a pack manifest from raw .gtpack bytes.
34///
35/// CBOR must be decoded exclusively via `greentic_types::decode_pack_manifest`.
36pub fn load_pack_manifest_from_bytes(bytes: &[u8]) -> Result<PackManifest> {
37    decode_pack_manifest(bytes).map_err(DeployerError::ManifestDecode)
38}
39
40/// Build a plan context from the provided pack.
41pub fn build_plan(config: &DeployerConfig) -> Result<PlanContext> {
42    let cwd = std::env::current_dir()?;
43    if let Some(pack_ref) = &config.pack_ref {
44        let source = resolve_distributor_source(config)?;
45        let mut pack_source = PackSource::from_registry(pack_ref.clone(), source)?;
46        return build_plan_with_source(&mut pack_source, config);
47    }
48    if let Some(pack_path) = &config.pack_path {
49        let safe_path = if pack_path.is_absolute() {
50            pack_path.canonicalize()?
51        } else {
52            normalize_under_root(&cwd, pack_path)
53                .map_err(|err| DeployerError::Pack(err.to_string()))?
54        };
55        let mut pack_source = PackSource::open(&safe_path)?;
56        return build_plan_with_source(&mut pack_source, config);
57    }
58    // No bundle pack provided: build a minimal serviceless plan.
59    Ok(build_serviceless_plan(config))
60}
61
62/// Build a minimal plan context when no app bundle is provided (self-contained service packs).
63///
64/// Used when a deployment pack carries its own complete terraform (e.g. `tenant-manager-aws.gtpack`)
65/// and the caller has no Greentic app bundle to introspect. The resulting plan has empty
66/// runners/channels/components/secrets — the deployment pack owns all infrastructure decisions.
67fn build_serviceless_plan(config: &DeployerConfig) -> PlanContext {
68    use crate::plan::{DeploymentHints, Target, assemble_plan};
69    use greentic_types::deployment::DeploymentPlan;
70    use semver::Version;
71    use serde_json::Value as JsonValue;
72
73    let target: Target = config.provider.into();
74    let deployment = DeploymentHints {
75        target,
76        provider: config.provider.as_str().to_string(),
77        strategy: config.strategy.clone(),
78    };
79
80    let plan = DeploymentPlan {
81        pack_id: config.tenant.clone(),
82        pack_version: Version::new(0, 0, 0),
83        tenant: config.tenant.clone(),
84        environment: config.environment.clone(),
85        runners: Vec::new(),
86        messaging: None,
87        channels: Vec::new(),
88        secrets: Vec::new(),
89        oauth: Vec::new(),
90        telemetry: None,
91        extra: JsonValue::Null,
92    };
93
94    let mut ctx = assemble_plan(plan, config, deployment, Vec::new(), Vec::new());
95    ctx.serviceless = true;
96    ctx
97}
98
99/// Build a plan using an explicitly provided pack source (e.g., registry).
100pub fn build_plan_with_source(
101    source: &mut PackSource,
102    config: &DeployerConfig,
103) -> Result<PlanContext> {
104    let manifest = source.read_manifest()?;
105    let deployment = build_deployment_hints(config);
106    let base = plan_from_pack_kind(&manifest, config);
107    let external_components: Vec<String> = external_facing_components(&manifest)
108        .into_iter()
109        .map(|c| c.id.to_string())
110        .collect();
111    let components = infer_component_profiles(&manifest, &deployment);
112    Ok(assemble_plan(
113        base,
114        config,
115        deployment,
116        external_components,
117        components,
118    ))
119}
120
121/// Preferred pack sources.
122#[allow(dead_code)]
123pub enum PackSource {
124    GtpackPath(PathBuf),
125    Dir(PathBuf),
126    Registry {
127        reference: PackRef,
128        source: Arc<dyn DistributorSource>,
129    },
130}
131
132impl PackSource {
133    fn open(path: &Path) -> Result<Self> {
134        if path.is_dir() {
135            Ok(Self::Dir(path.to_path_buf()))
136        } else {
137            Ok(Self::GtpackPath(path.to_path_buf()))
138        }
139    }
140
141    #[allow(dead_code)]
142    pub fn from_registry(reference: PackRef, source: Arc<dyn DistributorSource>) -> Result<Self> {
143        Ok(Self::Registry { reference, source })
144    }
145
146    fn read_manifest(&mut self) -> Result<PackManifest> {
147        match self {
148            PackSource::GtpackPath(path) => read_manifest_from_gtpack(path),
149            PackSource::Dir(path) => read_manifest_from_directory(path),
150            PackSource::Registry {
151                source, reference, ..
152            } => read_manifest_from_registry(source.as_ref(), reference),
153        }
154    }
155}
156
157fn read_manifest_from_tar(path: &Path) -> Result<PackManifest> {
158    let file = File::open(path)?;
159    let mut archive = Archive::new(file);
160    let mut manifest_bytes = None;
161
162    for entry in archive.entries()? {
163        let mut entry = entry?;
164        if entry.path()?.as_ref() == Path::new("manifest.cbor") {
165            let mut buf = Vec::new();
166            entry.read_to_end(&mut buf)?;
167            manifest_bytes = Some(buf);
168            break;
169        }
170    }
171
172    let bytes = manifest_bytes.ok_or_else(|| {
173        DeployerError::Pack(format!(
174            "manifest.cbor missing in pack archive {}",
175            path.display()
176        ))
177    })?;
178
179    load_pack_manifest_from_bytes(&bytes)
180}
181
182fn read_manifest_from_zip(path: &Path) -> Result<PackManifest> {
183    let file = File::open(path)?;
184    let mut archive = zip::ZipArchive::new(file).map_err(|err| {
185        DeployerError::Pack(format!("failed to open zip pack {}: {err}", path.display()))
186    })?;
187    let mut entry = archive.by_name("manifest.cbor").map_err(|err| match err {
188        ZipError::FileNotFound => DeployerError::Pack(format!(
189            "manifest.cbor missing in pack archive {}",
190            path.display()
191        )),
192        other => DeployerError::Pack(format!(
193            "failed to read manifest.cbor in {}: {other}",
194            path.display()
195        )),
196    })?;
197    let mut bytes = Vec::new();
198    entry.read_to_end(&mut bytes)?;
199    load_pack_manifest_from_bytes(&bytes)
200}
201
202/// Read a manifest directly from a `.gtpack` archive on disk.
203pub fn read_manifest_from_gtpack(path: &Path) -> Result<PackManifest> {
204    match read_manifest_from_tar(path) {
205        Ok(manifest) => Ok(manifest),
206        Err(DeployerError::Io(err)) if err.kind() == std::io::ErrorKind::InvalidData => {
207            read_manifest_from_zip(path)
208        }
209        Err(DeployerError::Io(err)) if err.kind() == std::io::ErrorKind::Other => {
210            read_manifest_from_zip(path)
211        }
212        Err(err) => Err(err),
213    }
214}
215
216/// Read an arbitrary entry from a `.gtpack` archive.
217pub fn read_entry_from_gtpack(path: &Path, entry_path: &Path) -> Result<Vec<u8>> {
218    match read_entry_from_tar_gtpack(path, entry_path) {
219        Ok(bytes) => Ok(bytes),
220        Err(DeployerError::Io(err)) if err.kind() == std::io::ErrorKind::InvalidData => {
221            read_entry_from_zip_gtpack(path, entry_path)
222        }
223        Err(DeployerError::Io(err)) if err.kind() == std::io::ErrorKind::Other => {
224            read_entry_from_zip_gtpack(path, entry_path)
225        }
226        Err(err) => Err(err),
227    }
228}
229
230fn read_entry_from_tar_gtpack(path: &Path, entry_path: &Path) -> Result<Vec<u8>> {
231    let file = File::open(path)?;
232    let mut archive = Archive::new(file);
233    for entry in archive.entries()? {
234        let mut entry = entry?;
235        if entry.path()?.as_ref() == entry_path {
236            let mut buf = Vec::new();
237            entry.read_to_end(&mut buf)?;
238            return Ok(buf);
239        }
240    }
241    Err(DeployerError::Pack(format!(
242        "entry {} not found in {}",
243        entry_path.display(),
244        path.display()
245    )))
246}
247
248fn read_entry_from_zip_gtpack(path: &Path, entry_path: &Path) -> Result<Vec<u8>> {
249    let file = File::open(path)?;
250    let mut archive = zip::ZipArchive::new(file).map_err(|err| {
251        DeployerError::Pack(format!("failed to open zip pack {}: {err}", path.display()))
252    })?;
253    let mut entry = archive
254        .by_name(&entry_path.to_string_lossy())
255        .map_err(|err| match err {
256            ZipError::FileNotFound => DeployerError::Pack(format!(
257                "entry {} not found in {}",
258                entry_path.display(),
259                path.display()
260            )),
261            other => DeployerError::Pack(format!(
262                "failed to read entry {} in {}: {other}",
263                entry_path.display(),
264                path.display()
265            )),
266        })?;
267    let mut buf = Vec::new();
268    entry.read_to_end(&mut buf)?;
269    Ok(buf)
270}
271
272fn resolve_distributor_source(config: &DeployerConfig) -> Result<Arc<dyn DistributorSource>> {
273    if let Some(source) = DISTRIBUTOR_SOURCE.get().cloned() {
274        return Ok(source);
275    }
276
277    build_http_distributor_source(config).map_err(|err| {
278        if matches!(err, DeployerError::Config(_) | DeployerError::OfflineDisallowed(_)) {
279            err
280        } else {
281            DeployerError::Config(format!(
282                "no distributor source registered; either register one programmatically or set distributor_url ({err})"
283            ))
284        }
285    })
286}
287
288static DISTRIBUTOR_SOURCE: once_cell::sync::OnceCell<Arc<dyn DistributorSource>> =
289    once_cell::sync::OnceCell::new();
290
291/// Register a distributor source for registry-based pack resolution.
292pub fn set_distributor_source(source: Arc<dyn DistributorSource>) {
293    let _ = DISTRIBUTOR_SOURCE.set(source);
294}
295
296fn build_http_distributor_source(config: &DeployerConfig) -> Result<Arc<dyn DistributorSource>> {
297    let base_url = config
298        .distributor_url
299        .as_deref()
300        .ok_or_else(|| DeployerError::Config("set distributor_url when using pack_id".into()))?;
301
302    if matches!(
303        config.greentic.environment.connection,
304        Some(ConnectionKind::Offline)
305    ) {
306        return Err(DeployerError::OfflineDisallowed(
307            "connection is Offline but distributor URL was requested".into(),
308        ));
309    }
310
311    let client = build_http_client(&config.greentic.network, base_url)?;
312    Ok(Arc::new(HttpPackSource::new(
313        client,
314        base_url.to_string(),
315        config.distributor_token.clone(),
316    )))
317}
318
319struct HttpPackSource {
320    client: reqwest::blocking::Client,
321    base_url: String,
322    token: Option<String>,
323    retries: usize,
324}
325
326impl HttpPackSource {
327    fn new(client: reqwest::blocking::Client, base_url: String, token: Option<String>) -> Self {
328        Self {
329            client,
330            base_url,
331            token,
332            retries: 3,
333        }
334    }
335}
336
337fn build_http_client(network: &NetworkConfig, base_url: &str) -> Result<reqwest::blocking::Client> {
338    let mut builder = reqwest::blocking::Client::builder();
339
340    if let Some(proxy_url) = &network.proxy_url {
341        let proxy = reqwest::Proxy::all(proxy_url).map_err(|err| {
342            DeployerError::Config(format!("invalid proxy URL {proxy_url}: {err}"))
343        })?;
344        builder = builder.proxy(proxy);
345    }
346
347    builder = match network.tls_mode {
348        TlsMode::Disabled => {
349            if base_url.starts_with("https://") {
350                return Err(DeployerError::Config(
351                    "network.tls_mode=disabled is not allowed for https distributor_url; use http or enable TLS"
352                        .into(),
353                ));
354            }
355            builder
356        }
357        TlsMode::System | TlsMode::Strict => builder,
358    };
359
360    if let Some(connect_ms) = network.connect_timeout_ms {
361        builder = builder.connect_timeout(Duration::from_millis(connect_ms));
362    }
363    if let Some(read_ms) = network.read_timeout_ms {
364        builder = builder.timeout(Duration::from_millis(read_ms));
365    }
366
367    builder.build().map_err(|err| {
368        DeployerError::Config(format!(
369            "failed to build HTTP client for distributor: {err}"
370        ))
371    })
372}
373
374impl DistributorSource for HttpPackSource {
375    fn fetch_pack(
376        &self,
377        pack_id: &PackId,
378        version: &Version,
379    ) -> std::result::Result<Vec<u8>, greentic_distributor_client::error::DistributorError> {
380        let url = format!("{}/distributor-api/pack", self.base_url);
381        let payload = serde_json::json!({
382            "pack_id": pack_id.as_str(),
383            "version": version.to_string(),
384        });
385        let mut last_err = None;
386        for _ in 0..self.retries {
387            let mut request = self.client.post(url.clone()).json(&payload);
388            if let Some(token) = &self.token {
389                request = request.bearer_auth(token);
390            }
391            match request.send() {
392                Ok(response) if response.status().is_success() => {
393                    let bytes = response
394                        .bytes()
395                        .map_err(|err| {
396                            greentic_distributor_client::error::DistributorError::Other(
397                                err.to_string(),
398                            )
399                        })?
400                        .to_vec();
401                    return Ok(bytes);
402                }
403                Ok(response) if response.status() == reqwest::StatusCode::NOT_FOUND => {
404                    return Err(greentic_distributor_client::error::DistributorError::NotFound);
405                }
406                Ok(response)
407                    if response.status() == reqwest::StatusCode::UNAUTHORIZED
408                        || response.status() == reqwest::StatusCode::FORBIDDEN =>
409                {
410                    return Err(
411                        greentic_distributor_client::error::DistributorError::PermissionDenied,
412                    );
413                }
414                Ok(response) => {
415                    last_err = Some(format!("http status {}", response.status()));
416                }
417                Err(err) => {
418                    last_err = Some(err.to_string());
419                }
420            }
421        }
422
423        Err(greentic_distributor_client::error::DistributorError::Other(
424            last_err.unwrap_or_else(|| "failed to fetch pack".into()),
425        ))
426    }
427
428    fn fetch_component(
429        &self,
430        _component_id: &greentic_distributor_client::ComponentId,
431        _version: &Version,
432    ) -> std::result::Result<Vec<u8>, greentic_distributor_client::error::DistributorError> {
433        Err(greentic_distributor_client::error::DistributorError::NotFound)
434    }
435}
436
437pub fn read_manifest_from_directory(root: &Path) -> Result<PackManifest> {
438    let cbor = normalize_under_root(root, Path::new("manifest.cbor"))
439        .map_err(|err| DeployerError::Pack(err.to_string()))?;
440    if !cbor.exists() {
441        return Err(DeployerError::Pack(format!(
442            "manifest.cbor missing in {}",
443            root.display()
444        )));
445    }
446    let bytes = fs::read(cbor)?;
447    load_pack_manifest_from_bytes(&bytes)
448}
449
450fn read_manifest_from_registry(
451    source: &dyn DistributorSource,
452    reference: &PackRef,
453) -> Result<PackManifest> {
454    let pack_id = reference.oci_url.parse::<PackId>().map_err(|err| {
455        DeployerError::Config(format!("invalid pack id '{}': {err}", reference.oci_url))
456    })?;
457    let bytes = source.fetch_pack(&pack_id, &reference.version)?;
458    load_pack_manifest_from_bytes(&bytes)
459}
460
461fn build_deployment_hints(config: &DeployerConfig) -> DeploymentHints {
462    let target: Target = config.provider.into();
463    DeploymentHints {
464        target,
465        provider: config.provider.as_str().to_string(),
466        strategy: config.strategy.clone(),
467    }
468}
469
470fn plan_from_pack_kind(manifest: &PackManifest, config: &DeployerConfig) -> DeploymentPlan {
471    match manifest.kind {
472        PackKind::Application => plan_application(manifest, config),
473        PackKind::Provider => plan_provider(manifest, config),
474        PackKind::Infrastructure => plan_infrastructure(manifest, config),
475        PackKind::Library => plan_library(manifest, config),
476    }
477}
478
479fn plan_application(manifest: &PackManifest, config: &DeployerConfig) -> DeploymentPlan {
480    infer_base_deployment_plan(manifest, config)
481}
482
483fn plan_provider(manifest: &PackManifest, config: &DeployerConfig) -> DeploymentPlan {
484    let mut plan = infer_base_deployment_plan(manifest, config);
485    // Providers shouldn't expose channels directly; keep runners/secrets but drop channels.
486    plan.channels.clear();
487    plan
488}
489
490fn plan_infrastructure(manifest: &PackManifest, config: &DeployerConfig) -> DeploymentPlan {
491    let mut plan = infer_base_deployment_plan(manifest, config);
492    // Infra packs generally lack messaging entrypoints.
493    plan.channels.clear();
494    plan.messaging = None;
495    plan
496}
497
498fn plan_library(manifest: &PackManifest, config: &DeployerConfig) -> DeploymentPlan {
499    // Libraries are not deployed directly; surface metadata without runners/channels.
500    DeploymentPlan {
501        pack_id: manifest.pack_id.to_string(),
502        pack_version: manifest.version.clone(),
503        tenant: config.tenant.clone(),
504        environment: config.environment.clone(),
505        runners: Vec::new(),
506        messaging: None,
507        channels: Vec::new(),
508        secrets: collect_secret_requirements(manifest, config),
509        oauth: Vec::new(),
510        telemetry: None,
511        extra: JsonValue::Null,
512    }
513}
514
515fn infer_base_deployment_plan(manifest: &PackManifest, config: &DeployerConfig) -> DeploymentPlan {
516    let runners = build_runner_plan(manifest);
517    let channels = build_channel_plan(manifest);
518    let secrets = collect_secret_requirements(manifest, config);
519    let messaging = messaging_plan_if_needed(manifest, &channels);
520    let telemetry = Some(TelemetryPlan {
521        required: true,
522        suggested_endpoint: None,
523        extra: JsonValue::Null,
524    });
525
526    DeploymentPlan {
527        pack_id: manifest.pack_id.to_string(),
528        pack_version: manifest.version.clone(),
529        tenant: config.tenant.clone(),
530        environment: config.environment.clone(),
531        runners,
532        messaging,
533        channels,
534        secrets,
535        oauth: Vec::new(),
536        telemetry,
537        extra: JsonValue::Null,
538    }
539}
540
541fn messaging_plan_if_needed(
542    manifest: &PackManifest,
543    channels: &[ChannelPlan],
544) -> Option<MessagingPlan> {
545    if messaging_flows(manifest).next().is_none() && channels.is_empty() {
546        return None;
547    }
548
549    Some(MessagingPlan {
550        logical_cluster: "nats-default".to_string(),
551        subjects: Vec::new(),
552        extra: JsonValue::Null,
553    })
554}
555
556fn build_runner_plan(manifest: &PackManifest) -> Vec<RunnerPlan> {
557    components_for_deployment(manifest)
558        .into_iter()
559        .map(|component| {
560            let resources = &component.resources;
561            let replicas = if resources.average_latency_ms.unwrap_or(0) < 50 {
562                2
563            } else {
564                1
565            };
566            RunnerPlan {
567                name: component.id.to_string(),
568                replicas,
569                capabilities: json!({
570                    "cpu_millis": resources.cpu_millis,
571                    "memory_mb": resources.memory_mb,
572                    "average_latency_ms": resources.average_latency_ms,
573                }),
574            }
575        })
576        .collect()
577}
578
579fn build_channel_plan(manifest: &PackManifest) -> Vec<ChannelPlan> {
580    let mut channels = Vec::new();
581
582    for entry in messaging_flows(manifest) {
583        let entrypoints: Vec<String> = if entry.flow.entrypoints.is_empty() {
584            vec!["default".to_string()]
585        } else {
586            entry.flow.entrypoints.keys().cloned().collect()
587        };
588
589        for name in entrypoints {
590            channels.push(ChannelPlan {
591                name: name.clone(),
592                flow_id: entry.id.to_string(),
593                kind: "messaging".to_string(),
594                config: JsonValue::Null,
595            });
596        }
597    }
598
599    for entry in http_flows(manifest) {
600        let entrypoints: Vec<String> = if entry.flow.entrypoints.is_empty() {
601            vec!["default".to_string()]
602        } else {
603            entry.flow.entrypoints.keys().cloned().collect()
604        };
605
606        for name in entrypoints {
607            channels.push(ChannelPlan {
608                name: name.clone(),
609                flow_id: entry.id.to_string(),
610                kind: "http".to_string(),
611                config: JsonValue::Null,
612            });
613        }
614    }
615
616    channels
617}
618
619fn collect_secret_requirements(
620    manifest: &PackManifest,
621    config: &DeployerConfig,
622) -> Vec<SecretRequirement> {
623    let mut secrets = Vec::new();
624    for component in components_for_deployment(manifest) {
625        if let Some(spec) = component.capabilities.host.secrets.as_ref() {
626            for requirement in &spec.required {
627                let mut requirement = requirement.clone();
628                if requirement.scope.is_none() {
629                    requirement.scope = Some(SecretScope {
630                        env: config.environment.clone(),
631                        tenant: config.tenant.clone(),
632                        team: None,
633                    });
634                }
635
636                if secrets.iter().any(|entry: &SecretRequirement| {
637                    entry.key == requirement.key && entry.scope == requirement.scope
638                }) {
639                    continue;
640                }
641                secrets.push(requirement);
642            }
643        }
644    }
645    secrets
646}
647
648/// Components that should be deployed (currently all declared components).
649pub fn components_for_deployment(manifest: &PackManifest) -> Vec<&ComponentManifest> {
650    manifest.components.iter().collect()
651}
652
653/// Components that are external-facing (messaging/http/event ingress).
654pub fn external_facing_components(manifest: &PackManifest) -> Vec<&ComponentManifest> {
655    manifest
656        .components
657        .iter()
658        .filter(|component| {
659            let host_caps = &component.capabilities.host;
660            host_caps
661                .messaging
662                .as_ref()
663                .map(|m| m.inbound)
664                .unwrap_or(false)
665                || host_caps
666                    .events
667                    .as_ref()
668                    .map(|e| e.inbound)
669                    .unwrap_or(false)
670                || host_caps
671                    .http
672                    .as_ref()
673                    .map(|http| http.server)
674                    .unwrap_or(false)
675        })
676        .collect()
677}
678
679/// Iterator over messaging flows embedded in the pack.
680pub fn messaging_flows<'a>(
681    manifest: &'a PackManifest,
682) -> impl Iterator<Item = &'a PackFlowEntry> + 'a {
683    manifest
684        .flows
685        .iter()
686        .filter(|entry| entry.kind == FlowKind::Messaging)
687}
688
689/// Iterator over HTTP flows embedded in the pack.
690pub fn http_flows<'a>(manifest: &'a PackManifest) -> impl Iterator<Item = &'a PackFlowEntry> + 'a {
691    manifest
692        .flows
693        .iter()
694        .filter(|entry| entry.kind == FlowKind::Http)
695}
696
697/// Iterator over component configuration flows.
698pub fn config_flows<'a>(
699    manifest: &'a PackManifest,
700) -> impl Iterator<Item = &'a PackFlowEntry> + 'a {
701    manifest
702        .flows
703        .iter()
704        .filter(|entry| entry.kind == FlowKind::ComponentConfig)
705}
706
707fn infer_component_profiles(
708    manifest: &PackManifest,
709    deployment: &DeploymentHints,
710) -> Vec<PlannedComponent> {
711    let mut planned = Vec::new();
712    for component in &manifest.components {
713        let role = infer_component_role(component);
714        let (profile, inference) = infer_profile(component, &role);
715        let infra = map_profile_to_infra(&deployment.target, &profile);
716        planned.push(PlannedComponent {
717            id: component.id.to_string(),
718            role,
719            profile,
720            target: deployment.target.clone(),
721            infra,
722            inference,
723        });
724    }
725
726    planned.sort_by(|a, b| a.id.cmp(&b.id));
727    planned
728}
729
730fn infer_component_role(component: &ComponentManifest) -> ComponentRole {
731    let host_caps = &component.capabilities.host;
732    if host_caps
733        .messaging
734        .as_ref()
735        .map(|caps| caps.inbound)
736        .unwrap_or(false)
737    {
738        return ComponentRole::MessagingAdapter;
739    }
740    if host_caps
741        .events
742        .as_ref()
743        .map(|caps| caps.inbound)
744        .unwrap_or(false)
745    {
746        return ComponentRole::EventProvider;
747    }
748    if host_caps
749        .events
750        .as_ref()
751        .map(|caps| caps.outbound)
752        .unwrap_or(false)
753    {
754        return ComponentRole::EventBridge;
755    }
756    ComponentRole::Worker
757}
758
759fn infer_profile(
760    component: &ComponentManifest,
761    role: &ComponentRole,
762) -> (DeploymentProfile, Option<InferenceNotes>) {
763    if let Some(default) = component.profiles.default.as_deref()
764        && let Some(profile) = parse_profile(default)
765    {
766        return (
767            profile,
768            Some(InferenceNotes {
769                source: "default profile from component manifest".to_string(),
770                warnings: Vec::new(),
771            }),
772        );
773    }
774
775    let host_caps = &component.capabilities.host;
776    if host_caps
777        .http
778        .as_ref()
779        .map(|caps| caps.server)
780        .unwrap_or(false)
781    {
782        return (
783            DeploymentProfile::HttpEndpoint,
784            Some(InferenceNotes {
785                source: "inferred from http.server capability".to_string(),
786                warnings: Vec::new(),
787            }),
788        );
789    }
790    if host_caps
791        .messaging
792        .as_ref()
793        .map(|caps| caps.inbound || caps.outbound)
794        .unwrap_or(false)
795    {
796        return (
797            DeploymentProfile::LongLivedService,
798            Some(InferenceNotes {
799                source: "inferred from messaging capability".to_string(),
800                warnings: Vec::new(),
801            }),
802        );
803    }
804    if host_caps
805        .events
806        .as_ref()
807        .map(|caps| caps.inbound || caps.outbound)
808        .unwrap_or(false)
809    {
810        return (
811            DeploymentProfile::ScheduledSource,
812            Some(InferenceNotes {
813                source: "inferred from events capability".to_string(),
814                warnings: Vec::new(),
815            }),
816        );
817    }
818
819    let (profile, warning) = default_profile(role);
820    let warnings = if warning {
821        vec![format!(
822            "component {} (role={}) defaulted to {:?}",
823            component.id,
824            role_label(role),
825            profile
826        )]
827    } else {
828        Vec::new()
829    };
830
831    (
832        profile,
833        Some(InferenceNotes {
834            source: "fallback profile inference".to_string(),
835            warnings,
836        }),
837    )
838}
839
840fn role_label(role: &ComponentRole) -> &'static str {
841    match role {
842        ComponentRole::EventProvider => "event_provider",
843        ComponentRole::EventBridge => "event_bridge",
844        ComponentRole::MessagingAdapter => "messaging_adapter",
845        ComponentRole::Worker => "worker",
846        ComponentRole::Other => "component",
847    }
848}
849
850fn default_profile(role: &ComponentRole) -> (DeploymentProfile, bool) {
851    match role {
852        ComponentRole::Worker => (DeploymentProfile::OneShotJob, false),
853        ComponentRole::EventProvider | ComponentRole::EventBridge => {
854            (DeploymentProfile::LongLivedService, true)
855        }
856        ComponentRole::MessagingAdapter => (DeploymentProfile::LongLivedService, true),
857        ComponentRole::Other => (DeploymentProfile::LongLivedService, true),
858    }
859}
860
861fn parse_profile(value: &str) -> Option<DeploymentProfile> {
862    let normalized = value.trim().to_ascii_lowercase().replace(['-', ' '], "_");
863    match normalized.as_str() {
864        "longlivedservice" | "long_lived_service" => Some(DeploymentProfile::LongLivedService),
865        "httpendpoint" | "http_endpoint" => Some(DeploymentProfile::HttpEndpoint),
866        "queueconsumer" | "queue_consumer" => Some(DeploymentProfile::QueueConsumer),
867        "scheduledsource" | "scheduled_source" => Some(DeploymentProfile::ScheduledSource),
868        "oneshotjob" | "one_shot_job" | "one_shot" => Some(DeploymentProfile::OneShotJob),
869        _ => None,
870    }
871}
872
873fn map_profile_to_infra(target: &Target, profile: &DeploymentProfile) -> InfraPlan {
874    let (summary, resources) = match (target, profile) {
875        (Target::Local, DeploymentProfile::HttpEndpoint) => (
876            "local gateway + handler".to_string(),
877            vec!["local-gateway".into(), "runner-handler".into()],
878        ),
879        (Target::Aws, DeploymentProfile::HttpEndpoint) => (
880            "api-gateway + lambda".to_string(),
881            vec!["api-gateway".into(), "lambda".into()],
882        ),
883        (Target::Azure, DeploymentProfile::HttpEndpoint) => (
884            "function app (http trigger)".to_string(),
885            vec!["function-app".into()],
886        ),
887        (Target::Gcp, DeploymentProfile::HttpEndpoint) => {
888            ("cloud run (http)".to_string(), vec!["cloud-run".into()])
889        }
890        (Target::K8s, DeploymentProfile::HttpEndpoint) => (
891            "ingress + service + deployment".to_string(),
892            vec!["ingress".into(), "service".into(), "deployment".into()],
893        ),
894        (Target::Local, DeploymentProfile::LongLivedService) => (
895            "runner-managed long-lived process".to_string(),
896            vec!["local-runner".into()],
897        ),
898        (Target::Aws, DeploymentProfile::LongLivedService) => (
899            "ecs/eks service".to_string(),
900            vec!["container-service".into()],
901        ),
902        (Target::Azure, DeploymentProfile::LongLivedService) => (
903            "container apps / app service".to_string(),
904            vec!["container-app".into()],
905        ),
906        (Target::Gcp, DeploymentProfile::LongLivedService) => (
907            "cloud run (always on)".to_string(),
908            vec!["cloud-run".into()],
909        ),
910        (Target::K8s, DeploymentProfile::LongLivedService) => (
911            "deployment + service".to_string(),
912            vec!["deployment".into(), "service".into()],
913        ),
914        (Target::Local, DeploymentProfile::QueueConsumer) => (
915            "local queue worker".to_string(),
916            vec!["local-queue-worker".into()],
917        ),
918        (Target::Aws, DeploymentProfile::QueueConsumer) => (
919            "sqs/event source + lambda".to_string(),
920            vec!["sqs".into(), "lambda".into()],
921        ),
922        (Target::Azure, DeploymentProfile::QueueConsumer) => (
923            "service bus queue trigger".to_string(),
924            vec!["service-bus".into(), "function".into()],
925        ),
926        (Target::Gcp, DeploymentProfile::QueueConsumer) => (
927            "pubsub subscriber".to_string(),
928            vec!["pubsub".into(), "subscriber".into()],
929        ),
930        (Target::K8s, DeploymentProfile::QueueConsumer) => (
931            "deployment + queue consumer".to_string(),
932            vec!["deployment".into()],
933        ),
934        (Target::Local, DeploymentProfile::ScheduledSource) => (
935            "local scheduler + runner invocation".to_string(),
936            vec!["scheduler".into(), "runner".into()],
937        ),
938        (Target::Aws, DeploymentProfile::ScheduledSource) => (
939            "eventbridge schedule + lambda".to_string(),
940            vec!["eventbridge".into(), "lambda".into()],
941        ),
942        (Target::Azure, DeploymentProfile::ScheduledSource) => (
943            "timer-triggered function".to_string(),
944            vec!["function-app".into()],
945        ),
946        (Target::Gcp, DeploymentProfile::ScheduledSource) => (
947            "cloud scheduler + run/function".to_string(),
948            vec!["cloud-scheduler".into(), "cloud-run".into()],
949        ),
950        (Target::K8s, DeploymentProfile::ScheduledSource) => {
951            ("cronjob".to_string(), vec!["cronjob".into()])
952        }
953        (Target::Local, DeploymentProfile::OneShotJob) => {
954            ("runner one-shot job".to_string(), vec!["runner".into()])
955        }
956        (Target::Aws, DeploymentProfile::OneShotJob) => {
957            ("lambda invocation".to_string(), vec!["lambda".into()])
958        }
959        (Target::Azure, DeploymentProfile::OneShotJob) => (
960            "container apps job / function".to_string(),
961            vec!["container-app-job".into()],
962        ),
963        (Target::Gcp, DeploymentProfile::OneShotJob) => {
964            ("cloud run job".to_string(), vec!["cloud-run-job".into()])
965        }
966        (Target::K8s, DeploymentProfile::OneShotJob) => ("job".to_string(), vec!["job".into()]),
967    };
968
969    InfraPlan {
970        target: target.clone(),
971        profile: profile.clone(),
972        summary,
973        resources,
974        notes: None,
975    }
976}
977
978#[cfg(test)]
979mod tests {
980    use super::*;
981    use crate::config::{DeployerConfig, OutputFormat, Provider};
982    use crate::contract::DeployerCapability;
983    use greentic_types::cbor::encode_pack_manifest;
984    use greentic_types::component::{ComponentCapabilities, ComponentProfiles, HostCapabilities};
985    use greentic_types::flow::{
986        ComponentRef, Flow, FlowMetadata, InputMapping, Node, OutputMapping,
987    };
988    use greentic_types::pack_manifest::PackDependency;
989    use greentic_types::{ComponentId, FlowId, NodeId, PackId, SemverReq};
990    use indexmap::IndexMap;
991    use semver::Version;
992    use std::env;
993    use std::io::Write;
994    use std::str::FromStr;
995    use tar::Builder;
996
997    fn sample_component(id: &str, inbound_messaging: bool) -> ComponentManifest {
998        let host_caps = HostCapabilities {
999            messaging: Some(greentic_types::component::MessagingCapabilities {
1000                inbound: inbound_messaging,
1001                outbound: true,
1002            }),
1003            ..Default::default()
1004        };
1005        ComponentManifest {
1006            id: ComponentId::from_str(id).unwrap(),
1007            version: Version::new(0, 1, 0),
1008            supports: vec![FlowKind::Messaging, FlowKind::Http],
1009            world: "greentic:test/world".to_string(),
1010            profiles: ComponentProfiles {
1011                default: Some("long_lived_service".to_string()),
1012                supported: vec!["long_lived_service".to_string()],
1013            },
1014            capabilities: ComponentCapabilities {
1015                host: host_caps,
1016                ..Default::default()
1017            },
1018            configurators: None,
1019            operations: Vec::new(),
1020            config_schema: None,
1021            resources: Default::default(),
1022            dev_flows: Default::default(),
1023        }
1024    }
1025
1026    fn sample_flow(id: &str, kind: FlowKind, component: &ComponentManifest) -> PackFlowEntry {
1027        let mut nodes: IndexMap<NodeId, Node, greentic_types::flow::FlowHasher> =
1028            IndexMap::default();
1029        nodes.insert(
1030            NodeId::from_str("start").unwrap(),
1031            Node {
1032                id: NodeId::from_str("start").unwrap(),
1033                component: ComponentRef {
1034                    id: component.id.clone(),
1035                    pack_alias: None,
1036                    operation: None,
1037                },
1038                input: InputMapping {
1039                    mapping: JsonValue::Null,
1040                },
1041                output: OutputMapping {
1042                    mapping: JsonValue::Null,
1043                },
1044                err_map: None,
1045                routing: greentic_types::flow::Routing::End,
1046                telemetry: Default::default(),
1047                conversational: false,
1048            },
1049        );
1050
1051        let mut entrypoints = std::collections::BTreeMap::new();
1052        entrypoints.insert("default".to_string(), JsonValue::Null);
1053
1054        let flow = Flow {
1055            schema_version: "flowir-v1".to_string(),
1056            id: FlowId::from_str(id).unwrap(),
1057            kind,
1058            entrypoints,
1059            nodes,
1060            metadata: FlowMetadata::default(),
1061        };
1062
1063        PackFlowEntry {
1064            id: flow.id.clone(),
1065            kind,
1066            flow,
1067            tags: vec![format!("{kind:?}")],
1068            entrypoints: vec!["default".to_string()],
1069        }
1070    }
1071
1072    fn sample_manifest() -> PackManifest {
1073        let messaging_component = sample_component("dev.greentic.chat", true);
1074        let http_component = sample_component("dev.greentic.http", false);
1075
1076        let flows = vec![
1077            sample_flow("chat_flow", FlowKind::Messaging, &messaging_component),
1078            sample_flow("http_flow", FlowKind::Http, &http_component),
1079            sample_flow(
1080                "config_flow",
1081                FlowKind::ComponentConfig,
1082                &messaging_component,
1083            ),
1084        ];
1085
1086        PackManifest {
1087            agents: Default::default(),
1088            schema_version: "pack-v1".to_string(),
1089            pack_id: PackId::from_str("dev.greentic.sample").unwrap(),
1090            name: None,
1091            version: Version::new(0, 1, 0),
1092            kind: PackKind::Application,
1093            publisher: "greentic".to_string(),
1094            secret_requirements: Vec::new(),
1095            components: vec![messaging_component, http_component],
1096            flows,
1097            dependencies: vec![PackDependency {
1098                alias: "common".to_string(),
1099                pack_id: PackId::from_str("dev.greentic.common").unwrap(),
1100                version_req: SemverReq::parse("*").unwrap(),
1101                required_capabilities: vec![],
1102            }],
1103            capabilities: Vec::new(),
1104            signatures: Default::default(),
1105            bootstrap: None,
1106            extensions: None,
1107        }
1108    }
1109
1110    #[test]
1111    fn manifest_round_trip_from_tar_and_dir() {
1112        let manifest = sample_manifest();
1113        let encoded = encode_pack_manifest(&manifest).expect("encode manifest");
1114
1115        let mut builder = Builder::new(Vec::new());
1116        let mut header = tar::Header::new_gnu();
1117        header.set_size(encoded.len() as u64);
1118        header.set_cksum();
1119        header.set_mode(0o644);
1120        builder
1121            .append_data(&mut header, "manifest.cbor", encoded.as_slice())
1122            .expect("append manifest");
1123        let dummy = b"wasm";
1124        let mut comp_header = tar::Header::new_gnu();
1125        comp_header.set_size(dummy.len() as u64);
1126        comp_header.set_cksum();
1127        comp_header.set_mode(0o644);
1128        builder
1129            .append_data(
1130                &mut comp_header,
1131                "components/dev.greentic.chat.wasm",
1132                dummy.as_slice(),
1133            )
1134            .expect("append component");
1135        let tar_bytes = builder.into_inner().expect("tar bytes");
1136
1137        let from_bytes = load_pack_manifest_from_bytes(&encode_pack_manifest(&manifest).unwrap())
1138            .expect("decode manifest");
1139        assert_eq!(from_bytes.pack_id, manifest.pack_id);
1140
1141        let base = env::current_dir().expect("cwd").join("target/tmp-tests");
1142        std::fs::create_dir_all(&base).expect("create tmp base");
1143        let dir = tempfile::tempdir_in(base).expect("temp dir");
1144        let manifest_path = dir.path().join("manifest.cbor");
1145        fs::write(&manifest_path, &encoded).expect("write manifest");
1146        fs::create_dir(dir.path().join("components")).expect("mkdir components");
1147        fs::write(
1148            dir.path().join("components/dev.greentic.chat.wasm"),
1149            b"wasm",
1150        )
1151        .expect("write component");
1152
1153        let mut tar_file = tempfile::NamedTempFile::new().expect("temp tar");
1154        tar_file.write_all(&tar_bytes).expect("write tar");
1155
1156        let decoded_tar = {
1157            let mut source = PackSource::open(tar_file.path()).expect("open tar source");
1158            source.read_manifest().expect("read tar manifest")
1159        };
1160        assert_eq!(decoded_tar.pack_id, manifest.pack_id);
1161        let decoded_dir = {
1162            let mut source = PackSource::open(dir.path()).expect("open dir source");
1163            source.read_manifest().expect("read dir manifest")
1164        };
1165        assert_eq!(decoded_dir.pack_id, manifest.pack_id);
1166    }
1167
1168    #[test]
1169    fn helpers_filter_flows_and_components() {
1170        let manifest = sample_manifest();
1171        let messaging: Vec<_> = messaging_flows(&manifest).collect();
1172        let http: Vec<_> = http_flows(&manifest).collect();
1173        let config: Vec<_> = config_flows(&manifest).collect();
1174
1175        assert_eq!(messaging.len(), 1);
1176        assert_eq!(http.len(), 1);
1177        assert_eq!(config.len(), 1);
1178
1179        let components = components_for_deployment(&manifest);
1180        assert_eq!(components.len(), 2);
1181        let external = external_facing_components(&manifest);
1182        assert_eq!(external.len(), 1);
1183        assert_eq!(
1184            external[0].id,
1185            ComponentId::from_str("dev.greentic.chat").unwrap()
1186        );
1187    }
1188
1189    #[test]
1190    fn runner_plan_respects_resource_hints() {
1191        let mut manifest = sample_manifest();
1192        // Set resource hints to drive replicas > 1.
1193        if let Some(component) = manifest
1194            .components
1195            .iter_mut()
1196            .find(|c| c.id == ComponentId::from_str("dev.greentic.chat").unwrap())
1197        {
1198            component.resources.cpu_millis = Some(256);
1199            component.resources.memory_mb = Some(512);
1200            component.resources.average_latency_ms = Some(10);
1201        }
1202        let runners = build_runner_plan(&manifest);
1203        let chat = runners
1204            .iter()
1205            .find(|r| r.name == "dev.greentic.chat")
1206            .expect("runner present");
1207        assert!(
1208            chat.replicas >= 2,
1209            "low-latency components scale up replicas"
1210        );
1211        assert_eq!(
1212            chat.capabilities.get("cpu_millis").and_then(|v| v.as_u64()),
1213            Some(256)
1214        );
1215        assert_eq!(
1216            chat.capabilities.get("memory_mb").and_then(|v| v.as_u64()),
1217            Some(512)
1218        );
1219    }
1220
1221    #[test]
1222    fn library_pack_skips_runners_and_channels() {
1223        let mut manifest = sample_manifest();
1224        manifest.kind = PackKind::Library;
1225        let config = default_config(PathBuf::from("."));
1226        let plan = plan_from_pack_kind(&manifest, &config);
1227        assert!(plan.runners.is_empty());
1228        assert!(plan.channels.is_empty());
1229        assert_eq!(plan.pack_id, manifest.pack_id.to_string());
1230    }
1231
1232    #[test]
1233    fn provider_plan_drops_channels_but_keeps_runners() {
1234        let mut manifest = sample_manifest();
1235        manifest.kind = PackKind::Provider;
1236        let config = default_config(PathBuf::from("."));
1237        let plan = plan_from_pack_kind(&manifest, &config);
1238        assert!(
1239            plan.channels.is_empty(),
1240            "provider packs should not expose channels"
1241        );
1242        assert!(!plan.runners.is_empty(), "provider packs keep runners");
1243    }
1244
1245    #[test]
1246    fn infrastructure_plan_has_no_messaging() {
1247        let mut manifest = sample_manifest();
1248        manifest.kind = PackKind::Infrastructure;
1249        let config = default_config(PathBuf::from("."));
1250        let plan = plan_from_pack_kind(&manifest, &config);
1251        assert!(plan.messaging.is_none(), "infra packs drop messaging plan");
1252    }
1253
1254    struct MemorySource {
1255        bytes: Vec<u8>,
1256    }
1257
1258    impl DistributorSource for MemorySource {
1259        fn fetch_pack(
1260            &self,
1261            _pack_id: &PackId,
1262            _version: &Version,
1263        ) -> std::result::Result<Vec<u8>, greentic_distributor_client::error::DistributorError>
1264        {
1265            Ok(self.bytes.clone())
1266        }
1267
1268        fn fetch_component(
1269            &self,
1270            _component_id: &greentic_distributor_client::ComponentId,
1271            _version: &Version,
1272        ) -> std::result::Result<Vec<u8>, greentic_distributor_client::error::DistributorError>
1273        {
1274            Err(greentic_distributor_client::error::DistributorError::NotFound)
1275        }
1276    }
1277
1278    #[test]
1279    fn registry_source_can_load_manifest() {
1280        let manifest = sample_manifest();
1281        let encoded = encode_pack_manifest(&manifest).expect("encode manifest");
1282        let source = MemorySource { bytes: encoded };
1283        let pack_id = PackId::try_from("dev.greentic.sample").unwrap();
1284        let reference = PackRef::new(
1285            pack_id.to_string(),
1286            Version::new(0, 1, 0),
1287            "sha256:deadbeef",
1288        );
1289        let decoded = read_manifest_from_registry(&source, &reference).expect("registry decode");
1290        assert_eq!(decoded.pack_id, manifest.pack_id);
1291    }
1292
1293    #[test]
1294    fn build_plan_uses_registry_when_pack_ref_set() {
1295        let manifest = sample_manifest();
1296        let encoded = encode_pack_manifest(&manifest).expect("encode manifest");
1297        let source = MemorySource { bytes: encoded };
1298        set_distributor_source(Arc::new(source));
1299
1300        let config = registry_config();
1301
1302        let plan = build_plan(&config).expect("plan builds via registry");
1303        assert_eq!(plan.plan.pack_id, manifest.pack_id.to_string());
1304    }
1305
1306    fn registry_config() -> DeployerConfig {
1307        DeployerConfig {
1308            capability: DeployerCapability::Plan,
1309            provider: Provider::Aws,
1310            strategy: "iac-only".into(),
1311            tenant: "acme".into(),
1312            environment: "staging".into(),
1313            pack_path: Some(PathBuf::from("unused.gtpack")),
1314            bundle_root: None,
1315            providers_dir: PathBuf::from("providers/deployer"),
1316            packs_dir: PathBuf::from("packs"),
1317            provider_pack: None,
1318            pack_ref: Some(PackRef::new(
1319                "dev.greentic.sample",
1320                Version::new(0, 1, 0),
1321                "sha256:deadbeef",
1322            )),
1323            distributor_url: None,
1324            distributor_token: None,
1325            preview: false,
1326            dry_run: false,
1327            execute_local: false,
1328            output: OutputFormat::Text,
1329            greentic: greentic_config::ConfigResolver::new()
1330                .load()
1331                .expect("load default config")
1332                .config,
1333            provenance: greentic_config::ProvenanceMap::new(),
1334            config_warnings: Vec::new(),
1335            deploy_pack_id_override: None,
1336            deploy_flow_id_override: None,
1337            bundle_source: None,
1338            bundle_digest: None,
1339            repo_registry_base: None,
1340            store_registry_base: None,
1341        }
1342    }
1343
1344    fn default_config(pack_path: PathBuf) -> DeployerConfig {
1345        DeployerConfig {
1346            capability: DeployerCapability::Plan,
1347            provider: Provider::Aws,
1348            strategy: "iac-only".into(),
1349            tenant: "acme".into(),
1350            environment: "staging".into(),
1351            pack_path: Some(pack_path),
1352            bundle_root: None,
1353            providers_dir: PathBuf::from("providers/deployer"),
1354            packs_dir: PathBuf::from("packs"),
1355            provider_pack: None,
1356            pack_ref: None,
1357            distributor_url: None,
1358            distributor_token: None,
1359            preview: false,
1360            dry_run: false,
1361            execute_local: false,
1362            output: OutputFormat::Text,
1363            greentic: greentic_config::ConfigResolver::new()
1364                .load()
1365                .expect("load default config")
1366                .config,
1367            provenance: greentic_config::ProvenanceMap::new(),
1368            config_warnings: Vec::new(),
1369            deploy_pack_id_override: None,
1370            deploy_flow_id_override: None,
1371            bundle_source: None,
1372            bundle_digest: None,
1373            repo_registry_base: None,
1374            store_registry_base: None,
1375        }
1376    }
1377}