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
33pub fn load_pack_manifest_from_bytes(bytes: &[u8]) -> Result<PackManifest> {
37 decode_pack_manifest(bytes).map_err(DeployerError::ManifestDecode)
38}
39
40pub 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 Ok(build_serviceless_plan(config))
60}
61
62fn 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
99pub 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#[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
202pub 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
216pub 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
291pub 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 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 plan.channels.clear();
494 plan.messaging = None;
495 plan
496}
497
498fn plan_library(manifest: &PackManifest, config: &DeployerConfig) -> DeploymentPlan {
499 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
648pub fn components_for_deployment(manifest: &PackManifest) -> Vec<&ComponentManifest> {
650 manifest.components.iter().collect()
651}
652
653pub 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
679pub 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
689pub 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
697pub 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 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}