1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::fs::File;
3use std::io::Read;
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6use std::sync::Arc;
7use std::time::Duration;
8
9use crate::cache::{ArtifactKey, CacheConfig, CacheManager, CpuPolicy, EngineProfile};
10use crate::component_api::{
11 self, node::ExecCtx as ComponentExecCtx, node::InvokeResult, node::NodeError,
12};
13use crate::oauth::{OAuthBrokerConfig, OAuthBrokerHost, OAuthHostContext};
14use crate::provider::{ProviderBinding, ProviderRegistry};
15use crate::provider_core::{
16 schema_core::SchemaCorePre as LegacySchemaCorePre,
17 schema_core_path::SchemaCorePre as PathSchemaCorePre,
18 schema_core_schema::SchemaCorePre as SchemaSchemaCorePre,
19};
20use crate::provider_core_only;
21use crate::runtime_wasmtime::{Component, Engine, InstancePre, Linker, ResourceTable};
22use anyhow::{Context, Result, anyhow, bail};
23use futures::executor::block_on;
24use greentic_distributor_client::dist::{
25 CachePolicy, DistClient, DistError, DistOptions, ResolvePolicy,
26};
27use greentic_interfaces_wasmtime::host_helpers::v1::{
28 self as host_v1, HostFns, add_all_v1_to_linker,
29 runner_host_http::RunnerHostHttp,
30 runner_host_kv::RunnerHostKv,
31 secrets_store::{SecretsError, SecretsErrorV1_1, SecretsStoreHost, SecretsStoreHostV1_1},
32 state_store::{
33 OpAck as StateOpAck, StateKey as HostStateKey, StateStoreError as StateError,
34 StateStoreHost, TenantCtx as StateTenantCtx,
35 },
36 telemetry_logger::{
37 OpAck as TelemetryAck, SpanContext as TelemetrySpanContext,
38 TelemetryLoggerError as TelemetryError, TelemetryLoggerHost,
39 TenantCtx as TelemetryTenantCtx,
40 },
41};
42use greentic_interfaces_wasmtime::http_client_client_v1_1::greentic::http::http_client as http_client_client_alias;
43use greentic_interfaces_wasmtime::{
44 http_client_client_v1_0::greentic::interfaces_types::types as http_types_v1_0,
45 http_client_client_v1_1::greentic::interfaces_types::types as http_types_v1_1,
46};
47use greentic_pack::builder as legacy_pack;
48use greentic_types::flow::FlowHasher;
49use greentic_types::{
50 ArtifactLocationV1, ComponentId, ComponentManifest, ComponentSourceRef, ComponentSourcesV1,
51 EXT_COMPONENT_SOURCES_V1, EnvId, ExtensionRef, Flow, FlowComponentRef, FlowId, FlowKind,
52 FlowMetadata, InputMapping, Node, NodeId, OutputMapping, Routing, StateKey as StoreStateKey,
53 TeamId, TelemetryHints, TenantCtx as TypesTenantCtx, TenantId, UserId, decode_pack_manifest,
54 pack_manifest::ExtensionInline,
55};
56use host_v1::http_client as host_http_client;
57use host_v1::http_client::{
58 HttpClientError, HttpClientErrorV1_1, HttpClientHost, HttpClientHostV1_1,
59 Request as HttpRequest, RequestOptionsV1_1 as HttpRequestOptionsV1_1,
60 RequestV1_1 as HttpRequestV1_1, Response as HttpResponse, ResponseV1_1 as HttpResponseV1_1,
61 TenantCtx as HttpTenantCtx, TenantCtxV1_1 as HttpTenantCtxV1_1,
62};
63use indexmap::IndexMap;
64use once_cell::sync::Lazy;
65use parking_lot::{Mutex, RwLock};
66use reqwest::blocking::Client as BlockingClient;
67use runner_core::normalize_under_root;
68use serde::{Deserialize, Serialize};
69use serde_cbor;
70use serde_json::{self, Value};
71use sha2::Digest;
72use tempfile::TempDir;
73use tokio::fs;
74use wasmparser::{Parser, Payload};
75use wasmtime::{Store, StoreContextMut};
76use wasmtime_wasi_http::WasiHttpCtx;
77use wasmtime_wasi_http::p2::{
78 WasiHttpCtxView, WasiHttpView, add_only_http_to_linker_sync as add_wasi_http_to_linker,
79};
80use wasmtime_wasi_tls::{LinkOptions, WasiTls, WasiTlsCtx, WasiTlsCtxBuilder};
81use zip::ZipArchive;
82
83use crate::runner::engine::{FlowContext, FlowEngine, FlowStatus};
84use crate::runner::flow_adapter::{FlowIR, flow_doc_to_ir, flow_ir_to_flow};
85use crate::runner::mocks::{HttpDecision, HttpMockRequest, HttpMockResponse, MockLayer};
86#[cfg(feature = "fault-injection")]
87use crate::testing::fault_injection::{FaultContext, FaultPoint, maybe_fail};
88
89use crate::config::HostConfig;
90use crate::fault;
91use crate::secrets::{
92 DynSecretsManager, canonicalize_secret_key, read_secret_blocking, write_secret_blocking,
93};
94use crate::storage::state::STATE_PREFIX;
95use crate::storage::{DynSessionStore, DynStateStore};
96use crate::verify;
97use crate::wasi::{PreopenSpec, RunnerWasiPolicy};
98use tracing::warn;
99use wasmtime_wasi::p2::add_to_linker_sync as add_wasi_to_linker;
100use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
101
102use greentic_flow::model::FlowDoc;
103
104#[allow(dead_code)]
105pub struct PackRuntime {
106 path: PathBuf,
108 archive_path: Option<PathBuf>,
110 config: Arc<HostConfig>,
111 engine: Engine,
112 metadata: PackMetadata,
113 manifest: Option<greentic_types::PackManifest>,
114 legacy_manifest: Option<Box<legacy_pack::PackManifest>>,
115 component_manifests: HashMap<String, ComponentManifest>,
116 mocks: Option<Arc<MockLayer>>,
117 flows: Option<PackFlows>,
118 components: HashMap<String, PackComponent>,
119 http_client: Arc<BlockingClient>,
120 pre_cache: Mutex<HashMap<String, InstancePre<ComponentState>>>,
121 session_store: Option<DynSessionStore>,
122 state_store: Option<DynStateStore>,
123 wasi_policy: Arc<RunnerWasiPolicy>,
124 assets_tempdir: Option<TempDir>,
125 provider_registry: RwLock<Option<ProviderRegistry>>,
126 secrets: DynSecretsManager,
127 oauth_config: Option<OAuthBrokerConfig>,
128 cache: CacheManager,
129}
130
131struct PackComponent {
132 #[allow(dead_code)]
133 name: String,
134 #[allow(dead_code)]
135 version: String,
136 component: Arc<Component>,
137}
138
139fn run_on_wasi_thread<F, T>(task_name: &'static str, task: F) -> Result<T>
140where
141 F: FnOnce() -> Result<T> + Send + 'static,
142 T: Send + 'static,
143{
144 let builder = std::thread::Builder::new().name(format!("greentic-wasmtime-{task_name}"));
145 let handle = builder
146 .spawn(move || {
147 let pid = std::process::id();
148 let thread_id = std::thread::current().id();
149 let tokio_handle_present = tokio::runtime::Handle::try_current().is_ok();
150 tracing::info!(
151 event = "wasmtime.thread.start",
152 task = task_name,
153 pid,
154 thread_id = ?thread_id,
155 tokio_handle_present,
156 "starting Wasmtime thread"
157 );
158 task()
159 })
160 .context("failed to spawn Wasmtime thread")?;
161 handle
162 .join()
163 .map_err(|err| {
164 let reason = if let Some(msg) = err.downcast_ref::<&str>() {
165 msg.to_string()
166 } else if let Some(msg) = err.downcast_ref::<String>() {
167 msg.clone()
168 } else {
169 "unknown panic".to_string()
170 };
171 anyhow!("Wasmtime thread panicked: {reason}")
172 })
173 .and_then(|res| res)
174}
175
176#[derive(Debug, Default, Clone)]
177pub struct ComponentResolution {
178 pub materialized_root: Option<PathBuf>,
180 pub overrides: HashMap<String, PathBuf>,
182 pub dist_offline: bool,
184 pub dist_cache_dir: Option<PathBuf>,
186 pub allow_missing_hash: bool,
188}
189
190fn build_blocking_client() -> BlockingClient {
191 std::thread::spawn(|| {
192 BlockingClient::builder()
193 .no_proxy()
194 .build()
195 .expect("blocking client")
196 })
197 .join()
198 .expect("client build thread panicked")
199}
200
201fn normalize_pack_path(path: &Path) -> Result<(PathBuf, PathBuf)> {
202 let (root, candidate) = if path.is_absolute() {
203 let parent = path
204 .parent()
205 .ok_or_else(|| anyhow!("pack path {} has no parent", path.display()))?;
206 let root = parent
207 .canonicalize()
208 .with_context(|| format!("failed to canonicalize {}", parent.display()))?;
209 let file = path
210 .file_name()
211 .ok_or_else(|| anyhow!("pack path {} has no file name", path.display()))?;
212 (root, PathBuf::from(file))
213 } else {
214 let cwd = std::env::current_dir().context("failed to resolve current directory")?;
215 let base = if let Some(parent) = path.parent() {
216 cwd.join(parent)
217 } else {
218 cwd
219 };
220 let root = base
221 .canonicalize()
222 .with_context(|| format!("failed to canonicalize {}", base.display()))?;
223 let file = path
224 .file_name()
225 .ok_or_else(|| anyhow!("pack path {} has no file name", path.display()))?;
226 (root, PathBuf::from(file))
227 };
228 let safe = normalize_under_root(&root, &candidate)?;
229 Ok((root, safe))
230}
231
232static HTTP_CLIENT: Lazy<Arc<BlockingClient>> = Lazy::new(|| Arc::new(build_blocking_client()));
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct FlowDescriptor {
236 pub id: String,
237 #[serde(rename = "type")]
238 pub flow_type: String,
239 pub pack_id: String,
240 pub profile: String,
241 pub version: String,
242 #[serde(default)]
243 pub description: Option<String>,
244}
245
246pub struct HostState {
247 #[allow(dead_code)]
248 pack_id: String,
249 config: Arc<HostConfig>,
250 http_client: Arc<BlockingClient>,
251 default_env: String,
252 #[allow(dead_code)]
253 session_store: Option<DynSessionStore>,
254 state_store: Option<DynStateStore>,
255 mocks: Option<Arc<MockLayer>>,
256 secrets: DynSecretsManager,
257 oauth_config: Option<OAuthBrokerConfig>,
258 oauth_host: OAuthBrokerHost,
259 exec_ctx: Option<ComponentExecCtx>,
260 component_ref: Option<String>,
261 provider_core_component: bool,
262}
263
264impl HostState {
265 #[allow(clippy::default_constructed_unit_structs)]
266 #[allow(clippy::too_many_arguments)]
267 pub fn new(
268 pack_id: String,
269 config: Arc<HostConfig>,
270 http_client: Arc<BlockingClient>,
271 mocks: Option<Arc<MockLayer>>,
272 session_store: Option<DynSessionStore>,
273 state_store: Option<DynStateStore>,
274 secrets: DynSecretsManager,
275 oauth_config: Option<OAuthBrokerConfig>,
276 exec_ctx: Option<ComponentExecCtx>,
277 component_ref: Option<String>,
278 provider_core_component: bool,
279 ) -> Result<Self> {
280 let default_env = std::env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string());
281 Ok(Self {
282 pack_id,
283 config,
284 http_client,
285 default_env,
286 session_store,
287 state_store,
288 mocks,
289 secrets,
290 oauth_config,
291 oauth_host: OAuthBrokerHost::default(),
292 exec_ctx,
293 component_ref,
294 provider_core_component,
295 })
296 }
297
298 fn instantiate_component_result(
299 linker: &mut Linker<ComponentState>,
300 store: &mut Store<ComponentState>,
301 component: &Component,
302 ctx: &ComponentExecCtx,
303 component_ref: &str,
304 operation: &str,
305 input_json: &str,
306 ) -> Result<InvokeResult> {
307 let pre_instance = linker.instantiate_pre(component)?;
308 match component_api::v0_6::ComponentPre::new(pre_instance) {
309 Ok(pre) => {
310 let envelope = component_api::envelope_v0_6(ctx, component_ref, input_json)?;
311 let operation_owned = operation.to_string();
312 let result = block_on(async {
313 let bindings = pre.instantiate_async(&mut *store).await?;
314 let node = bindings.greentic_component_node();
315 node.call_invoke(&mut *store, &operation_owned, &envelope)
316 })?;
317 component_api::invoke_result_from_v0_6(result)
318 }
319 Err(err_v06) => {
320 if !is_missing_node_export(&err_v06, "0.6.0") {
321 return Err(err_v06.into());
322 }
323 let pre_instance = linker.instantiate_pre(component)?;
324 match component_api::v0_5::ComponentPre::new(pre_instance) {
325 Ok(pre) => {
326 let result = block_on(async {
327 let bindings = pre.instantiate_async(&mut *store).await?;
328 let node = bindings.greentic_component_node();
329 let ctx_v05 = component_api::exec_ctx_v0_5(ctx);
330 let operation_owned = operation.to_string();
331 let input_owned = input_json.to_string();
332 node.call_invoke(&mut *store, &ctx_v05, &operation_owned, &input_owned)
333 })?;
334 Ok(component_api::invoke_result_from_v0_5(result))
335 }
336 Err(err) => {
337 if !is_missing_node_export(&err, "0.5.0") {
338 return Err(err.into());
339 }
340 let pre_instance = linker.instantiate_pre(component)?;
341 match component_api::v0_4::ComponentPre::new(pre_instance) {
342 Ok(pre) => {
343 let result = block_on(async {
344 let bindings = pre.instantiate_async(&mut *store).await?;
345 let node = bindings.greentic_component_node();
346 let ctx_v04 = component_api::exec_ctx_v0_4(ctx);
347 let operation_owned = operation.to_string();
348 let input_owned = input_json.to_string();
349 node.call_invoke(
350 &mut *store,
351 &ctx_v04,
352 &operation_owned,
353 &input_owned,
354 )
355 })?;
356 Ok(component_api::invoke_result_from_v0_4(result))
357 }
358 Err(err_v04) => {
359 if is_missing_node_export(&err_v04, "0.4.0") {
360 Self::try_v06_runtime(linker, store, component, input_json)
361 } else {
362 Err(err_v04.into())
363 }
364 }
365 }
366 }
367 }
368 }
369 }
370 }
371
372 fn try_v06_runtime(
375 linker: &mut Linker<ComponentState>,
376 store: &mut Store<ComponentState>,
377 component: &Component,
378 input_json: &str,
379 ) -> Result<InvokeResult> {
380 let pre_instance = linker.instantiate_pre(component)?;
381 let pre = component_api::v0_6_runtime::ComponentV0V6RuntimePre::new(pre_instance).map_err(
382 |err| err.context("component exports neither node@0.5/0.4 nor component-runtime@0.6"),
383 )?;
384
385 let result = block_on(async {
386 let bindings = pre.instantiate_async(&mut *store).await?;
387 let runtime = bindings.greentic_component_component_runtime();
388
389 let input_value: Value = serde_json::from_str(input_json).unwrap_or(Value::Null);
391 let input_cbor =
392 serde_cbor::to_vec(&input_value).context("encode input as CBOR for v0.6")?;
393 let empty_state = serde_cbor::to_vec(&Value::Object(Default::default()))
394 .context("encode empty state")?;
395
396 let run_result = runtime
397 .call_run(&mut *store, &input_cbor, &empty_state)
398 .map_err(|err| err.context("v0.6 component-runtime::run call failed"))?;
399
400 let output_value: Value = serde_cbor::from_slice(&run_result.output)
402 .context("decode v0.6 run output CBOR")?;
403 let output_json = serde_json::to_string(&output_value)
404 .context("serialize v0.6 run output to JSON")?;
405
406 Ok::<_, anyhow::Error>(output_json)
407 })?;
408
409 Ok(InvokeResult::Ok(result))
410 }
411
412 fn convert_invoke_result(result: InvokeResult) -> Result<Value> {
413 match result {
414 InvokeResult::Ok(body) => {
415 if body.is_empty() {
416 return Ok(Value::Null);
417 }
418 serde_json::from_str(&body).or_else(|_| Ok(Value::String(body)))
419 }
420 InvokeResult::Err(NodeError {
421 code,
422 message,
423 retryable,
424 backoff_ms,
425 details,
426 }) => {
427 let mut obj = serde_json::Map::new();
428 obj.insert("ok".into(), Value::Bool(false));
429 let mut error = serde_json::Map::new();
430 error.insert("code".into(), Value::String(code));
431 error.insert("message".into(), Value::String(message));
432 error.insert("retryable".into(), Value::Bool(retryable));
433 if let Some(backoff) = backoff_ms {
434 error.insert("backoff_ms".into(), Value::Number(backoff.into()));
435 }
436 if let Some(details) = details {
437 error.insert(
438 "details".into(),
439 serde_json::from_str(&details).unwrap_or(Value::String(details)),
440 );
441 }
442 obj.insert("error".into(), Value::Object(error));
443 Ok(Value::Object(obj))
444 }
445 }
446 }
447
448 fn secrets_tenant_ctx(&self) -> TypesTenantCtx {
452 let mut ctx = self.config.tenant_ctx();
453 if let Some(exec_ctx) = self.exec_ctx.as_ref()
454 && let Some(team) = exec_ctx.tenant.team.as_ref()
455 && let Ok(team_id) = TeamId::from_str(team)
456 {
457 ctx = ctx.with_team(Some(team_id));
458 }
459 ctx
460 }
461
462 pub fn get_secret(&self, key: &str) -> Result<String> {
463 if provider_core_only::is_enabled() {
464 bail!(provider_core_only::blocked_message("secrets"))
465 }
466 if !self.config.secrets_policy.is_allowed(key) {
467 bail!("secret {key} is not permitted by bindings policy");
468 }
469 if let Some(mock) = &self.mocks
470 && let Some(value) = mock.secrets_lookup(key)
471 {
472 return Ok(value);
473 }
474 let ctx = self.secrets_tenant_ctx();
475 let canonical_key = canonicalize_secret_key(key);
476 let bytes = read_secret_blocking(&self.secrets, &ctx, &self.pack_id, &canonical_key)
477 .context("failed to read secret from manager")?;
478 let value = String::from_utf8(bytes).context("secret value is not valid UTF-8")?;
479 Ok(value)
480 }
481
482 fn allows_secret_write_in_provider_core_only(&self) -> bool {
483 self.provider_core_component || self.component_ref.is_none()
484 }
485
486 fn tenant_ctx_from_v1(&self, ctx: Option<StateTenantCtx>) -> Result<TypesTenantCtx> {
487 let tenant_raw = ctx
488 .as_ref()
489 .map(|ctx| ctx.tenant.clone())
490 .or_else(|| self.exec_ctx.as_ref().map(|ctx| ctx.tenant.tenant.clone()))
491 .unwrap_or_else(|| self.config.tenant.clone());
492 let env_raw = ctx
493 .as_ref()
494 .map(|ctx| ctx.env.clone())
495 .unwrap_or_else(|| self.default_env.clone());
496 let tenant_id = TenantId::from_str(&tenant_raw)
497 .with_context(|| format!("invalid tenant id `{tenant_raw}`"))?;
498 let env_id = EnvId::from_str(&env_raw)
499 .unwrap_or_else(|_| EnvId::from_str("local").expect("default env must be valid"));
500 let mut tenant_ctx = TypesTenantCtx::new(env_id, tenant_id);
501 if let Some(exec_ctx) = self.exec_ctx.as_ref() {
502 if let Some(team) = exec_ctx.tenant.team.as_ref() {
503 let team_id =
504 TeamId::from_str(team).with_context(|| format!("invalid team id `{team}`"))?;
505 tenant_ctx = tenant_ctx.with_team(Some(team_id));
506 }
507 if let Some(user) = exec_ctx.tenant.user.as_ref() {
508 let user_id =
509 UserId::from_str(user).with_context(|| format!("invalid user id `{user}`"))?;
510 tenant_ctx = tenant_ctx.with_user(Some(user_id));
511 }
512 tenant_ctx = tenant_ctx.with_flow(exec_ctx.flow_id.clone());
513 if let Some(node) = exec_ctx.node_id.as_ref() {
514 tenant_ctx = tenant_ctx.with_node(node.clone());
515 }
516 if let Some(session) = exec_ctx.tenant.correlation_id.as_ref() {
517 tenant_ctx = tenant_ctx.with_session(session.clone());
518 }
519 tenant_ctx.trace_id = exec_ctx.tenant.trace_id.clone();
520 }
521
522 if let Some(ctx) = ctx {
523 if let Some(team) = ctx.team.or(ctx.team_id) {
524 let team_id =
525 TeamId::from_str(&team).with_context(|| format!("invalid team id `{team}`"))?;
526 tenant_ctx = tenant_ctx.with_team(Some(team_id));
527 }
528 if let Some(user) = ctx.user.or(ctx.user_id) {
529 let user_id =
530 UserId::from_str(&user).with_context(|| format!("invalid user id `{user}`"))?;
531 tenant_ctx = tenant_ctx.with_user(Some(user_id));
532 }
533 if let Some(flow) = ctx.flow_id {
534 tenant_ctx = tenant_ctx.with_flow(flow);
535 }
536 if let Some(node) = ctx.node_id {
537 tenant_ctx = tenant_ctx.with_node(node);
538 }
539 if let Some(provider) = ctx.provider_id {
540 tenant_ctx = tenant_ctx.with_provider(provider);
541 }
542 if let Some(session) = ctx.session_id {
543 tenant_ctx = tenant_ctx.with_session(session);
544 }
545 tenant_ctx.trace_id = ctx.trace_id;
546 }
547 Ok(tenant_ctx)
548 }
549
550 fn send_http_request(
551 &mut self,
552 req: HttpRequest,
553 opts: Option<HttpRequestOptionsV1_1>,
554 _ctx: Option<HttpTenantCtx>,
555 ) -> Result<HttpResponse, HttpClientError> {
556 if !self.config.http_enabled {
557 return Err(HttpClientError {
558 code: "denied".into(),
559 message: "http client disabled by policy".into(),
560 });
561 }
562
563 let mut mock_state = None;
564 let raw_body = req.body.clone();
565 if let Some(mock) = &self.mocks
566 && let Ok(meta) = HttpMockRequest::new(&req.method, &req.url, raw_body.as_deref())
567 {
568 match mock.http_begin(&meta) {
569 HttpDecision::Mock(response) => {
570 let headers = response
571 .headers
572 .iter()
573 .map(|(k, v)| (k.clone(), v.clone()))
574 .collect();
575 return Ok(HttpResponse {
576 status: response.status,
577 headers,
578 body: response.body.clone().map(|b| b.into_bytes()),
579 });
580 }
581 HttpDecision::Deny(reason) => {
582 return Err(HttpClientError {
583 code: "denied".into(),
584 message: reason,
585 });
586 }
587 HttpDecision::Passthrough { record } => {
588 mock_state = Some((meta, record));
589 }
590 }
591 }
592
593 let method = req.method.parse().unwrap_or(reqwest::Method::GET);
594 let mut builder = self.http_client.request(method, &req.url);
595 for (key, value) in req.headers {
596 if let Ok(header) = reqwest::header::HeaderName::from_bytes(key.as_bytes())
597 && let Ok(header_value) = reqwest::header::HeaderValue::from_str(&value)
598 {
599 builder = builder.header(header, header_value);
600 }
601 }
602
603 if let Some(body) = raw_body.clone() {
604 builder = builder.body(body);
605 }
606
607 if let Some(opts) = opts {
608 if let Some(timeout_ms) = opts.timeout_ms {
609 builder = builder.timeout(Duration::from_millis(timeout_ms as u64));
610 }
611 if opts.allow_insecure == Some(true) {
612 warn!(url = %req.url, "allow-insecure not supported; using default TLS validation");
613 }
614 if let Some(follow_redirects) = opts.follow_redirects
615 && !follow_redirects
616 {
617 warn!(url = %req.url, "follow-redirects=false not supported; using default client behaviour");
618 }
619 }
620
621 let response = match builder.send() {
622 Ok(resp) => resp,
623 Err(err) => {
624 warn!(url = %req.url, error = %err, "http client request failed");
625 return Err(HttpClientError {
626 code: "unavailable".into(),
627 message: err.to_string(),
628 });
629 }
630 };
631
632 let status = response.status().as_u16();
633 let headers_vec = response
634 .headers()
635 .iter()
636 .map(|(k, v)| {
637 (
638 k.as_str().to_string(),
639 v.to_str().unwrap_or_default().to_string(),
640 )
641 })
642 .collect::<Vec<_>>();
643 let body_bytes = response.bytes().ok().map(|b| b.to_vec());
644
645 if let Some((meta, true)) = mock_state.take()
646 && let Some(mock) = &self.mocks
647 {
648 let recorded = HttpMockResponse::new(
649 status,
650 headers_vec.clone().into_iter().collect(),
651 body_bytes
652 .as_ref()
653 .map(|b| String::from_utf8_lossy(b).into_owned()),
654 );
655 mock.http_record(&meta, &recorded);
656 }
657
658 Ok(HttpResponse {
659 status,
660 headers: headers_vec,
661 body: body_bytes,
662 })
663 }
664}
665
666#[cfg(test)]
667mod canonicalize_tests {
668 use crate::secrets::canonicalize_secret_key;
669
670 #[test]
671 fn upper_snake_to_lower_snake() {
672 assert_eq!(
673 canonicalize_secret_key("TELEGRAM_BOT_TOKEN"),
674 "telegram_bot_token"
675 );
676 }
677
678 #[test]
679 fn trim_and_replace_non_alphanumeric() {
680 assert_eq!(
681 canonicalize_secret_key(" webex-bot-token "),
682 "webex_bot_token"
683 );
684 }
685
686 #[test]
687 fn preserve_existing_lower_snake_with_extra_underscores() {
688 assert_eq!(canonicalize_secret_key("MiXeD__Case"), "mixed__case");
689 }
690}
691
692impl SecretsStoreHost for HostState {
693 fn get(&mut self, key: String) -> Result<Option<Vec<u8>>, SecretsError> {
694 if provider_core_only::is_enabled() {
695 warn!(secret = %key, "provider-core only mode enabled; blocking secrets store");
696 return Err(SecretsError::Denied);
697 }
698 if !self.config.secrets_policy.is_allowed(&key) {
699 return Err(SecretsError::Denied);
700 }
701 if let Some(mock) = &self.mocks
702 && let Some(value) = mock.secrets_lookup(&key)
703 {
704 return Ok(Some(value.into_bytes()));
705 }
706 let ctx = self.secrets_tenant_ctx();
707 let canonical_key = canonicalize_secret_key(&key);
708 match read_secret_blocking(&self.secrets, &ctx, &self.pack_id, &canonical_key) {
709 Ok(bytes) => Ok(Some(bytes)),
710 Err(err) => {
711 warn!(secret = %key, canonical = %canonical_key, error = %err, "secret lookup failed");
712 Err(SecretsError::NotFound)
713 }
714 }
715 }
716}
717
718impl SecretsStoreHostV1_1 for HostState {
719 fn get(&mut self, key: String) -> Result<Option<Vec<u8>>, SecretsErrorV1_1> {
720 if provider_core_only::is_enabled() {
721 warn!(secret = %key, "provider-core only mode enabled; blocking secrets store");
722 return Err(SecretsErrorV1_1::Denied);
723 }
724 if !self.config.secrets_policy.is_allowed(&key) {
725 return Err(SecretsErrorV1_1::Denied);
726 }
727 if let Some(mock) = &self.mocks
728 && let Some(value) = mock.secrets_lookup(&key)
729 {
730 return Ok(Some(value.into_bytes()));
731 }
732 let ctx = self.secrets_tenant_ctx();
733 let canonical_key = canonicalize_secret_key(&key);
734 match read_secret_blocking(&self.secrets, &ctx, &self.pack_id, &canonical_key) {
735 Ok(bytes) => Ok(Some(bytes)),
736 Err(err) => {
737 warn!(secret = %key, canonical = %canonical_key, error = %err, "secret lookup failed");
738 Err(SecretsErrorV1_1::NotFound)
739 }
740 }
741 }
742
743 fn put(&mut self, key: String, value: Vec<u8>) {
744 if key.trim().is_empty() {
745 warn!(secret = %key, "secret write blocked: empty key");
746 panic!("secret write denied for key {key}: invalid key");
747 }
748 if provider_core_only::is_enabled() && !self.allows_secret_write_in_provider_core_only() {
749 warn!(
750 secret = %key,
751 component = self.component_ref.as_deref().unwrap_or("<pack>"),
752 "provider-core only mode enabled; blocking secrets store write"
753 );
754 panic!("secret write denied for key {key}: provider-core-only mode");
755 }
756 if !self.config.secrets_policy.is_allowed(&key) {
757 warn!(secret = %key, "secret write denied by bindings policy");
758 panic!("secret write denied for key {key}: policy");
759 }
760 let ctx = self.secrets_tenant_ctx();
761 let canonical_key = canonicalize_secret_key(&key);
762 if let Err(err) =
763 write_secret_blocking(&self.secrets, &ctx, &self.pack_id, &canonical_key, &value)
764 {
765 warn!(secret = %key, canonical = %canonical_key, error = %err, "secret write failed");
766 panic!("secret write failed for key {key}");
767 }
768 }
769}
770
771impl HttpClientHost for HostState {
772 fn send(
773 &mut self,
774 req: HttpRequest,
775 ctx: Option<HttpTenantCtx>,
776 ) -> Result<HttpResponse, HttpClientError> {
777 self.send_http_request(req, None, ctx)
778 }
779}
780
781impl HttpClientHostV1_1 for HostState {
782 fn send(
783 &mut self,
784 req: HttpRequestV1_1,
785 opts: Option<HttpRequestOptionsV1_1>,
786 ctx: Option<HttpTenantCtxV1_1>,
787 ) -> Result<HttpResponseV1_1, HttpClientErrorV1_1> {
788 let legacy_req = HttpRequest {
789 method: req.method,
790 url: req.url,
791 headers: req.headers,
792 body: req.body,
793 };
794 let legacy_ctx = ctx.map(|ctx| HttpTenantCtx {
795 env: ctx.env,
796 tenant: ctx.tenant,
797 tenant_id: ctx.tenant_id,
798 team: ctx.team,
799 team_id: ctx.team_id,
800 user: ctx.user,
801 user_id: ctx.user_id,
802 trace_id: ctx.trace_id,
803 correlation_id: ctx.correlation_id,
804 i18n_id: ctx.i18n_id,
805 attributes: ctx.attributes,
806 session_id: ctx.session_id,
807 flow_id: ctx.flow_id,
808 node_id: ctx.node_id,
809 provider_id: ctx.provider_id,
810 deadline_ms: ctx.deadline_ms,
811 attempt: ctx.attempt,
812 idempotency_key: ctx.idempotency_key,
813 impersonation: ctx.impersonation.map(|imp| http_types_v1_0::Impersonation {
814 actor_id: imp.actor_id,
815 reason: imp.reason,
816 }),
817 });
818
819 self.send_http_request(legacy_req, opts, legacy_ctx)
820 .map(|resp| HttpResponseV1_1 {
821 status: resp.status,
822 headers: resp.headers,
823 body: resp.body,
824 })
825 .map_err(|err| HttpClientErrorV1_1 {
826 code: err.code,
827 message: err.message,
828 })
829 }
830}
831
832impl StateStoreHost for HostState {
833 fn read(
834 &mut self,
835 key: HostStateKey,
836 ctx: Option<StateTenantCtx>,
837 ) -> Result<Vec<u8>, StateError> {
838 let store = match self.state_store.as_ref() {
839 Some(store) => store.clone(),
840 None => {
841 return Err(StateError {
842 code: "unavailable".into(),
843 message: "state store not configured".into(),
844 });
845 }
846 };
847 let tenant_ctx = match self.tenant_ctx_from_v1(ctx) {
848 Ok(ctx) => ctx,
849 Err(err) => {
850 return Err(StateError {
851 code: "invalid-ctx".into(),
852 message: err.to_string(),
853 });
854 }
855 };
856 #[cfg(feature = "fault-injection")]
857 {
858 let exec_ctx = self.exec_ctx.as_ref();
859 let flow_id = exec_ctx
860 .map(|ctx| ctx.flow_id.as_str())
861 .unwrap_or("unknown");
862 let node_id = exec_ctx.and_then(|ctx| ctx.node_id.as_deref());
863 let attempt = exec_ctx.map(|ctx| ctx.tenant.attempt).unwrap_or(1);
864 let fault_ctx = FaultContext {
865 pack_id: self.pack_id.as_str(),
866 flow_id,
867 node_id,
868 attempt,
869 };
870 if let Err(err) = maybe_fail(FaultPoint::StateRead, fault_ctx) {
871 return Err(StateError {
872 code: "internal".into(),
873 message: err.to_string(),
874 });
875 }
876 }
877 let key = StoreStateKey::from(key);
878 match store.get_json(&tenant_ctx, STATE_PREFIX, &key, None) {
879 Ok(Some(value)) => Ok(serde_json::to_vec(&value).unwrap_or_else(|_| Vec::new())),
880 Ok(None) => Err(StateError {
881 code: "not_found".into(),
882 message: "state key not found".into(),
883 }),
884 Err(err) => Err(StateError {
885 code: "internal".into(),
886 message: err.to_string(),
887 }),
888 }
889 }
890
891 fn write(
892 &mut self,
893 key: HostStateKey,
894 bytes: Vec<u8>,
895 ctx: Option<StateTenantCtx>,
896 ) -> Result<StateOpAck, StateError> {
897 let store = match self.state_store.as_ref() {
898 Some(store) => store.clone(),
899 None => {
900 return Err(StateError {
901 code: "unavailable".into(),
902 message: "state store not configured".into(),
903 });
904 }
905 };
906 let tenant_ctx = match self.tenant_ctx_from_v1(ctx) {
907 Ok(ctx) => ctx,
908 Err(err) => {
909 return Err(StateError {
910 code: "invalid-ctx".into(),
911 message: err.to_string(),
912 });
913 }
914 };
915 #[cfg(feature = "fault-injection")]
916 {
917 let exec_ctx = self.exec_ctx.as_ref();
918 let flow_id = exec_ctx
919 .map(|ctx| ctx.flow_id.as_str())
920 .unwrap_or("unknown");
921 let node_id = exec_ctx.and_then(|ctx| ctx.node_id.as_deref());
922 let attempt = exec_ctx.map(|ctx| ctx.tenant.attempt).unwrap_or(1);
923 let fault_ctx = FaultContext {
924 pack_id: self.pack_id.as_str(),
925 flow_id,
926 node_id,
927 attempt,
928 };
929 if let Err(err) = maybe_fail(FaultPoint::StateWrite, fault_ctx) {
930 return Err(StateError {
931 code: "internal".into(),
932 message: err.to_string(),
933 });
934 }
935 }
936 let key = StoreStateKey::from(key);
937 let value = serde_json::from_slice(&bytes)
938 .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).to_string()));
939 match store.set_json(&tenant_ctx, STATE_PREFIX, &key, None, &value, None) {
940 Ok(()) => Ok(StateOpAck::Ok),
941 Err(err) => Err(StateError {
942 code: "internal".into(),
943 message: err.to_string(),
944 }),
945 }
946 }
947
948 fn delete(
949 &mut self,
950 key: HostStateKey,
951 ctx: Option<StateTenantCtx>,
952 ) -> Result<StateOpAck, StateError> {
953 let store = match self.state_store.as_ref() {
954 Some(store) => store.clone(),
955 None => {
956 return Err(StateError {
957 code: "unavailable".into(),
958 message: "state store not configured".into(),
959 });
960 }
961 };
962 let tenant_ctx = match self.tenant_ctx_from_v1(ctx) {
963 Ok(ctx) => ctx,
964 Err(err) => {
965 return Err(StateError {
966 code: "invalid-ctx".into(),
967 message: err.to_string(),
968 });
969 }
970 };
971 let key = StoreStateKey::from(key);
972 match store.del(&tenant_ctx, STATE_PREFIX, &key) {
973 Ok(_) => Ok(StateOpAck::Ok),
974 Err(err) => Err(StateError {
975 code: "internal".into(),
976 message: err.to_string(),
977 }),
978 }
979 }
980}
981
982impl TelemetryLoggerHost for HostState {
983 fn log(
984 &mut self,
985 span: TelemetrySpanContext,
986 fields: Vec<(String, String)>,
987 _ctx: Option<TelemetryTenantCtx>,
988 ) -> Result<TelemetryAck, TelemetryError> {
989 if let Some(mock) = &self.mocks
990 && mock.telemetry_drain(&[("span_json", span.flow_id.as_str())])
991 {
992 return Ok(TelemetryAck::Ok);
993 }
994 let mut map = serde_json::Map::new();
995 for (k, v) in fields {
996 map.insert(k, Value::String(v));
997 }
998 tracing::info!(
999 tenant = %span.tenant,
1000 flow_id = %span.flow_id,
1001 node = ?span.node_id,
1002 provider = %span.provider,
1003 fields = %serde_json::Value::Object(map.clone()),
1004 "telemetry log from pack"
1005 );
1006 Ok(TelemetryAck::Ok)
1007 }
1008}
1009
1010impl RunnerHostHttp for HostState {
1011 fn request(
1012 &mut self,
1013 method: String,
1014 url: String,
1015 headers: Vec<String>,
1016 body: Option<Vec<u8>>,
1017 ) -> Result<Vec<u8>, String> {
1018 let req = HttpRequest {
1019 method,
1020 url,
1021 headers: headers
1022 .chunks(2)
1023 .filter_map(|chunk| {
1024 if chunk.len() == 2 {
1025 Some((chunk[0].clone(), chunk[1].clone()))
1026 } else {
1027 None
1028 }
1029 })
1030 .collect(),
1031 body,
1032 };
1033 match HttpClientHost::send(self, req, None) {
1034 Ok(resp) => Ok(resp.body.unwrap_or_default()),
1035 Err(err) => Err(err.message),
1036 }
1037 }
1038}
1039
1040impl RunnerHostKv for HostState {
1041 fn get(&mut self, _ns: String, _key: String) -> Option<String> {
1042 None
1043 }
1044
1045 fn put(&mut self, _ns: String, _key: String, _val: String) {}
1046}
1047
1048enum ManifestLoad {
1049 New {
1050 manifest: Box<greentic_types::PackManifest>,
1051 flows: PackFlows,
1052 },
1053 Legacy {
1054 manifest: Box<legacy_pack::PackManifest>,
1055 flows: PackFlows,
1056 },
1057}
1058
1059fn load_manifest_and_flows(path: &Path) -> Result<ManifestLoad> {
1060 let mut archive = ZipArchive::new(File::open(path)?)
1061 .with_context(|| format!("{} is not a valid gtpack", path.display()))?;
1062 let bytes = read_entry(&mut archive, "manifest.cbor")
1063 .with_context(|| format!("missing manifest.cbor in {}", path.display()))?;
1064 match decode_pack_manifest(&bytes) {
1065 Ok(manifest) => {
1066 let cache = PackFlows::from_manifest(manifest.clone());
1067 Ok(ManifestLoad::New {
1068 manifest: Box::new(manifest),
1069 flows: cache,
1070 })
1071 }
1072 Err(err) => {
1073 tracing::debug!(
1074 error = %err,
1075 pack = %path.display(),
1076 "decode_pack_manifest failed for archive; falling back to legacy manifest"
1077 );
1078 let legacy: legacy_pack::PackManifest = serde_cbor::from_slice(&bytes)
1079 .context("failed to decode legacy pack manifest from manifest.cbor")?;
1080 let flows = load_legacy_flows_from_archive(&mut archive, &legacy)?;
1081 Ok(ManifestLoad::Legacy {
1082 manifest: Box::new(legacy),
1083 flows,
1084 })
1085 }
1086 }
1087}
1088
1089fn load_manifest_and_flows_from_dir(root: &Path) -> Result<ManifestLoad> {
1090 let manifest_path = root.join("manifest.cbor");
1091 let bytes = std::fs::read(&manifest_path)
1092 .with_context(|| format!("missing manifest.cbor in {}", root.display()))?;
1093 match decode_pack_manifest(&bytes) {
1094 Ok(manifest) => {
1095 let cache = PackFlows::from_manifest(manifest.clone());
1096 Ok(ManifestLoad::New {
1097 manifest: Box::new(manifest),
1098 flows: cache,
1099 })
1100 }
1101 Err(err) => {
1102 tracing::debug!(
1103 error = %err,
1104 pack = %root.display(),
1105 "decode_pack_manifest failed for materialized pack; trying legacy manifest"
1106 );
1107 let legacy: legacy_pack::PackManifest = serde_cbor::from_slice(&bytes)
1108 .context("failed to decode legacy pack manifest from manifest.cbor")?;
1109 let flows = load_legacy_flows_from_dir(root, &legacy)?;
1110 Ok(ManifestLoad::Legacy {
1111 manifest: Box::new(legacy),
1112 flows,
1113 })
1114 }
1115 }
1116}
1117
1118fn load_legacy_flows_from_dir(
1119 root: &Path,
1120 manifest: &legacy_pack::PackManifest,
1121) -> Result<PackFlows> {
1122 build_legacy_flows(manifest, |rel_path| {
1123 let path = root.join(rel_path);
1124 std::fs::read(&path).with_context(|| format!("missing flow json {}", path.display()))
1125 })
1126}
1127
1128fn load_legacy_flows_from_archive(
1129 archive: &mut ZipArchive<File>,
1130 manifest: &legacy_pack::PackManifest,
1131) -> Result<PackFlows> {
1132 build_legacy_flows(manifest, |rel_path| {
1133 read_entry(archive, rel_path).with_context(|| format!("missing flow json {}", rel_path))
1134 })
1135}
1136
1137fn build_legacy_flows(
1138 manifest: &legacy_pack::PackManifest,
1139 mut read_json: impl FnMut(&str) -> Result<Vec<u8>>,
1140) -> Result<PackFlows> {
1141 let mut flows = HashMap::new();
1142 let mut descriptors = Vec::new();
1143
1144 for entry in &manifest.flows {
1145 let bytes = read_json(&entry.file_json)
1146 .with_context(|| format!("missing flow json {}", entry.file_json))?;
1147 let doc = parse_flow_doc_with_legacy_aliases(&bytes)?;
1148 let normalized = normalize_flow_doc(doc);
1149 let flow_ir = flow_doc_to_ir(normalized)?;
1150 let flow = flow_ir_to_flow(flow_ir)?;
1151
1152 descriptors.push(FlowDescriptor {
1153 id: entry.id.clone(),
1154 flow_type: entry.kind.clone(),
1155 pack_id: manifest.meta.pack_id.clone(),
1156 profile: manifest.meta.pack_id.clone(),
1157 version: manifest.meta.version.to_string(),
1158 description: None,
1159 });
1160 flows.insert(entry.id.clone(), flow);
1161 }
1162
1163 let mut entry_flows = manifest.meta.entry_flows.clone();
1164 if entry_flows.is_empty() {
1165 entry_flows = manifest.flows.iter().map(|f| f.id.clone()).collect();
1166 }
1167 let metadata = PackMetadata {
1168 pack_id: manifest.meta.pack_id.clone(),
1169 version: manifest.meta.version.to_string(),
1170 entry_flows,
1171 secret_requirements: Vec::new(),
1172 };
1173
1174 Ok(PackFlows {
1175 descriptors,
1176 flows,
1177 metadata,
1178 })
1179}
1180
1181fn parse_flow_doc_with_legacy_aliases(bytes: &[u8]) -> Result<FlowDoc> {
1182 let mut value: Value =
1183 serde_json::from_slice(bytes).context("failed to decode flow doc JSON")?;
1184 if let Some(map) = value.as_object_mut()
1185 && !map.contains_key("type")
1186 && let Some(flow_type) = map.remove("flow_type")
1187 {
1188 map.insert("type".to_string(), flow_type);
1189 }
1190 serde_json::from_value(value).context("failed to decode flow doc structure")
1191}
1192
1193pub struct ComponentState {
1194 pub host: HostState,
1195 wasi_ctx: WasiCtx,
1196 wasi_tls_ctx: WasiTlsCtx,
1197 wasi_http_ctx: WasiHttpCtx,
1198 resource_table: ResourceTable,
1199}
1200
1201impl ComponentState {
1202 pub fn new(host: HostState, policy: Arc<RunnerWasiPolicy>) -> Result<Self> {
1203 let wasi_ctx = policy
1204 .instantiate()
1205 .context("failed to build WASI context")?;
1206 Ok(Self {
1207 host,
1208 wasi_ctx,
1209 wasi_tls_ctx: WasiTlsCtxBuilder::new().build(),
1210 wasi_http_ctx: WasiHttpCtx::new(),
1211 resource_table: ResourceTable::new(),
1212 })
1213 }
1214
1215 fn host_mut(&mut self) -> &mut HostState {
1216 &mut self.host
1217 }
1218
1219 fn should_cancel_host(&mut self) -> bool {
1220 false
1221 }
1222
1223 fn yield_now_host(&mut self) {
1224 }
1226}
1227
1228impl component_api::v0_4::greentic::component::control::Host for ComponentState {
1229 fn should_cancel(&mut self) -> bool {
1230 self.should_cancel_host()
1231 }
1232
1233 fn yield_now(&mut self) {
1234 self.yield_now_host();
1235 }
1236}
1237
1238impl component_api::v0_5::greentic::component::control::Host for ComponentState {
1239 fn should_cancel(&mut self) -> bool {
1240 self.should_cancel_host()
1241 }
1242
1243 fn yield_now(&mut self) {
1244 self.yield_now_host();
1245 }
1246}
1247
1248fn add_component_control_instance(
1249 linker: &mut Linker<ComponentState>,
1250 name: &str,
1251) -> wasmtime::Result<()> {
1252 let mut inst = linker.instance(name)?;
1253 inst.func_wrap(
1254 "should-cancel",
1255 |mut caller: StoreContextMut<'_, ComponentState>, (): ()| {
1256 let host = caller.data_mut();
1257 Ok((host.should_cancel_host(),))
1258 },
1259 )?;
1260 inst.func_wrap(
1261 "yield-now",
1262 |mut caller: StoreContextMut<'_, ComponentState>, (): ()| {
1263 let host = caller.data_mut();
1264 host.yield_now_host();
1265 Ok(())
1266 },
1267 )?;
1268 Ok(())
1269}
1270
1271fn add_component_control_to_linker(linker: &mut Linker<ComponentState>) -> wasmtime::Result<()> {
1272 add_component_control_instance(linker, "greentic:component/control@0.5.0")?;
1273 add_component_control_instance(linker, "greentic:component/control@0.4.0")?;
1274 Ok(())
1275}
1276
1277pub fn register_all(linker: &mut Linker<ComponentState>, allow_state_store: bool) -> Result<()> {
1278 add_wasi_to_linker(linker)?;
1279
1280 let mut opts = LinkOptions::default();
1282 opts.tls(true);
1283 wasmtime_wasi_tls::add_to_linker(linker, &mut opts, |h: &mut ComponentState| {
1284 WasiTls::new(&h.wasi_tls_ctx, &mut h.resource_table)
1285 })?;
1286
1287 add_wasi_http_to_linker(linker)?;
1289
1290 add_all_v1_to_linker(
1291 linker,
1292 HostFns {
1293 http_client_v1_1: Some(|state: &mut ComponentState| state.host_mut()),
1294 http_client: Some(|state: &mut ComponentState| state.host_mut()),
1295 oauth_broker: None,
1296 runner_host_http: Some(|state: &mut ComponentState| state.host_mut()),
1297 runner_host_kv: Some(|state: &mut ComponentState| state.host_mut()),
1298 telemetry_logger: Some(|state: &mut ComponentState| state.host_mut()),
1299 state_store: allow_state_store.then_some(|state: &mut ComponentState| state.host_mut()),
1300 secrets_store_v1_1: Some(|state: &mut ComponentState| state.host_mut()),
1301 secrets_store: None,
1302 },
1303 )?;
1304 add_http_client_client_world_aliases(linker)?;
1305 Ok(())
1306}
1307
1308fn add_http_client_client_world_aliases(linker: &mut Linker<ComponentState>) -> Result<()> {
1309 let mut inst_v1_1 = linker.instance("greentic:http/client@1.1.0")?;
1310 inst_v1_1.func_wrap(
1311 "send",
1312 move |mut caller: StoreContextMut<'_, ComponentState>,
1313 (req, opts, ctx): (
1314 http_client_client_alias::Request,
1315 Option<http_client_client_alias::RequestOptions>,
1316 Option<http_client_client_alias::TenantCtx>,
1317 )| {
1318 let host = caller.data_mut().host_mut();
1319 let result = HttpClientHostV1_1::send(
1320 host,
1321 alias_request_to_host(req),
1322 opts.map(alias_request_options_to_host),
1323 ctx.map(alias_tenant_ctx_to_host),
1324 );
1325 Ok((match result {
1326 Ok(resp) => Ok(alias_response_from_host(resp)),
1327 Err(err) => Err(alias_error_from_host(err)),
1328 },))
1329 },
1330 )?;
1331 let mut inst_v1_0 = linker.instance("greentic:http/client@1.0.0")?;
1332 inst_v1_0.func_wrap(
1333 "send",
1334 move |mut caller: StoreContextMut<'_, ComponentState>,
1335 (req, ctx): (
1336 host_http_client::Request,
1337 Option<host_http_client::TenantCtx>,
1338 )| {
1339 let host = caller.data_mut().host_mut();
1340 let result = HttpClientHost::send(host, req, ctx);
1341 Ok((result,))
1342 },
1343 )?;
1344 Ok(())
1345}
1346
1347fn alias_request_to_host(req: http_client_client_alias::Request) -> host_http_client::RequestV1_1 {
1348 host_http_client::RequestV1_1 {
1349 method: req.method,
1350 url: req.url,
1351 headers: req.headers,
1352 body: req.body,
1353 }
1354}
1355
1356fn alias_request_options_to_host(
1357 opts: http_client_client_alias::RequestOptions,
1358) -> host_http_client::RequestOptionsV1_1 {
1359 host_http_client::RequestOptionsV1_1 {
1360 timeout_ms: opts.timeout_ms,
1361 allow_insecure: opts.allow_insecure,
1362 follow_redirects: opts.follow_redirects,
1363 }
1364}
1365
1366fn alias_tenant_ctx_to_host(
1367 ctx: http_client_client_alias::TenantCtx,
1368) -> host_http_client::TenantCtxV1_1 {
1369 host_http_client::TenantCtxV1_1 {
1370 env: ctx.env,
1371 tenant: ctx.tenant,
1372 tenant_id: ctx.tenant_id,
1373 team: ctx.team,
1374 team_id: ctx.team_id,
1375 user: ctx.user,
1376 user_id: ctx.user_id,
1377 trace_id: ctx.trace_id,
1378 correlation_id: ctx.correlation_id,
1379 i18n_id: ctx.i18n_id,
1380 attributes: ctx.attributes,
1381 session_id: ctx.session_id,
1382 flow_id: ctx.flow_id,
1383 node_id: ctx.node_id,
1384 provider_id: ctx.provider_id,
1385 deadline_ms: ctx.deadline_ms,
1386 attempt: ctx.attempt,
1387 idempotency_key: ctx.idempotency_key,
1388 impersonation: ctx.impersonation.map(|imp| http_types_v1_1::Impersonation {
1389 actor_id: imp.actor_id,
1390 reason: imp.reason,
1391 }),
1392 }
1393}
1394
1395fn alias_response_from_host(
1396 resp: host_http_client::ResponseV1_1,
1397) -> http_client_client_alias::Response {
1398 http_client_client_alias::Response {
1399 status: resp.status,
1400 headers: resp.headers,
1401 body: resp.body,
1402 }
1403}
1404
1405fn alias_error_from_host(
1406 err: host_http_client::HttpClientErrorV1_1,
1407) -> http_client_client_alias::HostError {
1408 http_client_client_alias::HostError {
1409 code: err.code,
1410 message: err.message,
1411 }
1412}
1413
1414impl OAuthHostContext for ComponentState {
1415 fn tenant_id(&self) -> &str {
1416 &self.host.config.tenant
1417 }
1418
1419 fn env(&self) -> &str {
1420 &self.host.default_env
1421 }
1422
1423 fn oauth_broker_host(&mut self) -> &mut OAuthBrokerHost {
1424 &mut self.host.oauth_host
1425 }
1426
1427 fn oauth_config(&self) -> Option<&OAuthBrokerConfig> {
1428 self.host.oauth_config.as_ref()
1429 }
1430}
1431
1432impl WasiView for ComponentState {
1433 fn ctx(&mut self) -> WasiCtxView<'_> {
1434 WasiCtxView {
1435 ctx: &mut self.wasi_ctx,
1436 table: &mut self.resource_table,
1437 }
1438 }
1439}
1440
1441impl WasiHttpView for ComponentState {
1442 fn http(&mut self) -> WasiHttpCtxView<'_> {
1443 WasiHttpCtxView {
1444 ctx: &mut self.wasi_http_ctx,
1445 table: &mut self.resource_table,
1446 hooks: Default::default(),
1447 }
1448 }
1449}
1450
1451#[allow(unsafe_code)]
1452unsafe impl Send for ComponentState {}
1453#[allow(unsafe_code)]
1454unsafe impl Sync for ComponentState {}
1455
1456impl PackRuntime {
1457 fn allows_state_store(&self, component_ref: &str) -> bool {
1458 if self.state_store.is_none() {
1459 return false;
1460 }
1461 if !self.config.state_store_policy.allow {
1462 return false;
1463 }
1464 let Some(manifest) = self.component_manifests.get(component_ref) else {
1465 return true;
1467 };
1468 manifest
1473 .capabilities
1474 .host
1475 .state
1476 .as_ref()
1477 .map(|caps| caps.read || caps.write)
1478 .unwrap_or(true)
1479 }
1480
1481 pub fn contains_component(&self, component_ref: &str) -> bool {
1482 self.components.contains_key(component_ref)
1483 }
1484
1485 pub fn state_store_handle(&self) -> Option<crate::storage::DynStateStore> {
1490 self.state_store.clone()
1491 }
1492
1493 #[allow(clippy::too_many_arguments)]
1494 pub async fn load(
1495 path: impl AsRef<Path>,
1496 config: Arc<HostConfig>,
1497 mocks: Option<Arc<MockLayer>>,
1498 archive_source: Option<&Path>,
1499 session_store: Option<DynSessionStore>,
1500 state_store: Option<DynStateStore>,
1501 wasi_policy: Arc<RunnerWasiPolicy>,
1502 secrets: DynSecretsManager,
1503 oauth_config: Option<OAuthBrokerConfig>,
1504 verify_archive: bool,
1505 component_resolution: ComponentResolution,
1506 ) -> Result<Self> {
1507 let path = path.as_ref();
1508 let (_pack_root, safe_path) = normalize_pack_path(path)?;
1509 let path_meta = std::fs::metadata(&safe_path).ok();
1510 let is_dir = path_meta
1511 .as_ref()
1512 .map(|meta| meta.is_dir())
1513 .unwrap_or(false);
1514 let is_component = !is_dir
1515 && safe_path
1516 .extension()
1517 .and_then(|ext| ext.to_str())
1518 .map(|ext| ext.eq_ignore_ascii_case("wasm"))
1519 .unwrap_or(false);
1520 let archive_hint_path = if let Some(source) = archive_source {
1521 let (_, normalized) = normalize_pack_path(source)?;
1522 Some(normalized)
1523 } else if is_component || is_dir {
1524 None
1525 } else {
1526 Some(safe_path.clone())
1527 };
1528 let archive_hint = archive_hint_path.as_deref();
1529 if verify_archive {
1530 if let Some(verify_target) = archive_hint.and_then(|p| {
1531 std::fs::metadata(p)
1532 .ok()
1533 .filter(|meta| meta.is_file())
1534 .map(|_| p)
1535 }) {
1536 verify::verify_pack(verify_target).await?;
1537 tracing::info!(pack_path = %verify_target.display(), "pack verification complete");
1538 } else {
1539 tracing::debug!("skipping archive verification (no archive source)");
1540 }
1541 }
1542 let engine = Engine::default();
1543 let engine_profile =
1544 EngineProfile::from_engine(&engine, CpuPolicy::Native, "default".to_string());
1545 let cache = CacheManager::new(CacheConfig::default(), engine_profile);
1546 let mut metadata = PackMetadata::fallback(&safe_path);
1547 let mut manifest = None;
1548 let mut legacy_manifest: Option<Box<legacy_pack::PackManifest>> = None;
1549 let mut flows = None;
1550 let materialized_root = component_resolution.materialized_root.clone().or_else(|| {
1551 if is_dir {
1552 Some(safe_path.clone())
1553 } else {
1554 None
1555 }
1556 });
1557 let (pack_assets_dir, assets_tempdir) =
1558 locate_pack_assets(materialized_root.as_deref(), archive_hint)?;
1559 let setup_yaml_exists = pack_assets_dir
1560 .as_ref()
1561 .map(|dir| dir.join("setup.yaml").is_file())
1562 .unwrap_or(false);
1563 tracing::info!(
1564 pack_root = %safe_path.display(),
1565 assets_setup_yaml_exists = setup_yaml_exists,
1566 "pack unpack metadata"
1567 );
1568
1569 if let Some(root) = materialized_root.as_ref() {
1570 match load_manifest_and_flows_from_dir(root) {
1571 Ok(ManifestLoad::New {
1572 manifest: m,
1573 flows: cache,
1574 }) => {
1575 metadata = cache.metadata.clone();
1576 manifest = Some(*m);
1577 flows = Some(cache);
1578 }
1579 Ok(ManifestLoad::Legacy {
1580 manifest: m,
1581 flows: cache,
1582 }) => {
1583 metadata = cache.metadata.clone();
1584 legacy_manifest = Some(m);
1585 flows = Some(cache);
1586 }
1587 Err(err) => {
1588 warn!(error = %err, pack = %root.display(), "failed to parse materialized pack manifest");
1589 }
1590 }
1591 }
1592
1593 if manifest.is_none()
1594 && legacy_manifest.is_none()
1595 && let Some(archive_path) = archive_hint
1596 {
1597 let manifest_load = load_manifest_and_flows(archive_path).with_context(|| {
1598 format!(
1599 "failed to load manifest.cbor from {}",
1600 archive_path.display()
1601 )
1602 })?;
1603 match manifest_load {
1604 ManifestLoad::New {
1605 manifest: m,
1606 flows: cache,
1607 } => {
1608 metadata = cache.metadata.clone();
1609 manifest = Some(*m);
1610 flows = Some(cache);
1611 }
1612 ManifestLoad::Legacy {
1613 manifest: m,
1614 flows: cache,
1615 } => {
1616 metadata = cache.metadata.clone();
1617 legacy_manifest = Some(m);
1618 flows = Some(cache);
1619 }
1620 }
1621 }
1622 #[cfg(feature = "fault-injection")]
1623 {
1624 let fault_ctx = FaultContext {
1625 pack_id: metadata.pack_id.as_str(),
1626 flow_id: "unknown",
1627 node_id: None,
1628 attempt: 1,
1629 };
1630 maybe_fail(FaultPoint::PackResolve, fault_ctx)
1631 .map_err(|err| anyhow!(err.to_string()))?;
1632 }
1633 let mut pack_lock = None;
1634 for root in find_pack_lock_roots(&safe_path, is_dir, archive_hint) {
1635 pack_lock = load_pack_lock(&root)?;
1636 if pack_lock.is_some() {
1637 break;
1638 }
1639 }
1640 let component_sources_payload = if pack_lock.is_none() {
1641 if let Some(manifest) = manifest.as_ref() {
1642 manifest
1643 .get_component_sources_v1()
1644 .context("invalid component sources extension")?
1645 } else {
1646 None
1647 }
1648 } else {
1649 None
1650 };
1651 let component_sources = if let Some(lock) = pack_lock.as_ref() {
1652 Some(component_sources_table_from_pack_lock(
1653 lock,
1654 component_resolution.allow_missing_hash,
1655 )?)
1656 } else {
1657 component_sources_table(component_sources_payload.as_ref())?
1658 };
1659 let components = if is_component {
1660 let wasm_bytes = fs::read(&safe_path).await?;
1661 metadata = PackMetadata::from_wasm(&wasm_bytes)
1662 .unwrap_or_else(|| PackMetadata::fallback(&safe_path));
1663 let name = safe_path
1664 .file_stem()
1665 .map(|s| s.to_string_lossy().to_string())
1666 .unwrap_or_else(|| "component".to_string());
1667 let component = compile_component_with_cache(&cache, &engine, None, wasm_bytes).await?;
1668 let mut map = HashMap::new();
1669 map.insert(
1670 name.clone(),
1671 PackComponent {
1672 name,
1673 version: metadata.version.clone(),
1674 component,
1675 },
1676 );
1677 map
1678 } else {
1679 let specs = component_specs(
1680 manifest.as_ref(),
1681 legacy_manifest.as_deref(),
1682 component_sources_payload.as_ref(),
1683 pack_lock.as_ref(),
1684 );
1685 if specs.is_empty() {
1686 HashMap::new()
1687 } else {
1688 let mut loaded = HashMap::new();
1689 let mut missing: HashSet<String> =
1690 specs.iter().map(|spec| spec.id.clone()).collect();
1691 let mut searched = Vec::new();
1692
1693 if !component_resolution.overrides.is_empty() {
1694 load_components_from_overrides(
1695 &cache,
1696 &engine,
1697 &component_resolution.overrides,
1698 &specs,
1699 &mut missing,
1700 &mut loaded,
1701 )
1702 .await?;
1703 searched.push("override map".to_string());
1704 }
1705
1706 if let Some(component_sources) = component_sources.as_ref() {
1707 load_components_from_sources(
1708 &cache,
1709 &engine,
1710 component_sources,
1711 &component_resolution,
1712 &specs,
1713 &mut missing,
1714 &mut loaded,
1715 materialized_root.as_deref(),
1716 archive_hint,
1717 )
1718 .await?;
1719 searched.push(format!("extension {}", EXT_COMPONENT_SOURCES_V1));
1720 }
1721
1722 if let Some(root) = materialized_root.as_ref() {
1723 load_components_from_dir(
1724 &cache,
1725 &engine,
1726 root,
1727 &specs,
1728 &mut missing,
1729 &mut loaded,
1730 )
1731 .await?;
1732 searched.push(format!("components dir {}", root.display()));
1733 }
1734
1735 if let Some(archive_path) = archive_hint {
1736 load_components_from_archive(
1737 &cache,
1738 &engine,
1739 archive_path,
1740 &specs,
1741 &mut missing,
1742 &mut loaded,
1743 )
1744 .await?;
1745 searched.push(format!("archive {}", archive_path.display()));
1746 }
1747
1748 if !missing.is_empty() {
1749 let missing_list = missing.into_iter().collect::<Vec<_>>().join(", ");
1750 let sources = if searched.is_empty() {
1751 "no component sources".to_string()
1752 } else {
1753 searched.join(", ")
1754 };
1755 bail!(
1756 "components missing: {}; looked in {}",
1757 missing_list,
1758 sources
1759 );
1760 }
1761
1762 loaded
1763 }
1764 };
1765 let http_client = Arc::clone(&HTTP_CLIENT);
1766 let mut component_manifests = HashMap::new();
1767 if let Some(manifest) = manifest.as_ref() {
1768 for component in &manifest.components {
1769 component_manifests.insert(component.id.as_str().to_string(), component.clone());
1770 }
1771 }
1772 let mut pack_policy = (*wasi_policy).clone();
1773 if let Some(dir) = pack_assets_dir {
1774 tracing::debug!(path = %dir.display(), "preopening pack assets directory for WASI /assets");
1775 pack_policy =
1776 pack_policy.with_preopen(PreopenSpec::new(dir, "/assets").read_only(true));
1777 }
1778 let wasi_policy = Arc::new(pack_policy);
1779 Ok(Self {
1780 path: safe_path,
1781 archive_path: archive_hint.map(Path::to_path_buf),
1782 config,
1783 engine,
1784 metadata,
1785 manifest,
1786 legacy_manifest,
1787 component_manifests,
1788 mocks,
1789 flows,
1790 components,
1791 http_client,
1792 pre_cache: Mutex::new(HashMap::new()),
1793 session_store,
1794 state_store,
1795 wasi_policy,
1796 assets_tempdir,
1797 provider_registry: RwLock::new(None),
1798 secrets,
1799 oauth_config,
1800 cache,
1801 })
1802 }
1803
1804 pub async fn list_flows(&self) -> Result<Vec<FlowDescriptor>> {
1805 if let Some(cache) = &self.flows {
1806 return Ok(cache.descriptors.clone());
1807 }
1808 if let Some(manifest) = &self.manifest {
1809 let descriptors = manifest
1810 .flows
1811 .iter()
1812 .map(|flow| FlowDescriptor {
1813 id: flow.id.as_str().to_string(),
1814 flow_type: flow_kind_to_str(flow.kind).to_string(),
1815 pack_id: manifest.pack_id.as_str().to_string(),
1816 profile: manifest.pack_id.as_str().to_string(),
1817 version: manifest.version.to_string(),
1818 description: None,
1819 })
1820 .collect();
1821 return Ok(descriptors);
1822 }
1823 Ok(Vec::new())
1824 }
1825
1826 #[allow(dead_code)]
1827 pub async fn run_flow(
1828 &self,
1829 flow_id: &str,
1830 input: serde_json::Value,
1831 ) -> Result<serde_json::Value> {
1832 let pack = Arc::new(
1833 PackRuntime::load(
1834 &self.path,
1835 Arc::clone(&self.config),
1836 self.mocks.clone(),
1837 self.archive_path.as_deref(),
1838 self.session_store.clone(),
1839 self.state_store.clone(),
1840 Arc::clone(&self.wasi_policy),
1841 self.secrets.clone(),
1842 self.oauth_config.clone(),
1843 false,
1844 ComponentResolution::default(),
1845 )
1846 .await?,
1847 );
1848
1849 let engine = FlowEngine::new(vec![Arc::clone(&pack)], Arc::clone(&self.config)).await?;
1850 let retry_config = self.config.retry_config().into();
1851 let mocks = pack.mocks.as_deref();
1852 let tenant = self.config.tenant.as_str();
1853
1854 let ctx = FlowContext {
1855 tenant,
1856 pack_id: pack.metadata().pack_id.as_str(),
1857 flow_id,
1858 node_id: None,
1859 tool: None,
1860 action: None,
1861 session_id: None,
1862 provider_id: None,
1863 retry_config,
1864 attempt: 1,
1865 observer: None,
1866 mocks,
1867 };
1868
1869 let execution = engine.execute(ctx, input).await?;
1870 match execution.status {
1871 FlowStatus::Completed => Ok(execution.output),
1872 FlowStatus::Waiting(wait) => Ok(serde_json::json!({
1873 "status": "pending",
1874 "reason": wait.reason,
1875 "resume": wait.snapshot,
1876 "response": execution.output,
1877 })),
1878 }
1879 }
1880
1881 pub async fn invoke_component(
1882 &self,
1883 component_ref: &str,
1884 ctx: ComponentExecCtx,
1885 operation: &str,
1886 config_json: Option<String>,
1887 input_json: String,
1888 ) -> Result<Value> {
1889 let pack_component = self
1890 .components
1891 .get(component_ref)
1892 .with_context(|| format!("component '{component_ref}' not found in pack"))?;
1893 let engine = self.engine.clone();
1894 let config = Arc::clone(&self.config);
1895 let http_client = Arc::clone(&self.http_client);
1896 let mocks = self.mocks.clone();
1897 let session_store = self.session_store.clone();
1898 let state_store = self.state_store.clone();
1899 let secrets = Arc::clone(&self.secrets);
1900 let oauth_config = self.oauth_config.clone();
1901 let wasi_policy = Arc::clone(&self.wasi_policy);
1902 let pack_id = self.metadata().pack_id.clone();
1903 let allow_state_store = self.allows_state_store(component_ref);
1904 let component = pack_component.component.clone();
1905 let component_ref_owned = component_ref.to_string();
1906 let operation_owned = operation.to_string();
1907 let input_owned =
1908 Self::merge_component_config_into_input_json(config_json.as_deref(), &input_json)
1909 .context("merge component config into invocation payload")?;
1910 let ctx_owned = ctx;
1911
1912 run_on_wasi_thread("component.invoke", move || {
1913 let mut linker = Linker::new(&engine);
1914 register_all(&mut linker, allow_state_store)?;
1915 add_component_control_to_linker(&mut linker)?;
1916
1917 let host_state = HostState::new(
1918 pack_id.clone(),
1919 config,
1920 http_client,
1921 mocks,
1922 session_store,
1923 state_store,
1924 secrets,
1925 oauth_config,
1926 Some(ctx_owned.clone()),
1927 Some(component_ref_owned.clone()),
1928 false,
1929 )?;
1930 let store_state = ComponentState::new(host_state, wasi_policy)?;
1931 let mut store = wasmtime::Store::new(&engine, store_state);
1932
1933 let invoke_result = HostState::instantiate_component_result(
1934 &mut linker,
1935 &mut store,
1936 &component,
1937 &ctx_owned,
1938 &component_ref_owned,
1939 &operation_owned,
1940 &input_owned,
1941 )?;
1942 HostState::convert_invoke_result(invoke_result)
1943 })
1944 }
1945
1946 fn merge_component_config_into_input_json(
1947 config_json: Option<&str>,
1948 input_json: &str,
1949 ) -> Result<String> {
1950 let Some(config_json) = config_json else {
1951 return Ok(input_json.to_string());
1952 };
1953
1954 let config_value: Value =
1955 serde_json::from_str(config_json).context("parse component config JSON")?;
1956
1957 if let Ok(mut invocation) =
1958 serde_json::from_str::<greentic_types::InvocationEnvelope>(input_json)
1959 {
1960 let payload_value = serde_json::from_slice(&invocation.payload).unwrap_or_else(|_| {
1961 Value::String(String::from_utf8_lossy(&invocation.payload).into_owned())
1962 });
1963 invocation.payload = serde_json::to_vec(&serde_json::json!({
1964 "config": config_value,
1965 "input": payload_value,
1966 }))
1967 .context("serialize merged invocation payload")?;
1968 return serde_json::to_string(&invocation)
1969 .context("serialize merged invocation envelope");
1970 }
1971
1972 let input_value = serde_json::from_str(input_json)
1973 .unwrap_or_else(|_| Value::String(input_json.to_string()));
1974 serde_json::to_string(&serde_json::json!({
1975 "config": config_value,
1976 "input": input_value,
1977 }))
1978 .context("serialize merged component input")
1979 }
1980
1981 pub fn resolve_provider(
1982 &self,
1983 provider_id: Option<&str>,
1984 provider_type: Option<&str>,
1985 ) -> Result<ProviderBinding> {
1986 let registry = self.provider_registry()?;
1987 registry.resolve(provider_id, provider_type)
1988 }
1989
1990 pub async fn invoke_provider(
1991 &self,
1992 binding: &ProviderBinding,
1993 ctx: ComponentExecCtx,
1994 op: &str,
1995 input_json: Vec<u8>,
1996 ) -> Result<Value> {
1997 let component_ref_owned = binding.component_ref.clone();
1998 let pack_component = self.components.get(&component_ref_owned).with_context(|| {
1999 format!("provider component '{component_ref_owned}' not found in pack")
2000 })?;
2001 let component = pack_component.component.clone();
2002
2003 let engine = self.engine.clone();
2004 let config = Arc::clone(&self.config);
2005 let http_client = Arc::clone(&self.http_client);
2006 let mocks = self.mocks.clone();
2007 let session_store = self.session_store.clone();
2008 let state_store = self.state_store.clone();
2009 let secrets = Arc::clone(&self.secrets);
2010 let oauth_config = self.oauth_config.clone();
2011 let wasi_policy = Arc::clone(&self.wasi_policy);
2012 let pack_id = self.metadata().pack_id.clone();
2013 let allow_state_store = self.allows_state_store(&component_ref_owned);
2014 let input_owned = input_json;
2015 let op_owned = op.to_string();
2016 let ctx_owned = ctx;
2017 let world = binding.world.clone();
2018
2019 run_on_wasi_thread("provider.invoke", move || {
2020 let mut linker = Linker::new(&engine);
2021 register_all(&mut linker, allow_state_store)?;
2022 add_component_control_to_linker(&mut linker)?;
2023 let host_state = HostState::new(
2024 pack_id.clone(),
2025 config,
2026 http_client,
2027 mocks,
2028 session_store,
2029 state_store,
2030 secrets,
2031 oauth_config,
2032 Some(ctx_owned.clone()),
2033 Some(component_ref_owned.clone()),
2034 true,
2035 )?;
2036 let store_state = ComponentState::new(host_state, wasi_policy)?;
2037 let mut store = wasmtime::Store::new(&engine, store_state);
2038 let use_schema_core_schema = world.contains("provider-schema-core");
2039 let use_schema_core_path = world.contains("provider/schema-core");
2040 let result = if use_schema_core_schema {
2041 let pre_instance = linker.instantiate_pre(component.as_ref())?;
2042 let pre: SchemaSchemaCorePre<ComponentState> =
2043 SchemaSchemaCorePre::new(pre_instance)?;
2044 let bindings = block_on(async { pre.instantiate_async(&mut store).await })?;
2045 let provider = bindings.greentic_provider_schema_core_schema_core_api();
2046 provider.call_invoke(&mut store, &op_owned, &input_owned)?
2047 } else if use_schema_core_path {
2048 let pre_instance = linker.instantiate_pre(component.as_ref())?;
2049 let path_attempt = (|| -> Result<Vec<u8>> {
2050 let pre: PathSchemaCorePre<ComponentState> =
2051 PathSchemaCorePre::new(pre_instance)?;
2052 let bindings = block_on(async { pre.instantiate_async(&mut store).await })?;
2053 let provider = bindings.greentic_provider_schema_core_api();
2054 Ok(provider.call_invoke(&mut store, &op_owned, &input_owned)?)
2055 })();
2056 match path_attempt {
2057 Ok(value) => value,
2058 Err(path_err)
2059 if path_err.to_string().contains("no exported instance named") =>
2060 {
2061 let pre_instance = linker.instantiate_pre(component.as_ref())?;
2062 let pre: SchemaSchemaCorePre<ComponentState> =
2063 SchemaSchemaCorePre::new(pre_instance)?;
2064 let bindings = block_on(async { pre.instantiate_async(&mut store).await })?;
2065 let provider = bindings.greentic_provider_schema_core_schema_core_api();
2066 provider.call_invoke(&mut store, &op_owned, &input_owned)?
2067 }
2068 Err(path_err) => return Err(path_err),
2069 }
2070 } else {
2071 let pre_instance = linker.instantiate_pre(component.as_ref())?;
2072 let pre: LegacySchemaCorePre<ComponentState> =
2073 LegacySchemaCorePre::new(pre_instance)?;
2074 let bindings = block_on(async { pre.instantiate_async(&mut store).await })?;
2075 let provider = bindings.greentic_provider_core_schema_core_api();
2076 provider.call_invoke(&mut store, &op_owned, &input_owned)?
2077 };
2078 deserialize_json_bytes(result)
2079 })
2080 }
2081
2082 pub(crate) fn provider_registry(&self) -> Result<ProviderRegistry> {
2083 if let Some(registry) = self.provider_registry.read().clone() {
2084 return Ok(registry);
2085 }
2086 let manifest = self
2087 .manifest
2088 .as_ref()
2089 .context("pack manifest required for provider resolution")?;
2090 let env = std::env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string());
2091 let registry = ProviderRegistry::new(
2092 manifest,
2093 self.state_store.clone(),
2094 &self.config.tenant,
2095 &env,
2096 )?;
2097 *self.provider_registry.write() = Some(registry.clone());
2098 Ok(registry)
2099 }
2100
2101 pub(crate) fn provider_registry_optional(&self) -> Result<Option<ProviderRegistry>> {
2102 if self.manifest.is_none() {
2103 return Ok(None);
2104 }
2105 Ok(Some(self.provider_registry()?))
2106 }
2107
2108 pub fn load_flow(&self, flow_id: &str) -> Result<Flow> {
2109 if let Some(cache) = &self.flows {
2110 return cache
2111 .flows
2112 .get(flow_id)
2113 .cloned()
2114 .ok_or_else(|| anyhow!("flow '{flow_id}' not found in pack"));
2115 }
2116 if let Some(manifest) = &self.manifest {
2117 let entry = manifest
2118 .flows
2119 .iter()
2120 .find(|f| f.id.as_str() == flow_id)
2121 .ok_or_else(|| anyhow!("flow '{flow_id}' not found in manifest"))?;
2122 return Ok(entry.flow.clone());
2123 }
2124 bail!("flow '{flow_id}' not available (pack exports disabled)")
2125 }
2126
2127 pub fn metadata(&self) -> &PackMetadata {
2128 &self.metadata
2129 }
2130
2131 pub fn read_asset(&self, asset_path: &str) -> Result<Vec<u8>> {
2136 let normalized = asset_path
2137 .trim_start_matches("assets/")
2138 .trim_start_matches("/assets/");
2139 if let Some(tempdir) = &self.assets_tempdir {
2141 let full = tempdir.path().join("assets").join(normalized);
2142 if full.exists() {
2143 return std::fs::read(&full)
2144 .with_context(|| format!("read asset {}", full.display()));
2145 }
2146 }
2147 let full = self.path.join("assets").join(normalized);
2149 if full.exists() {
2150 return std::fs::read(&full).with_context(|| format!("read asset {}", full.display()));
2151 }
2152 bail!("asset not found: {}", asset_path)
2153 }
2154
2155 pub fn component_manifest(&self, component_ref: &str) -> Option<&ComponentManifest> {
2156 self.component_manifests.get(component_ref)
2157 }
2158
2159 pub fn describe_component_contract_v0_6(&self, component_ref: &str) -> Result<Option<Value>> {
2160 let pack_component = self
2161 .components
2162 .get(component_ref)
2163 .with_context(|| format!("component '{component_ref}' not found in pack"))?;
2164 let engine = self.engine.clone();
2165 let config = Arc::clone(&self.config);
2166 let http_client = Arc::clone(&self.http_client);
2167 let mocks = self.mocks.clone();
2168 let session_store = self.session_store.clone();
2169 let state_store = self.state_store.clone();
2170 let secrets = Arc::clone(&self.secrets);
2171 let oauth_config = self.oauth_config.clone();
2172 let wasi_policy = Arc::clone(&self.wasi_policy);
2173 let pack_id = self.metadata().pack_id.clone();
2174 let allow_state_store = self.allows_state_store(component_ref);
2175 let component = pack_component.component.clone();
2176 let component_ref_owned = component_ref.to_string();
2177
2178 run_on_wasi_thread("component.describe", move || {
2179 let mut linker = Linker::new(&engine);
2180 register_all(&mut linker, allow_state_store)?;
2181 add_component_control_to_linker(&mut linker)?;
2182
2183 let host_state = HostState::new(
2184 pack_id.clone(),
2185 config,
2186 http_client,
2187 mocks,
2188 session_store,
2189 state_store,
2190 secrets,
2191 oauth_config,
2192 None,
2193 Some(component_ref_owned),
2194 false,
2195 )?;
2196 let store_state = ComponentState::new(host_state, wasi_policy)?;
2197 let mut store = wasmtime::Store::new(&engine, store_state);
2198 let pre_instance = linker.instantiate_pre(&component)?;
2199 let pre = match component_api::v0_6_descriptor::ComponentV0V6V0Pre::new(pre_instance) {
2200 Ok(pre) => pre,
2201 Err(_) => return Ok(None),
2202 };
2203 let bytes = block_on(async {
2204 let bindings = pre.instantiate_async(&mut store).await?;
2205 let descriptor = bindings.greentic_component_component_descriptor();
2206 descriptor.call_describe(&mut store)
2207 })?;
2208
2209 if bytes.is_empty() {
2210 return Ok(Some(Value::Null));
2211 }
2212 if let Ok(value) = serde_cbor::from_slice::<Value>(&bytes) {
2213 return Ok(Some(value));
2214 }
2215 if let Ok(value) = serde_json::from_slice::<Value>(&bytes) {
2216 return Ok(Some(value));
2217 }
2218 if let Ok(text) = String::from_utf8(bytes) {
2219 if let Ok(value) = serde_json::from_str::<Value>(&text) {
2220 return Ok(Some(value));
2221 }
2222 return Ok(Some(Value::String(text)));
2223 }
2224 Ok(Some(Value::Null))
2225 })
2226 }
2227
2228 pub fn load_schema_json(&self, schema_ref: &str) -> Result<Option<Value>> {
2229 let rel = normalize_schema_ref(schema_ref)?;
2230 if self.path.is_dir() {
2231 let candidate = self.path.join(&rel);
2232 if candidate.exists() {
2233 let bytes = std::fs::read(&candidate).with_context(|| {
2234 format!("failed to read schema file {}", candidate.display())
2235 })?;
2236 let value = serde_json::from_slice::<Value>(&bytes)
2237 .with_context(|| format!("invalid schema JSON in {}", candidate.display()))?;
2238 return Ok(Some(value));
2239 }
2240 }
2241
2242 if let Some(archive_path) = self
2243 .archive_path
2244 .as_ref()
2245 .or_else(|| path_is_gtpack(&self.path).then_some(&self.path))
2246 {
2247 let file = File::open(archive_path)
2248 .with_context(|| format!("failed to open {}", archive_path.display()))?;
2249 let mut archive = ZipArchive::new(file)
2250 .with_context(|| format!("failed to read pack {}", archive_path.display()))?;
2251 match archive.by_name(&rel) {
2252 Ok(mut entry) => {
2253 let mut bytes = Vec::new();
2254 entry.read_to_end(&mut bytes)?;
2255 let value = serde_json::from_slice::<Value>(&bytes).with_context(|| {
2256 format!("invalid schema JSON in {}:{}", archive_path.display(), rel)
2257 })?;
2258 Ok(Some(value))
2259 }
2260 Err(zip::result::ZipError::FileNotFound) => Ok(None),
2261 Err(err) => Err(anyhow!(err)).with_context(|| {
2262 format!(
2263 "failed to read schema `{}` from {}",
2264 rel,
2265 archive_path.display()
2266 )
2267 }),
2268 }
2269 } else {
2270 Ok(None)
2271 }
2272 }
2273
2274 pub fn required_secrets(&self) -> &[greentic_types::SecretRequirement] {
2275 &self.metadata.secret_requirements
2276 }
2277
2278 pub fn missing_secrets(
2279 &self,
2280 tenant_ctx: &TypesTenantCtx,
2281 ) -> Vec<greentic_types::SecretRequirement> {
2282 let env = tenant_ctx.env.as_str().to_string();
2283 let tenant = tenant_ctx.tenant.as_str().to_string();
2284 let team = tenant_ctx.team.as_ref().map(|t| t.as_str().to_string());
2285 self.required_secrets()
2286 .iter()
2287 .filter(|req| {
2288 if let Some(scope) = &req.scope {
2290 if scope.env != env {
2291 return false;
2292 }
2293 if scope.tenant != tenant {
2294 return false;
2295 }
2296 if let Some(ref team_req) = scope.team
2297 && team.as_ref() != Some(team_req)
2298 {
2299 return false;
2300 }
2301 }
2302 let ctx = self.config.tenant_ctx();
2303 read_secret_blocking(
2304 &self.secrets,
2305 &ctx,
2306 &self.metadata.pack_id,
2307 canonicalize_secret_key(req.key.as_str()).as_str(),
2308 )
2309 .is_err()
2310 })
2311 .cloned()
2312 .collect()
2313 }
2314
2315 pub fn for_component_test(
2316 components: Vec<(String, PathBuf)>,
2317 flows: HashMap<String, FlowIR>,
2318 pack_id: &str,
2319 config: Arc<HostConfig>,
2320 ) -> Result<Self> {
2321 let engine = Engine::default();
2322 let engine_profile =
2323 EngineProfile::from_engine(&engine, CpuPolicy::Native, "default".to_string());
2324 let cache = CacheManager::new(CacheConfig::default(), engine_profile);
2325 let mut component_map = HashMap::new();
2326 for (name, path) in components {
2327 if !path.exists() {
2328 bail!("component artifact missing: {}", path.display());
2329 }
2330 let wasm_bytes = std::fs::read(&path)?;
2331 let component =
2332 Arc::new(Component::from_binary(&engine, &wasm_bytes).map_err(|err| {
2333 anyhow!("failed to compile component {}: {err}", path.display())
2334 })?);
2335 component_map.insert(
2336 name.clone(),
2337 PackComponent {
2338 name,
2339 version: "0.0.0".into(),
2340 component,
2341 },
2342 );
2343 }
2344
2345 let mut flow_map = HashMap::new();
2346 let mut descriptors = Vec::new();
2347 for (id, ir) in flows {
2348 let flow_type = ir.flow_type.clone();
2349 let flow = flow_ir_to_flow(ir)?;
2350 flow_map.insert(id.clone(), flow);
2351 descriptors.push(FlowDescriptor {
2352 id: id.clone(),
2353 flow_type,
2354 pack_id: pack_id.to_string(),
2355 profile: "test".into(),
2356 version: "0.0.0".into(),
2357 description: None,
2358 });
2359 }
2360 let entry_flows = descriptors.iter().map(|flow| flow.id.clone()).collect();
2361 let metadata = PackMetadata {
2362 pack_id: pack_id.to_string(),
2363 version: "0.0.0".into(),
2364 entry_flows,
2365 secret_requirements: Vec::new(),
2366 };
2367 let flows_cache = PackFlows {
2368 descriptors: descriptors.clone(),
2369 flows: flow_map,
2370 metadata: metadata.clone(),
2371 };
2372
2373 Ok(Self {
2374 path: PathBuf::new(),
2375 archive_path: None,
2376 config,
2377 engine,
2378 metadata,
2379 manifest: None,
2380 legacy_manifest: None,
2381 component_manifests: HashMap::new(),
2382 mocks: None,
2383 flows: Some(flows_cache),
2384 components: component_map,
2385 http_client: Arc::clone(&HTTP_CLIENT),
2386 pre_cache: Mutex::new(HashMap::new()),
2387 session_store: None,
2388 state_store: None,
2389 wasi_policy: Arc::new(RunnerWasiPolicy::new()),
2390 assets_tempdir: None,
2391 provider_registry: RwLock::new(None),
2392 secrets: crate::secrets::default_manager()?,
2393 oauth_config: None,
2394 cache,
2395 })
2396 }
2397}
2398
2399fn normalize_schema_ref(schema_ref: &str) -> Result<String> {
2400 let candidate = schema_ref.trim();
2401 if candidate.is_empty() {
2402 bail!("schema ref cannot be empty");
2403 }
2404 let path = Path::new(candidate);
2405 if path.is_absolute() {
2406 bail!("schema ref must be relative: {}", schema_ref);
2407 }
2408 let mut normalized = PathBuf::new();
2409 for component in path.components() {
2410 match component {
2411 std::path::Component::Normal(part) => normalized.push(part),
2412 std::path::Component::CurDir => {}
2413 _ => bail!("schema ref must not contain traversal: {}", schema_ref),
2414 }
2415 }
2416 let normalized = normalized
2417 .to_str()
2418 .map(ToString::to_string)
2419 .ok_or_else(|| anyhow!("schema ref must be valid UTF-8"))?;
2420 if normalized.is_empty() {
2421 bail!("schema ref cannot normalize to empty path");
2422 }
2423 Ok(normalized)
2424}
2425
2426fn path_is_gtpack(path: &Path) -> bool {
2427 path.extension()
2428 .and_then(|ext| ext.to_str())
2429 .map(|ext| ext.eq_ignore_ascii_case("gtpack"))
2430 .unwrap_or(false)
2431}
2432
2433fn is_missing_node_export(err: &wasmtime::Error, version: &str) -> bool {
2434 let message = err.to_string();
2435 message.contains("no exported instance named")
2436 && message.contains(&format!("greentic:component/node@{version}"))
2437}
2438
2439struct PackFlows {
2440 descriptors: Vec<FlowDescriptor>,
2441 flows: HashMap<String, Flow>,
2442 metadata: PackMetadata,
2443}
2444
2445const RUNTIME_FLOW_EXTENSION_IDS: [&str; 3] = [
2446 "greentic.pack.runtime_flow",
2447 "greentic.pack.flow_runtime",
2448 "greentic.pack.runtime_flows",
2449];
2450
2451#[derive(Debug, Deserialize)]
2452struct RuntimeFlowBundle {
2453 flows: Vec<RuntimeFlow>,
2454}
2455
2456#[derive(Debug, Deserialize)]
2457struct RuntimeFlow {
2458 id: String,
2459 #[serde(alias = "flow_type")]
2460 kind: FlowKind,
2461 #[serde(default)]
2462 schema_version: Option<String>,
2463 #[serde(default)]
2464 start: Option<String>,
2465 #[serde(default)]
2466 entrypoints: BTreeMap<String, Value>,
2467 nodes: BTreeMap<String, RuntimeNode>,
2468 #[serde(default)]
2469 metadata: Option<FlowMetadata>,
2470}
2471
2472#[derive(Debug, Deserialize)]
2473struct RuntimeNode {
2474 #[serde(alias = "component")]
2475 component_id: String,
2476 #[serde(default, alias = "operation")]
2477 operation_name: Option<String>,
2478 #[serde(default, alias = "payload", alias = "input")]
2479 operation_payload: Value,
2480 #[serde(default)]
2481 config: Value,
2482 #[serde(default)]
2483 routing: Option<Routing>,
2484 #[serde(default)]
2485 telemetry: Option<TelemetryHints>,
2486}
2487
2488fn deserialize_json_bytes(bytes: Vec<u8>) -> Result<Value> {
2489 if bytes.is_empty() {
2490 return Ok(Value::Null);
2491 }
2492 serde_json::from_slice(&bytes).or_else(|_| {
2493 String::from_utf8(bytes)
2494 .map(Value::String)
2495 .map_err(|err| anyhow!(err))
2496 })
2497}
2498
2499impl PackFlows {
2500 fn from_manifest(manifest: greentic_types::PackManifest) -> Self {
2501 if let Some(flows) = flows_from_runtime_extension(&manifest) {
2502 return flows;
2503 }
2504 let descriptors = manifest
2505 .flows
2506 .iter()
2507 .map(|entry| FlowDescriptor {
2508 id: entry.id.as_str().to_string(),
2509 flow_type: flow_kind_to_str(entry.kind).to_string(),
2510 pack_id: manifest.pack_id.as_str().to_string(),
2511 profile: manifest.pack_id.as_str().to_string(),
2512 version: manifest.version.to_string(),
2513 description: None,
2514 })
2515 .collect();
2516 let mut flows = HashMap::new();
2517 for entry in &manifest.flows {
2518 flows.insert(entry.id.as_str().to_string(), entry.flow.clone());
2519 }
2520 Self {
2521 metadata: PackMetadata::from_manifest(&manifest),
2522 descriptors,
2523 flows,
2524 }
2525 }
2526}
2527
2528fn flows_from_runtime_extension(manifest: &greentic_types::PackManifest) -> Option<PackFlows> {
2529 let extensions = manifest.extensions.as_ref()?;
2530 let extension = extensions.iter().find_map(|(key, ext)| {
2531 if RUNTIME_FLOW_EXTENSION_IDS
2532 .iter()
2533 .any(|candidate| candidate == key)
2534 {
2535 Some(ext)
2536 } else {
2537 None
2538 }
2539 })?;
2540 let runtime_flows = match decode_runtime_flow_extension(extension) {
2541 Some(flows) if !flows.is_empty() => flows,
2542 _ => return None,
2543 };
2544
2545 let descriptors = runtime_flows
2546 .iter()
2547 .map(|flow| FlowDescriptor {
2548 id: flow.id.as_str().to_string(),
2549 flow_type: flow_kind_to_str(flow.kind).to_string(),
2550 pack_id: manifest.pack_id.as_str().to_string(),
2551 profile: manifest.pack_id.as_str().to_string(),
2552 version: manifest.version.to_string(),
2553 description: None,
2554 })
2555 .collect::<Vec<_>>();
2556 let flows = runtime_flows
2557 .into_iter()
2558 .map(|flow| (flow.id.as_str().to_string(), flow))
2559 .collect();
2560
2561 Some(PackFlows {
2562 metadata: PackMetadata::from_manifest(manifest),
2563 descriptors,
2564 flows,
2565 })
2566}
2567
2568fn decode_runtime_flow_extension(extension: &ExtensionRef) -> Option<Vec<Flow>> {
2569 let value = match extension.inline.as_ref()? {
2570 ExtensionInline::Other(value) => value.clone(),
2571 _ => return None,
2572 };
2573
2574 if let Ok(bundle) = serde_json::from_value::<RuntimeFlowBundle>(value.clone()) {
2575 return Some(collect_runtime_flows(bundle.flows));
2576 }
2577
2578 if let Ok(flows) = serde_json::from_value::<Vec<RuntimeFlow>>(value.clone()) {
2579 return Some(collect_runtime_flows(flows));
2580 }
2581
2582 if let Ok(flows) = serde_json::from_value::<Vec<Flow>>(value) {
2583 return Some(flows);
2584 }
2585
2586 warn!(
2587 extension = %extension.kind,
2588 version = %extension.version,
2589 "runtime flow extension present but could not be decoded"
2590 );
2591 None
2592}
2593
2594fn collect_runtime_flows(flows: Vec<RuntimeFlow>) -> Vec<Flow> {
2595 flows
2596 .into_iter()
2597 .filter_map(|flow| match runtime_flow_to_flow(flow) {
2598 Ok(flow) => Some(flow),
2599 Err(err) => {
2600 warn!(error = %err, "failed to decode runtime flow");
2601 None
2602 }
2603 })
2604 .collect()
2605}
2606
2607fn runtime_flow_to_flow(runtime: RuntimeFlow) -> Result<Flow> {
2608 let flow_id = FlowId::from_str(&runtime.id)
2609 .with_context(|| format!("invalid flow id `{}`", runtime.id))?;
2610 let mut entrypoints = runtime.entrypoints;
2611 if entrypoints.is_empty()
2612 && let Some(start) = &runtime.start
2613 {
2614 entrypoints.insert("default".into(), Value::String(start.clone()));
2615 }
2616
2617 let mut nodes: IndexMap<NodeId, Node, FlowHasher> = IndexMap::default();
2618 for (id, node) in runtime.nodes {
2619 let node_id = NodeId::from_str(&id).with_context(|| format!("invalid node id `{id}`"))?;
2620 let component_id = ComponentId::from_str(&node.component_id)
2621 .with_context(|| format!("invalid component id `{}`", node.component_id))?;
2622 let operation_payload = if node.config.is_null() {
2623 node.operation_payload
2624 } else {
2625 serde_json::json!({
2626 "input": node.operation_payload,
2627 "config": node.config,
2628 })
2629 };
2630 let component = FlowComponentRef {
2631 id: component_id,
2632 pack_alias: None,
2633 operation: node.operation_name,
2634 };
2635 let routing = node.routing.unwrap_or(Routing::End);
2636 let telemetry = node.telemetry.unwrap_or_default();
2637 nodes.insert(
2638 node_id.clone(),
2639 Node {
2640 id: node_id,
2641 component,
2642 input: InputMapping {
2643 mapping: operation_payload,
2644 },
2645 output: OutputMapping {
2646 mapping: Value::Null,
2647 },
2648 err_map: None,
2649 routing,
2650 telemetry,
2651 },
2652 );
2653 }
2654
2655 Ok(Flow {
2656 schema_version: runtime.schema_version.unwrap_or_else(|| "1.0".to_string()),
2657 id: flow_id,
2658 kind: runtime.kind,
2659 entrypoints,
2660 nodes,
2661 metadata: runtime.metadata.unwrap_or_default(),
2662 })
2663}
2664
2665fn flow_kind_to_str(kind: greentic_types::FlowKind) -> &'static str {
2666 match kind {
2667 greentic_types::FlowKind::Messaging => "messaging",
2668 greentic_types::FlowKind::Event => "event",
2669 greentic_types::FlowKind::ComponentConfig => "component-config",
2670 greentic_types::FlowKind::Job => "job",
2671 greentic_types::FlowKind::Http => "http",
2672 }
2673}
2674
2675fn read_entry(archive: &mut ZipArchive<File>, name: &str) -> Result<Vec<u8>> {
2676 let mut file = archive
2677 .by_name(name)
2678 .with_context(|| format!("entry {name} missing from archive"))?;
2679 let mut buf = Vec::new();
2680 file.read_to_end(&mut buf)?;
2681 Ok(buf)
2682}
2683
2684fn normalize_flow_doc(mut doc: FlowDoc) -> FlowDoc {
2685 for node in doc.nodes.values_mut() {
2686 let Some((component_ref, payload)) = node
2687 .raw
2688 .iter()
2689 .next()
2690 .map(|(key, value)| (key.clone(), value.clone()))
2691 else {
2692 continue;
2693 };
2694 if component_ref.starts_with("emit.") {
2695 node.operation = Some(component_ref);
2696 node.payload = payload;
2697 node.raw.clear();
2698 continue;
2699 }
2700 let (target_component, operation, input, config) =
2701 infer_component_exec(&payload, &component_ref);
2702 let mut payload_obj = serde_json::Map::new();
2703 payload_obj.insert("component".into(), Value::String(target_component));
2705 payload_obj.insert("operation".into(), Value::String(operation));
2706 payload_obj.insert("input".into(), input);
2707 if let Some(cfg) = config {
2708 payload_obj.insert("config".into(), cfg);
2709 }
2710 node.operation = Some("component.exec".to_string());
2711 node.payload = Value::Object(payload_obj);
2712 node.raw.clear();
2713 }
2714 doc
2715}
2716
2717fn infer_component_exec(
2718 payload: &Value,
2719 component_ref: &str,
2720) -> (String, String, Value, Option<Value>) {
2721 let default_op = if component_ref.starts_with("templating.") {
2722 "render"
2723 } else {
2724 "invoke"
2725 }
2726 .to_string();
2727
2728 if let Value::Object(map) = payload {
2729 let has_embedded_component =
2730 map.get("component").is_some() || map.get("component_ref").is_some();
2731 let op = map
2732 .get("op")
2733 .or_else(|| map.get("operation"))
2734 .and_then(Value::as_str)
2735 .map(|s| s.to_string())
2736 .unwrap_or_else(|| {
2737 if has_embedded_component {
2738 component_ref.to_string()
2739 } else {
2740 default_op.clone()
2741 }
2742 });
2743
2744 let mut input = map.clone();
2745 let config = input.remove("config");
2746 let canonical_input = if has_embedded_component {
2747 input.get("input").cloned()
2748 } else {
2749 None
2750 };
2751 let component = input
2752 .get("component")
2753 .or_else(|| input.get("component_ref"))
2754 .and_then(Value::as_str)
2755 .map(|s| s.to_string())
2756 .unwrap_or_else(|| component_ref.to_string());
2757 input.remove("component");
2758 input.remove("component_ref");
2759 input.remove("op");
2760 input.remove("operation");
2761 let input = canonical_input.unwrap_or(Value::Object(input));
2762 return (component, op, input, config);
2763 }
2764
2765 (component_ref.to_string(), default_op, payload.clone(), None)
2766}
2767
2768#[derive(Clone, Debug)]
2769struct ComponentSpec {
2770 id: String,
2771 version: String,
2772 legacy_path: Option<String>,
2773}
2774
2775#[derive(Clone, Debug)]
2776struct ComponentSourceInfo {
2777 digest: Option<String>,
2778 source: ComponentSourceRef,
2779 artifact: ComponentArtifactLocation,
2780 expected_wasm_sha256: Option<String>,
2781 skip_digest_verification: bool,
2782}
2783
2784#[derive(Clone, Debug)]
2785enum ComponentArtifactLocation {
2786 Inline { wasm_path: String },
2787 Remote,
2788}
2789
2790#[derive(Clone, Debug, Deserialize)]
2791struct PackLockV1 {
2792 schema_version: u32,
2793 components: Vec<PackLockComponent>,
2794}
2795
2796#[derive(Clone, Debug, Deserialize)]
2797struct PackLockComponent {
2798 name: String,
2799 #[serde(default, rename = "source_ref")]
2800 source_ref: Option<String>,
2801 #[serde(default, rename = "ref")]
2802 legacy_ref: Option<String>,
2803 #[serde(default)]
2804 component_id: Option<ComponentId>,
2805 #[serde(default)]
2806 bundled: Option<bool>,
2807 #[serde(default, rename = "bundled_path")]
2808 bundled_path: Option<String>,
2809 #[serde(default, rename = "path")]
2810 legacy_path: Option<String>,
2811 #[serde(default)]
2812 wasm_sha256: Option<String>,
2813 #[serde(default, rename = "sha256")]
2814 legacy_sha256: Option<String>,
2815 #[serde(default)]
2816 resolved_digest: Option<String>,
2817 #[serde(default)]
2818 digest: Option<String>,
2819}
2820
2821fn component_specs(
2822 manifest: Option<&greentic_types::PackManifest>,
2823 legacy_manifest: Option<&legacy_pack::PackManifest>,
2824 component_sources: Option<&ComponentSourcesV1>,
2825 pack_lock: Option<&PackLockV1>,
2826) -> Vec<ComponentSpec> {
2827 if let Some(manifest) = manifest {
2828 if !manifest.components.is_empty() {
2829 return manifest
2830 .components
2831 .iter()
2832 .map(|entry| ComponentSpec {
2833 id: entry.id.as_str().to_string(),
2834 version: entry.version.to_string(),
2835 legacy_path: None,
2836 })
2837 .collect();
2838 }
2839 if let Some(lock) = pack_lock {
2840 let mut seen = HashSet::new();
2841 let mut specs = Vec::new();
2842 for entry in &lock.components {
2843 let id = entry
2844 .component_id
2845 .as_ref()
2846 .map(|id| id.as_str())
2847 .unwrap_or(entry.name.as_str());
2848 if seen.insert(id.to_string()) {
2849 specs.push(ComponentSpec {
2850 id: id.to_string(),
2851 version: "0.0.0".to_string(),
2852 legacy_path: None,
2853 });
2854 }
2855 }
2856 return specs;
2857 }
2858 if let Some(sources) = component_sources {
2859 let mut seen = HashSet::new();
2860 let mut specs = Vec::new();
2861 for entry in &sources.components {
2862 let id = entry
2863 .component_id
2864 .as_ref()
2865 .map(|id| id.as_str())
2866 .unwrap_or(entry.name.as_str());
2867 if seen.insert(id.to_string()) {
2868 specs.push(ComponentSpec {
2869 id: id.to_string(),
2870 version: "0.0.0".to_string(),
2871 legacy_path: None,
2872 });
2873 }
2874 }
2875 return specs;
2876 }
2877 }
2878 if let Some(legacy_manifest) = legacy_manifest {
2879 return legacy_manifest
2880 .components
2881 .iter()
2882 .map(|entry| ComponentSpec {
2883 id: entry.name.clone(),
2884 version: entry.version.to_string(),
2885 legacy_path: Some(entry.file_wasm.clone()),
2886 })
2887 .collect();
2888 }
2889 Vec::new()
2890}
2891
2892fn component_sources_table(
2893 sources: Option<&ComponentSourcesV1>,
2894) -> Result<Option<HashMap<String, ComponentSourceInfo>>> {
2895 let Some(sources) = sources else {
2896 return Ok(None);
2897 };
2898 let mut table = HashMap::new();
2899 for entry in &sources.components {
2900 let artifact = match &entry.artifact {
2901 ArtifactLocationV1::Inline { wasm_path, .. } => ComponentArtifactLocation::Inline {
2902 wasm_path: wasm_path.clone(),
2903 },
2904 ArtifactLocationV1::Remote => ComponentArtifactLocation::Remote,
2905 };
2906 let info = ComponentSourceInfo {
2907 digest: Some(entry.resolved.digest.clone()),
2908 source: entry.source.clone(),
2909 artifact,
2910 expected_wasm_sha256: None,
2911 skip_digest_verification: false,
2912 };
2913 if let Some(component_id) = entry.component_id.as_ref() {
2914 table.insert(component_id.as_str().to_string(), info.clone());
2915 }
2916 table.insert(entry.name.clone(), info);
2917 }
2918 Ok(Some(table))
2919}
2920
2921fn load_pack_lock(path: &Path) -> Result<Option<PackLockV1>> {
2922 let lock_path = if path.is_dir() {
2923 let candidate = path.join("pack.lock");
2924 if candidate.exists() {
2925 Some(candidate)
2926 } else {
2927 let candidate = path.join("pack.lock.json");
2928 candidate.exists().then_some(candidate)
2929 }
2930 } else {
2931 None
2932 };
2933 let Some(lock_path) = lock_path else {
2934 return Ok(None);
2935 };
2936 let raw = std::fs::read_to_string(&lock_path)
2937 .with_context(|| format!("failed to read {}", lock_path.display()))?;
2938 let lock: PackLockV1 = serde_json::from_str(&raw).context("failed to parse pack.lock")?;
2939 if lock.schema_version != 1 {
2940 bail!("pack.lock schema_version must be 1");
2941 }
2942 Ok(Some(lock))
2943}
2944
2945fn find_pack_lock_roots(
2946 pack_path: &Path,
2947 is_dir: bool,
2948 archive_hint: Option<&Path>,
2949) -> Vec<PathBuf> {
2950 if is_dir {
2951 return vec![pack_path.to_path_buf()];
2952 }
2953 let mut roots = Vec::new();
2954 if let Some(archive_path) = archive_hint {
2955 if let Some(parent) = archive_path.parent() {
2956 roots.push(parent.to_path_buf());
2957 if let Some(grandparent) = parent.parent() {
2958 roots.push(grandparent.to_path_buf());
2959 }
2960 }
2961 } else if let Some(parent) = pack_path.parent() {
2962 roots.push(parent.to_path_buf());
2963 if let Some(grandparent) = parent.parent() {
2964 roots.push(grandparent.to_path_buf());
2965 }
2966 }
2967 roots
2968}
2969
2970fn normalize_sha256(digest: &str) -> Result<String> {
2971 let trimmed = digest.trim();
2972 if trimmed.is_empty() {
2973 bail!("sha256 digest cannot be empty");
2974 }
2975 if let Some(stripped) = trimmed.strip_prefix("sha256:") {
2976 if stripped.is_empty() {
2977 bail!("sha256 digest must include hex bytes after sha256:");
2978 }
2979 return Ok(trimmed.to_string());
2980 }
2981 if trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
2982 return Ok(format!("sha256:{trimmed}"));
2983 }
2984 bail!("sha256 digest must be hex or sha256:<hex>");
2985}
2986
2987fn component_sources_table_from_pack_lock(
2988 lock: &PackLockV1,
2989 allow_missing_hash: bool,
2990) -> Result<HashMap<String, ComponentSourceInfo>> {
2991 let mut table = HashMap::new();
2992 let mut names = HashSet::new();
2993 for entry in &lock.components {
2994 if !names.insert(entry.name.clone()) {
2995 bail!(
2996 "pack.lock contains duplicate component name `{}`",
2997 entry.name
2998 );
2999 }
3000 let source_ref = match (&entry.source_ref, &entry.legacy_ref) {
3001 (Some(primary), Some(legacy)) => {
3002 if primary != legacy {
3003 bail!(
3004 "pack.lock component {} has conflicting refs: {} vs {}",
3005 entry.name,
3006 primary,
3007 legacy
3008 );
3009 }
3010 primary.as_str()
3011 }
3012 (Some(primary), None) => primary.as_str(),
3013 (None, Some(legacy)) => legacy.as_str(),
3014 (None, None) => {
3015 bail!("pack.lock component {} missing source_ref", entry.name);
3016 }
3017 };
3018 let source: ComponentSourceRef = source_ref
3019 .parse()
3020 .with_context(|| format!("invalid component ref `{}`", source_ref))?;
3021 let bundled_path = match (&entry.bundled_path, &entry.legacy_path) {
3022 (Some(primary), Some(legacy)) => {
3023 if primary != legacy {
3024 bail!(
3025 "pack.lock component {} has conflicting bundled paths: {} vs {}",
3026 entry.name,
3027 primary,
3028 legacy
3029 );
3030 }
3031 Some(primary.clone())
3032 }
3033 (Some(primary), None) => Some(primary.clone()),
3034 (None, Some(legacy)) => Some(legacy.clone()),
3035 (None, None) => None,
3036 };
3037 let bundled = entry.bundled.unwrap_or(false) || bundled_path.is_some();
3038 let (artifact, digest, expected_wasm_sha256, skip_digest_verification) = if bundled {
3039 let wasm_path = bundled_path.ok_or_else(|| {
3040 anyhow!(
3041 "pack.lock component {} marked bundled but bundled_path is missing",
3042 entry.name
3043 )
3044 })?;
3045 let expected_raw = match (&entry.wasm_sha256, &entry.legacy_sha256) {
3046 (Some(primary), Some(legacy)) => {
3047 if primary != legacy {
3048 bail!(
3049 "pack.lock component {} has conflicting wasm_sha256 values: {} vs {}",
3050 entry.name,
3051 primary,
3052 legacy
3053 );
3054 }
3055 Some(primary.as_str())
3056 }
3057 (Some(primary), None) => Some(primary.as_str()),
3058 (None, Some(legacy)) => Some(legacy.as_str()),
3059 (None, None) => None,
3060 };
3061 let expected = match expected_raw {
3062 Some(value) => Some(normalize_sha256(value)?),
3063 None => None,
3064 };
3065 if expected.is_none() && !allow_missing_hash {
3066 bail!(
3067 "pack.lock component {} missing wasm_sha256 for bundled component",
3068 entry.name
3069 );
3070 }
3071 (
3072 ComponentArtifactLocation::Inline { wasm_path },
3073 expected.clone(),
3074 expected,
3075 allow_missing_hash && expected_raw.is_none(),
3076 )
3077 } else {
3078 if source.is_tag() {
3079 bail!(
3080 "component {} uses tag ref {} but is not bundled; rebuild the pack",
3081 entry.name,
3082 source
3083 );
3084 }
3085 let expected = entry
3086 .resolved_digest
3087 .as_deref()
3088 .or(entry.digest.as_deref())
3089 .ok_or_else(|| {
3090 anyhow!(
3091 "pack.lock component {} missing resolved_digest for remote component",
3092 entry.name
3093 )
3094 })?;
3095 (
3096 ComponentArtifactLocation::Remote,
3097 Some(normalize_digest(expected)),
3098 None,
3099 false,
3100 )
3101 };
3102 let info = ComponentSourceInfo {
3103 digest,
3104 source,
3105 artifact,
3106 expected_wasm_sha256,
3107 skip_digest_verification,
3108 };
3109 if let Some(component_id) = entry.component_id.as_ref() {
3110 let key = component_id.as_str().to_string();
3111 if table.contains_key(&key) {
3112 bail!(
3113 "pack.lock contains duplicate component id `{}`",
3114 component_id.as_str()
3115 );
3116 }
3117 table.insert(key, info.clone());
3118 }
3119 if entry.name
3120 != entry
3121 .component_id
3122 .as_ref()
3123 .map(|id| id.as_str())
3124 .unwrap_or("")
3125 {
3126 table.insert(entry.name.clone(), info);
3127 }
3128 }
3129 Ok(table)
3130}
3131
3132fn component_path_for_spec(root: &Path, spec: &ComponentSpec) -> PathBuf {
3133 if let Some(path) = &spec.legacy_path {
3134 return root.join(path);
3135 }
3136 root.join("components").join(format!("{}.wasm", spec.id))
3137}
3138
3139fn normalize_digest(digest: &str) -> String {
3140 if digest.starts_with("sha256:") || digest.starts_with("blake3:") {
3141 digest.to_string()
3142 } else {
3143 format!("sha256:{digest}")
3144 }
3145}
3146
3147fn compute_digest_for(bytes: &[u8], digest: &str) -> Result<String> {
3148 if digest.starts_with("blake3:") {
3149 let hash = blake3::hash(bytes);
3150 return Ok(format!("blake3:{}", hash.to_hex()));
3151 }
3152 let mut hasher = sha2::Sha256::new();
3153 hasher.update(bytes);
3154 Ok(format!("sha256:{}", to_hex(&hasher.finalize())))
3155}
3156
3157fn compute_sha256_digest_for(bytes: &[u8]) -> String {
3158 let mut hasher = sha2::Sha256::new();
3159 hasher.update(bytes);
3160 format!("sha256:{}", to_hex(&hasher.finalize()))
3161}
3162
3163fn build_artifact_key(cache: &CacheManager, digest: Option<&str>, bytes: &[u8]) -> ArtifactKey {
3164 let wasm_digest = digest
3165 .map(normalize_digest)
3166 .unwrap_or_else(|| compute_sha256_digest_for(bytes));
3167 ArtifactKey::new(cache.engine_profile_id().to_string(), wasm_digest)
3168}
3169
3170async fn compile_component_with_cache(
3171 cache: &CacheManager,
3172 engine: &Engine,
3173 digest: Option<&str>,
3174 bytes: Vec<u8>,
3175) -> Result<Arc<Component>> {
3176 let key = build_artifact_key(cache, digest, &bytes);
3177 cache.get_component(engine, &key, || Ok(bytes)).await
3178}
3179
3180fn verify_component_digest(component_id: &str, expected: &str, bytes: &[u8]) -> Result<()> {
3181 let normalized_expected = normalize_digest(expected);
3182 let actual = compute_digest_for(bytes, &normalized_expected)?;
3183 if normalize_digest(&actual) != normalized_expected {
3184 bail!(
3185 "component {component_id} digest mismatch: expected {normalized_expected}, got {actual}"
3186 );
3187 }
3188 Ok(())
3189}
3190
3191fn verify_wasm_sha256(component_id: &str, expected: &str, bytes: &[u8]) -> Result<()> {
3192 let normalized_expected = normalize_sha256(expected)?;
3193 let actual = compute_sha256_digest_for(bytes);
3194 if actual != normalized_expected {
3195 bail!(
3196 "component {component_id} bundled digest mismatch: expected {normalized_expected}, got {actual}"
3197 );
3198 }
3199 Ok(())
3200}
3201
3202fn to_hex(digest: &[u8]) -> String {
3203 digest.iter().map(|byte| format!("{byte:02x}")).collect()
3204}
3205
3206#[cfg(test)]
3207mod pack_lock_tests {
3208 use super::*;
3209 use tempfile::TempDir;
3210
3211 #[test]
3212 fn pack_lock_tag_ref_requires_bundle() {
3213 let lock = PackLockV1 {
3214 schema_version: 1,
3215 components: vec![PackLockComponent {
3216 name: "templates".to_string(),
3217 source_ref: Some("oci://registry.test/templates:latest".to_string()),
3218 legacy_ref: None,
3219 component_id: None,
3220 bundled: Some(false),
3221 bundled_path: None,
3222 legacy_path: None,
3223 wasm_sha256: None,
3224 legacy_sha256: None,
3225 resolved_digest: None,
3226 digest: None,
3227 }],
3228 };
3229 let err = component_sources_table_from_pack_lock(&lock, false).unwrap_err();
3230 assert!(
3231 err.to_string().contains("tag ref") && err.to_string().contains("rebuild the pack"),
3232 "unexpected error: {err}"
3233 );
3234 }
3235
3236 #[test]
3237 fn bundled_hash_mismatch_errors() {
3238 let rt = tokio::runtime::Runtime::new().expect("runtime");
3239 let temp = TempDir::new().expect("temp dir");
3240 let engine = Engine::default();
3241 let engine_profile =
3242 EngineProfile::from_engine(&engine, CpuPolicy::Native, "default".to_string());
3243 let cache_config = CacheConfig {
3244 root: temp.path().join("cache"),
3245 ..CacheConfig::default()
3246 };
3247 let cache = CacheManager::new(cache_config, engine_profile);
3248 let wasm_path = temp.path().join("component.wasm");
3249 let fixture_wasm = Path::new(env!("CARGO_MANIFEST_DIR"))
3250 .join("../../tests/fixtures/packs/secrets_store_smoke/components/echo_secret.wasm");
3251 let bytes = std::fs::read(&fixture_wasm).expect("read fixture wasm");
3252 std::fs::write(&wasm_path, &bytes).expect("write temp wasm");
3253
3254 let spec = ComponentSpec {
3255 id: "qa.process".to_string(),
3256 version: "0.0.0".to_string(),
3257 legacy_path: None,
3258 };
3259 let mut missing = HashSet::new();
3260 missing.insert(spec.id.clone());
3261
3262 let mut sources = HashMap::new();
3263 sources.insert(
3264 spec.id.clone(),
3265 ComponentSourceInfo {
3266 digest: Some("sha256:deadbeef".to_string()),
3267 source: ComponentSourceRef::Oci("registry.test/qa.process@sha256:deadbeef".into()),
3268 artifact: ComponentArtifactLocation::Inline {
3269 wasm_path: "component.wasm".to_string(),
3270 },
3271 expected_wasm_sha256: Some("sha256:deadbeef".to_string()),
3272 skip_digest_verification: false,
3273 },
3274 );
3275
3276 let mut loaded = HashMap::new();
3277 let result = rt.block_on(load_components_from_sources(
3278 &cache,
3279 &engine,
3280 &sources,
3281 &ComponentResolution::default(),
3282 &[spec],
3283 &mut missing,
3284 &mut loaded,
3285 Some(temp.path()),
3286 None,
3287 ));
3288 let err = result.unwrap_err();
3289 assert!(
3290 err.to_string().contains("bundled digest mismatch"),
3291 "unexpected error: {err}"
3292 );
3293 }
3294}
3295
3296#[cfg(test)]
3297mod pack_resolution_prop_tests {
3298 use super::*;
3299 use greentic_types::{ArtifactLocationV1, ComponentSourceEntryV1, ResolvedComponentV1};
3300 use proptest::prelude::*;
3301 use proptest::test_runner::{Config as ProptestConfig, RngAlgorithm, TestRng, TestRunner};
3302 use std::collections::BTreeSet;
3303 use std::path::Path;
3304 use std::str::FromStr;
3305
3306 #[derive(Clone, Debug)]
3307 enum ResolveRequest {
3308 ById(String),
3309 ByName(String),
3310 }
3311
3312 #[derive(Clone, Debug, PartialEq, Eq)]
3313 struct ResolvedComponent {
3314 key: String,
3315 source: String,
3316 artifact: String,
3317 digest: Option<String>,
3318 expected_wasm_sha256: Option<String>,
3319 skip_digest_verification: bool,
3320 }
3321
3322 #[derive(Clone, Debug, PartialEq, Eq)]
3323 struct ResolveError {
3324 code: String,
3325 message: String,
3326 context_key: String,
3327 }
3328
3329 #[derive(Clone, Debug)]
3330 struct Scenario {
3331 pack_lock: Option<PackLockV1>,
3332 component_sources: Option<ComponentSourcesV1>,
3333 request: ResolveRequest,
3334 expected_sha256: Option<String>,
3335 bytes: Vec<u8>,
3336 }
3337
3338 fn resolve_component_test(
3339 sources: Option<&ComponentSourcesV1>,
3340 lock: Option<&PackLockV1>,
3341 request: &ResolveRequest,
3342 ) -> Result<ResolvedComponent, ResolveError> {
3343 let table = if let Some(lock) = lock {
3344 component_sources_table_from_pack_lock(lock, false).map_err(|err| ResolveError {
3345 code: classify_pack_lock_error(err.to_string().as_str()).to_string(),
3346 message: err.to_string(),
3347 context_key: request_key(request).to_string(),
3348 })?
3349 } else {
3350 let sources = component_sources_table(sources).map_err(|err| ResolveError {
3351 code: "component_sources_error".to_string(),
3352 message: err.to_string(),
3353 context_key: request_key(request).to_string(),
3354 })?;
3355 sources.ok_or_else(|| ResolveError {
3356 code: "missing_component_sources".to_string(),
3357 message: "component sources not provided".to_string(),
3358 context_key: request_key(request).to_string(),
3359 })?
3360 };
3361
3362 let key = request_key(request);
3363 let source = table.get(key).ok_or_else(|| ResolveError {
3364 code: "component_not_found".to_string(),
3365 message: format!("component {key} not found"),
3366 context_key: key.to_string(),
3367 })?;
3368
3369 Ok(ResolvedComponent {
3370 key: key.to_string(),
3371 source: source.source.to_string(),
3372 artifact: match source.artifact {
3373 ComponentArtifactLocation::Inline { .. } => "inline".to_string(),
3374 ComponentArtifactLocation::Remote => "remote".to_string(),
3375 },
3376 digest: source.digest.clone(),
3377 expected_wasm_sha256: source.expected_wasm_sha256.clone(),
3378 skip_digest_verification: source.skip_digest_verification,
3379 })
3380 }
3381
3382 fn request_key(request: &ResolveRequest) -> &str {
3383 match request {
3384 ResolveRequest::ById(value) => value.as_str(),
3385 ResolveRequest::ByName(value) => value.as_str(),
3386 }
3387 }
3388
3389 fn classify_pack_lock_error(message: &str) -> &'static str {
3390 if message.contains("duplicate component name") {
3391 "duplicate_name"
3392 } else if message.contains("duplicate component id") {
3393 "duplicate_id"
3394 } else if message.contains("conflicting refs") {
3395 "conflicting_ref"
3396 } else if message.contains("conflicting bundled paths") {
3397 "conflicting_bundled_path"
3398 } else if message.contains("conflicting wasm_sha256") {
3399 "conflicting_wasm_sha256"
3400 } else if message.contains("missing source_ref") {
3401 "missing_source_ref"
3402 } else if message.contains("marked bundled but bundled_path is missing") {
3403 "missing_bundled_path"
3404 } else if message.contains("missing wasm_sha256") {
3405 "missing_wasm_sha256"
3406 } else if message.contains("tag ref") && message.contains("not bundled") {
3407 "tag_ref_requires_bundle"
3408 } else if message.contains("missing resolved_digest") {
3409 "missing_resolved_digest"
3410 } else if message.contains("invalid component ref") {
3411 "invalid_component_ref"
3412 } else if message.contains("sha256 digest") {
3413 "invalid_sha256"
3414 } else {
3415 "unknown_error"
3416 }
3417 }
3418
3419 fn known_error_codes() -> BTreeSet<&'static str> {
3420 [
3421 "component_sources_error",
3422 "missing_component_sources",
3423 "component_not_found",
3424 "duplicate_name",
3425 "duplicate_id",
3426 "conflicting_ref",
3427 "conflicting_bundled_path",
3428 "conflicting_wasm_sha256",
3429 "missing_source_ref",
3430 "missing_bundled_path",
3431 "missing_wasm_sha256",
3432 "tag_ref_requires_bundle",
3433 "missing_resolved_digest",
3434 "invalid_component_ref",
3435 "invalid_sha256",
3436 "unknown_error",
3437 ]
3438 .into_iter()
3439 .collect()
3440 }
3441
3442 fn proptest_config() -> ProptestConfig {
3443 let cases = std::env::var("PROPTEST_CASES")
3444 .ok()
3445 .and_then(|value| value.parse::<u32>().ok())
3446 .unwrap_or(128);
3447 ProptestConfig {
3448 cases,
3449 failure_persistence: None,
3450 ..ProptestConfig::default()
3451 }
3452 }
3453
3454 fn proptest_seed() -> Option<[u8; 32]> {
3455 let seed = std::env::var("PROPTEST_SEED")
3456 .ok()
3457 .and_then(|value| value.parse::<u64>().ok())?;
3458 let mut bytes = [0u8; 32];
3459 bytes[..8].copy_from_slice(&seed.to_le_bytes());
3460 Some(bytes)
3461 }
3462
3463 fn run_cases(strategy: impl Strategy<Value = Scenario>, cases: u32, seed: Option<[u8; 32]>) {
3464 let config = ProptestConfig {
3465 cases,
3466 failure_persistence: None,
3467 ..ProptestConfig::default()
3468 };
3469 let mut runner = match seed {
3470 Some(bytes) => {
3471 TestRunner::new_with_rng(config, TestRng::from_seed(RngAlgorithm::ChaCha, &bytes))
3472 }
3473 None => TestRunner::new(config),
3474 };
3475 runner
3476 .run(&strategy, |scenario| {
3477 run_scenario(&scenario);
3478 Ok(())
3479 })
3480 .unwrap();
3481 }
3482
3483 fn run_scenario(scenario: &Scenario) {
3484 let known_codes = known_error_codes();
3485 let first = resolve_component_test(
3486 scenario.component_sources.as_ref(),
3487 scenario.pack_lock.as_ref(),
3488 &scenario.request,
3489 );
3490 let second = resolve_component_test(
3491 scenario.component_sources.as_ref(),
3492 scenario.pack_lock.as_ref(),
3493 &scenario.request,
3494 );
3495 assert_eq!(normalize_result(&first), normalize_result(&second));
3496
3497 if let Some(lock) = scenario.pack_lock.as_ref() {
3498 let lock_only = resolve_component_test(None, Some(lock), &scenario.request);
3499 assert_eq!(normalize_result(&first), normalize_result(&lock_only));
3500 }
3501
3502 if let Err(err) = first.as_ref() {
3503 assert!(
3504 known_codes.contains(err.code.as_str()),
3505 "unexpected error code {}: {}",
3506 err.code,
3507 err.message
3508 );
3509 }
3510
3511 if let Some(expected) = scenario.expected_sha256.as_deref() {
3512 let expected_ok =
3513 verify_wasm_sha256("test.component", expected, &scenario.bytes).is_ok();
3514 let actual = compute_sha256_digest_for(&scenario.bytes);
3515 if actual == normalize_sha256(expected).unwrap_or_default() {
3516 assert!(expected_ok, "expected sha256 match to succeed");
3517 } else {
3518 assert!(!expected_ok, "expected sha256 mismatch to fail");
3519 }
3520 }
3521 }
3522
3523 fn normalize_result(
3524 result: &Result<ResolvedComponent, ResolveError>,
3525 ) -> Result<ResolvedComponent, ResolveError> {
3526 match result {
3527 Ok(value) => Ok(value.clone()),
3528 Err(err) => Err(err.clone()),
3529 }
3530 }
3531
3532 fn scenario_strategy() -> impl Strategy<Value = Scenario> {
3533 let name = any::<u8>().prop_map(|n| format!("component{n}.core"));
3534 let alt_name = any::<u8>().prop_map(|n| format!("component_alt{n}.core"));
3535 let tag_ref = any::<bool>();
3536 let bundled = any::<bool>();
3537 let include_sha = any::<bool>();
3538 let include_component_id = any::<bool>();
3539 let request_by_id = any::<bool>();
3540 let use_lock = any::<bool>();
3541 let use_sources = any::<bool>();
3542 let bytes = prop::collection::vec(any::<u8>(), 1..64);
3543
3544 (
3545 name,
3546 alt_name,
3547 tag_ref,
3548 bundled,
3549 include_sha,
3550 include_component_id,
3551 request_by_id,
3552 use_lock,
3553 use_sources,
3554 bytes,
3555 )
3556 .prop_map(
3557 |(
3558 name,
3559 alt_name,
3560 tag_ref,
3561 bundled,
3562 include_sha,
3563 include_component_id,
3564 request_by_id,
3565 use_lock,
3566 use_sources,
3567 bytes,
3568 )| {
3569 let component_id_str = if include_component_id {
3570 alt_name.clone()
3571 } else {
3572 name.clone()
3573 };
3574 let component_id = ComponentId::from_str(&component_id_str).ok();
3575 let source_ref = if tag_ref {
3576 format!("oci://registry.test/{name}:v1")
3577 } else {
3578 format!(
3579 "oci://registry.test/{name}@sha256:{}",
3580 hex::encode([0x11u8; 32])
3581 )
3582 };
3583 let expected_sha256 = if bundled && include_sha {
3584 Some(compute_sha256_digest_for(&bytes))
3585 } else {
3586 None
3587 };
3588
3589 let lock_component = PackLockComponent {
3590 name: name.clone(),
3591 source_ref: Some(source_ref),
3592 legacy_ref: None,
3593 component_id,
3594 bundled: Some(bundled),
3595 bundled_path: if bundled {
3596 Some(format!("components/{name}.wasm"))
3597 } else {
3598 None
3599 },
3600 legacy_path: None,
3601 wasm_sha256: expected_sha256.clone(),
3602 legacy_sha256: None,
3603 resolved_digest: if bundled {
3604 None
3605 } else {
3606 Some("sha256:deadbeef".to_string())
3607 },
3608 digest: None,
3609 };
3610
3611 let pack_lock = if use_lock {
3612 Some(PackLockV1 {
3613 schema_version: 1,
3614 components: vec![lock_component],
3615 })
3616 } else {
3617 None
3618 };
3619
3620 let component_sources = if use_sources {
3621 Some(ComponentSourcesV1::new(vec![ComponentSourceEntryV1 {
3622 name: name.clone(),
3623 component_id: ComponentId::from_str(&name).ok(),
3624 source: ComponentSourceRef::from_str(
3625 "oci://registry.test/component@sha256:deadbeef",
3626 )
3627 .expect("component ref"),
3628 resolved: ResolvedComponentV1 {
3629 digest: "sha256:deadbeef".to_string(),
3630 signature: None,
3631 signed_by: None,
3632 },
3633 artifact: if bundled {
3634 ArtifactLocationV1::Inline {
3635 wasm_path: format!("components/{name}.wasm"),
3636 manifest_path: None,
3637 }
3638 } else {
3639 ArtifactLocationV1::Remote
3640 },
3641 licensing_hint: None,
3642 metering_hint: None,
3643 }]))
3644 } else {
3645 None
3646 };
3647
3648 let request = if request_by_id {
3649 ResolveRequest::ById(component_id_str.clone())
3650 } else {
3651 ResolveRequest::ByName(name.clone())
3652 };
3653
3654 Scenario {
3655 pack_lock,
3656 component_sources,
3657 request,
3658 expected_sha256,
3659 bytes,
3660 }
3661 },
3662 )
3663 }
3664
3665 #[test]
3666 fn pack_resolution_proptest() {
3667 let seed = proptest_seed();
3668 run_cases(scenario_strategy(), proptest_config().cases, seed);
3669 }
3670
3671 #[test]
3672 fn pack_resolution_regression_seeds() {
3673 let seeds_path =
3674 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/proptest-seeds.txt");
3675 let raw = std::fs::read_to_string(&seeds_path).expect("read proptest seeds");
3676 for line in raw.lines() {
3677 let line = line.trim();
3678 if line.is_empty() || line.starts_with('#') {
3679 continue;
3680 }
3681 let seed = line.parse::<u64>().expect("seed must be an integer");
3682 let mut bytes = [0u8; 32];
3683 bytes[..8].copy_from_slice(&seed.to_le_bytes());
3684 run_cases(scenario_strategy(), 1, Some(bytes));
3685 }
3686 }
3687}
3688
3689fn locate_pack_assets(
3690 materialized_root: Option<&Path>,
3691 archive_hint: Option<&Path>,
3692) -> Result<(Option<PathBuf>, Option<TempDir>)> {
3693 if let Some(root) = materialized_root {
3694 let assets = root.join("assets");
3695 if assets.is_dir() {
3696 return Ok((Some(assets), None));
3697 }
3698 }
3699 if let Some(path) = archive_hint
3700 && let Some((tempdir, assets)) = extract_assets_from_archive(path)?
3701 {
3702 return Ok((Some(assets), Some(tempdir)));
3703 }
3704 Ok((None, None))
3705}
3706
3707fn extract_assets_from_archive(path: &Path) -> Result<Option<(TempDir, PathBuf)>> {
3708 let file =
3709 File::open(path).with_context(|| format!("failed to open pack {}", path.display()))?;
3710 let mut archive =
3711 ZipArchive::new(file).with_context(|| format!("failed to read pack {}", path.display()))?;
3712 let temp = TempDir::new().context("failed to create temporary assets directory")?;
3713 let mut found = false;
3714 for idx in 0..archive.len() {
3715 let mut entry = archive.by_index(idx)?;
3716 let name = entry.name();
3717 if !name.starts_with("assets/") {
3718 continue;
3719 }
3720 let dest = temp.path().join(name);
3721 if name.ends_with('/') {
3722 std::fs::create_dir_all(&dest)?;
3723 found = true;
3724 continue;
3725 }
3726 if let Some(parent) = dest.parent() {
3727 std::fs::create_dir_all(parent)?;
3728 }
3729 let mut outfile = std::fs::File::create(&dest)?;
3730 std::io::copy(&mut entry, &mut outfile)?;
3731 found = true;
3732 }
3733 if found {
3734 let assets_path = temp.path().join("assets");
3735 Ok(Some((temp, assets_path)))
3736 } else {
3737 Ok(None)
3738 }
3739}
3740
3741fn dist_options_from(component_resolution: &ComponentResolution) -> DistOptions {
3742 let mut opts = DistOptions {
3743 allow_tags: true,
3744 ..DistOptions::default()
3745 };
3746 if let Some(cache_dir) = component_resolution.dist_cache_dir.clone() {
3747 opts.cache_dir = cache_dir;
3748 }
3749 if component_resolution.dist_offline {
3750 opts.offline = true;
3751 }
3752 opts
3753}
3754
3755#[allow(clippy::too_many_arguments)]
3756async fn load_components_from_sources(
3757 cache: &CacheManager,
3758 engine: &Engine,
3759 component_sources: &HashMap<String, ComponentSourceInfo>,
3760 component_resolution: &ComponentResolution,
3761 specs: &[ComponentSpec],
3762 missing: &mut HashSet<String>,
3763 into: &mut HashMap<String, PackComponent>,
3764 materialized_root: Option<&Path>,
3765 archive_hint: Option<&Path>,
3766) -> Result<()> {
3767 let mut archive = if let Some(path) = archive_hint {
3768 Some(
3769 ZipArchive::new(File::open(path)?)
3770 .with_context(|| format!("{} is not a valid gtpack", path.display()))?,
3771 )
3772 } else {
3773 None
3774 };
3775 let mut dist_client: Option<DistClient> = None;
3776
3777 for spec in specs {
3778 if !missing.contains(&spec.id) {
3779 continue;
3780 }
3781 let Some(source) = component_sources.get(&spec.id) else {
3782 continue;
3783 };
3784
3785 let bytes = match &source.artifact {
3786 ComponentArtifactLocation::Inline { wasm_path } => {
3787 if let Some(root) = materialized_root {
3788 let path = root.join(wasm_path);
3789 if path.exists() {
3790 std::fs::read(&path).with_context(|| {
3791 format!(
3792 "failed to read inline component {} from {}",
3793 spec.id,
3794 path.display()
3795 )
3796 })?
3797 } else if archive.is_none() {
3798 bail!("inline component {} missing at {}", spec.id, path.display());
3799 } else {
3800 read_entry(
3801 archive.as_mut().expect("archive present when needed"),
3802 wasm_path,
3803 )
3804 .with_context(|| {
3805 format!(
3806 "inline component {} missing at {} in pack archive",
3807 spec.id, wasm_path
3808 )
3809 })?
3810 }
3811 } else if let Some(archive) = archive.as_mut() {
3812 read_entry(archive, wasm_path).with_context(|| {
3813 format!(
3814 "inline component {} missing at {} in pack archive",
3815 spec.id, wasm_path
3816 )
3817 })?
3818 } else {
3819 bail!(
3820 "inline component {} missing and no pack source available",
3821 spec.id
3822 );
3823 }
3824 }
3825 ComponentArtifactLocation::Remote => {
3826 if source.source.is_tag() {
3827 bail!(
3828 "component {} uses tag ref {} but is not bundled; rebuild the pack",
3829 spec.id,
3830 source.source
3831 );
3832 }
3833 let client = dist_client.get_or_insert_with(|| {
3834 DistClient::new(dist_options_from(component_resolution))
3835 });
3836 let reference = source.source.to_string();
3837 fault::maybe_fail_asset(&reference)
3838 .await
3839 .with_context(|| format!("fault injection blocked asset {reference}"))?;
3840 let digest = source.digest.as_deref().ok_or_else(|| {
3841 anyhow!(
3842 "component {} missing expected digest for remote component",
3843 spec.id
3844 )
3845 })?;
3846 let cache_path = if let Ok(cache_path) = client.fetch_digest(digest).await {
3847 cache_path
3848 } else if component_resolution.dist_offline {
3849 client
3850 .fetch_digest(digest)
3851 .await
3852 .map_err(|err| dist_error_for_component(err, &spec.id, &reference))?
3853 } else {
3854 let source = client
3855 .parse_source(&reference)
3856 .map_err(|err| dist_error_for_component(err, &spec.id, &reference))?;
3857 let descriptor = client
3858 .resolve(source, ResolvePolicy)
3859 .await
3860 .map_err(|err| dist_error_for_component(err, &spec.id, &reference))?;
3861 let resolved = client
3862 .fetch(&descriptor, CachePolicy)
3863 .await
3864 .map_err(|err| dist_error_for_component(err, &spec.id, &reference))?;
3865 let expected = normalize_digest(digest);
3866 let actual = normalize_digest(&resolved.digest);
3867 if expected != actual {
3868 bail!(
3869 "component {} digest mismatch after fetch: expected {}, got {}",
3870 spec.id,
3871 expected,
3872 actual
3873 );
3874 }
3875 resolved.cache_path.ok_or_else(|| {
3876 anyhow!(
3877 "component {} resolved from {} but cache path is missing",
3878 spec.id,
3879 reference
3880 )
3881 })?
3882 };
3883 std::fs::read(&cache_path).with_context(|| {
3884 format!(
3885 "failed to read cached component {} from {}",
3886 spec.id,
3887 cache_path.display()
3888 )
3889 })?
3890 }
3891 };
3892
3893 if let Some(expected) = source.expected_wasm_sha256.as_deref() {
3894 verify_wasm_sha256(&spec.id, expected, &bytes)?;
3895 } else if source.skip_digest_verification {
3896 let actual = compute_sha256_digest_for(&bytes);
3897 warn!(
3898 component_id = %spec.id,
3899 digest = %actual,
3900 "bundled component missing wasm_sha256; allowing due to flag"
3901 );
3902 } else {
3903 let expected = source.digest.as_deref().ok_or_else(|| {
3904 anyhow!(
3905 "component {} missing expected digest for verification",
3906 spec.id
3907 )
3908 })?;
3909 verify_component_digest(&spec.id, expected, &bytes)?;
3910 }
3911 let component =
3912 compile_component_with_cache(cache, engine, source.digest.as_deref(), bytes)
3913 .await
3914 .with_context(|| format!("failed to compile component {}", spec.id))?;
3915 into.insert(
3916 spec.id.clone(),
3917 PackComponent {
3918 name: spec.id.clone(),
3919 version: spec.version.clone(),
3920 component,
3921 },
3922 );
3923 missing.remove(&spec.id);
3924 }
3925
3926 Ok(())
3927}
3928
3929fn dist_error_for_component(err: DistError, component_id: &str, reference: &str) -> anyhow::Error {
3930 match err {
3931 DistError::NotFound { reference: missing } => anyhow!(
3932 "remote component {} is not cached for {}. Run `greentic-dist pull --lock <pack.lock>` or `greentic-dist pull {}`",
3933 component_id,
3934 missing,
3935 reference
3936 ),
3937 DistError::Offline { reference: blocked } => anyhow!(
3938 "offline mode blocked fetching component {} from {}; run `greentic-dist pull --lock <pack.lock>` or `greentic-dist pull {}`",
3939 component_id,
3940 blocked,
3941 reference
3942 ),
3943 DistError::Unauthorized { target } => anyhow!(
3944 "component {} requires authenticated source {}; run `greentic-dist pull --lock <pack.lock>` or `greentic-dist pull {}`",
3945 component_id,
3946 target,
3947 reference
3948 ),
3949 other => anyhow!(
3950 "failed to resolve component {} from {}: {}",
3951 component_id,
3952 reference,
3953 other
3954 ),
3955 }
3956}
3957
3958async fn load_components_from_overrides(
3959 cache: &CacheManager,
3960 engine: &Engine,
3961 overrides: &HashMap<String, PathBuf>,
3962 specs: &[ComponentSpec],
3963 missing: &mut HashSet<String>,
3964 into: &mut HashMap<String, PackComponent>,
3965) -> Result<()> {
3966 for spec in specs {
3967 if !missing.contains(&spec.id) {
3968 continue;
3969 }
3970 let Some(path) = overrides.get(&spec.id) else {
3971 continue;
3972 };
3973 let bytes = std::fs::read(path)
3974 .with_context(|| format!("failed to read override component {}", path.display()))?;
3975 let component = compile_component_with_cache(cache, engine, None, bytes)
3976 .await
3977 .with_context(|| {
3978 format!(
3979 "failed to compile component {} from override {}",
3980 spec.id,
3981 path.display()
3982 )
3983 })?;
3984 into.insert(
3985 spec.id.clone(),
3986 PackComponent {
3987 name: spec.id.clone(),
3988 version: spec.version.clone(),
3989 component,
3990 },
3991 );
3992 missing.remove(&spec.id);
3993 }
3994 Ok(())
3995}
3996
3997async fn load_components_from_dir(
3998 cache: &CacheManager,
3999 engine: &Engine,
4000 root: &Path,
4001 specs: &[ComponentSpec],
4002 missing: &mut HashSet<String>,
4003 into: &mut HashMap<String, PackComponent>,
4004) -> Result<()> {
4005 for spec in specs {
4006 if !missing.contains(&spec.id) {
4007 continue;
4008 }
4009 let path = component_path_for_spec(root, spec);
4010 if !path.exists() {
4011 tracing::debug!(component = %spec.id, path = %path.display(), "materialized component missing; will try other sources");
4012 continue;
4013 }
4014 let bytes = std::fs::read(&path)
4015 .with_context(|| format!("failed to read component {}", path.display()))?;
4016 let component = compile_component_with_cache(cache, engine, None, bytes)
4017 .await
4018 .with_context(|| {
4019 format!(
4020 "failed to compile component {} from {}",
4021 spec.id,
4022 path.display()
4023 )
4024 })?;
4025 into.insert(
4026 spec.id.clone(),
4027 PackComponent {
4028 name: spec.id.clone(),
4029 version: spec.version.clone(),
4030 component,
4031 },
4032 );
4033 missing.remove(&spec.id);
4034 }
4035 Ok(())
4036}
4037
4038async fn load_components_from_archive(
4039 cache: &CacheManager,
4040 engine: &Engine,
4041 path: &Path,
4042 specs: &[ComponentSpec],
4043 missing: &mut HashSet<String>,
4044 into: &mut HashMap<String, PackComponent>,
4045) -> Result<()> {
4046 let mut archive = ZipArchive::new(File::open(path)?)
4047 .with_context(|| format!("{} is not a valid gtpack", path.display()))?;
4048 for spec in specs {
4049 if !missing.contains(&spec.id) {
4050 continue;
4051 }
4052 let file_name = spec
4053 .legacy_path
4054 .clone()
4055 .unwrap_or_else(|| format!("components/{}.wasm", spec.id));
4056 let bytes = match read_entry(&mut archive, &file_name) {
4057 Ok(bytes) => bytes,
4058 Err(err) => {
4059 warn!(component = %spec.id, pack = %path.display(), error = %err, "component entry missing in pack archive");
4060 continue;
4061 }
4062 };
4063 let component = compile_component_with_cache(cache, engine, None, bytes)
4064 .await
4065 .with_context(|| format!("failed to compile component {}", spec.id))?;
4066 into.insert(
4067 spec.id.clone(),
4068 PackComponent {
4069 name: spec.id.clone(),
4070 version: spec.version.clone(),
4071 component,
4072 },
4073 );
4074 missing.remove(&spec.id);
4075 }
4076 Ok(())
4077}
4078
4079#[cfg(test)]
4080mod tests {
4081 use super::*;
4082 use greentic_flow::model::{FlowDoc, NodeDoc};
4083 use indexmap::IndexMap;
4084 use serde_json::json;
4085
4086 #[test]
4087 fn normalizes_raw_component_to_component_exec() {
4088 let mut nodes = IndexMap::new();
4089 let mut raw = IndexMap::new();
4090 raw.insert(
4091 "templating.handlebars".into(),
4092 json!({ "template": "Hi {{name}}" }),
4093 );
4094 nodes.insert(
4095 "start".into(),
4096 NodeDoc {
4097 raw,
4098 routing: json!([{"out": true}]),
4099 ..Default::default()
4100 },
4101 );
4102 let doc = FlowDoc {
4103 id: "welcome".into(),
4104 title: None,
4105 description: None,
4106 flow_type: "messaging".into(),
4107 start: Some("start".into()),
4108 parameters: json!({}),
4109 tags: Vec::new(),
4110 schema_version: None,
4111 entrypoints: IndexMap::new(),
4112 meta: None,
4113 nodes,
4114 };
4115
4116 let normalized = normalize_flow_doc(doc);
4117 let node = normalized.nodes.get("start").expect("node exists");
4118 assert_eq!(node.operation.as_deref(), Some("component.exec"));
4119 assert!(node.raw.is_empty());
4120 let payload = node.payload.as_object().expect("payload object");
4121 assert_eq!(
4122 payload.get("component"),
4123 Some(&Value::String("templating.handlebars".into()))
4124 );
4125 assert_eq!(
4126 payload.get("operation"),
4127 Some(&Value::String("render".into()))
4128 );
4129 let input = payload.get("input").unwrap();
4130 assert_eq!(input, &json!({ "template": "Hi {{name}}" }));
4131 }
4132
4133 #[test]
4134 fn normalizes_canonical_operation_node_to_component_exec_with_config() {
4135 let mut nodes = IndexMap::new();
4136 let mut raw = IndexMap::new();
4137 raw.insert(
4138 "handle_message".into(),
4139 json!({
4140 "component": "oci://ghcr.io/greenticai/component/component-llm-openai:stable",
4141 "config": {
4142 "provider": "ollama",
4143 "base_url": "http://127.0.0.1:11434/v1",
4144 "default_model": "llama3.2"
4145 },
4146 "input": {
4147 "messages": [{
4148 "role": "user",
4149 "content": "Say hello from Ollama."
4150 }]
4151 }
4152 }),
4153 );
4154 nodes.insert(
4155 "llm".into(),
4156 NodeDoc {
4157 raw,
4158 routing: json!([{"out": true}]),
4159 ..Default::default()
4160 },
4161 );
4162 let doc = FlowDoc {
4163 id: "ollama-repro".into(),
4164 title: None,
4165 description: None,
4166 flow_type: "messaging".into(),
4167 start: Some("llm".into()),
4168 parameters: json!({}),
4169 tags: Vec::new(),
4170 schema_version: None,
4171 entrypoints: IndexMap::new(),
4172 meta: None,
4173 nodes,
4174 };
4175
4176 let normalized = normalize_flow_doc(doc);
4177 let node = normalized.nodes.get("llm").expect("node exists");
4178 assert_eq!(node.operation.as_deref(), Some("component.exec"));
4179 assert!(node.raw.is_empty());
4180 let payload = node.payload.as_object().expect("payload object");
4181 assert_eq!(
4182 payload.get("component"),
4183 Some(&Value::String(
4184 "oci://ghcr.io/greenticai/component/component-llm-openai:stable".into()
4185 ))
4186 );
4187 assert_eq!(
4188 payload.get("operation"),
4189 Some(&Value::String("handle_message".into()))
4190 );
4191 assert_eq!(
4192 payload.get("config"),
4193 Some(&json!({
4194 "provider": "ollama",
4195 "base_url": "http://127.0.0.1:11434/v1",
4196 "default_model": "llama3.2"
4197 }))
4198 );
4199 assert_eq!(
4200 payload.get("input"),
4201 Some(&json!({
4202 "messages": [{
4203 "role": "user",
4204 "content": "Say hello from Ollama."
4205 }]
4206 }))
4207 );
4208 }
4209}
4210
4211#[derive(Clone, Debug, Default, Serialize, Deserialize)]
4212pub struct PackMetadata {
4213 pub pack_id: String,
4214 pub version: String,
4215 #[serde(default)]
4216 pub entry_flows: Vec<String>,
4217 #[serde(default)]
4218 pub secret_requirements: Vec<greentic_types::SecretRequirement>,
4219}
4220
4221impl PackMetadata {
4222 fn from_wasm(bytes: &[u8]) -> Option<Self> {
4223 let parser = Parser::new(0);
4224 for payload in parser.parse_all(bytes) {
4225 let payload = payload.ok()?;
4226 match payload {
4227 Payload::CustomSection(section) => {
4228 if section.name() == "greentic.manifest"
4229 && let Ok(meta) = Self::from_bytes(section.data())
4230 {
4231 return Some(meta);
4232 }
4233 }
4234 Payload::DataSection(reader) => {
4235 for segment in reader.into_iter().flatten() {
4236 if let Ok(meta) = Self::from_bytes(segment.data) {
4237 return Some(meta);
4238 }
4239 }
4240 }
4241 _ => {}
4242 }
4243 }
4244 None
4245 }
4246
4247 fn from_bytes(bytes: &[u8]) -> Result<Self, serde_cbor::Error> {
4248 #[derive(Deserialize)]
4249 struct RawManifest {
4250 pack_id: String,
4251 version: String,
4252 #[serde(default)]
4253 entry_flows: Vec<String>,
4254 #[serde(default)]
4255 flows: Vec<RawFlow>,
4256 #[serde(default)]
4257 secret_requirements: Vec<greentic_types::SecretRequirement>,
4258 }
4259
4260 #[derive(Deserialize)]
4261 struct RawFlow {
4262 id: String,
4263 }
4264
4265 let manifest: RawManifest = serde_cbor::from_slice(bytes)?;
4266 let mut entry_flows = if manifest.entry_flows.is_empty() {
4267 manifest.flows.iter().map(|f| f.id.clone()).collect()
4268 } else {
4269 manifest.entry_flows.clone()
4270 };
4271 entry_flows.retain(|id| !id.is_empty());
4272 Ok(Self {
4273 pack_id: manifest.pack_id,
4274 version: manifest.version,
4275 entry_flows,
4276 secret_requirements: manifest.secret_requirements,
4277 })
4278 }
4279
4280 pub fn fallback(path: &Path) -> Self {
4281 let pack_id = path
4282 .file_stem()
4283 .map(|s| s.to_string_lossy().into_owned())
4284 .unwrap_or_else(|| "unknown-pack".to_string());
4285 Self {
4286 pack_id,
4287 version: "0.0.0".to_string(),
4288 entry_flows: Vec::new(),
4289 secret_requirements: Vec::new(),
4290 }
4291 }
4292
4293 pub fn from_manifest(manifest: &greentic_types::PackManifest) -> Self {
4294 let entry_flows = manifest
4295 .flows
4296 .iter()
4297 .map(|flow| flow.id.as_str().to_string())
4298 .collect::<Vec<_>>();
4299 Self {
4300 pack_id: manifest.pack_id.as_str().to_string(),
4301 version: manifest.version.to_string(),
4302 entry_flows,
4303 secret_requirements: manifest.secret_requirements.clone(),
4304 }
4305 }
4306}