1use std::fs;
2use std::io::Read;
3use std::path::{Path, PathBuf};
4
5use anyhow::bail;
6use anyhow::{Context, Result, anyhow};
7use greentic_runner::desktop::{
8 HttpMock, HttpMockMode, MocksConfig, OtlpHook, Runner, SigningPolicy, ToolsMock,
9};
10use serde_json::{Value as JsonValue, json};
11use zip::ZipArchive;
12
13#[derive(Debug, Clone)]
14pub struct PackRunConfig<'a> {
15 pub pack_path: &'a Path,
16 pub entry: Option<String>,
17 pub input: Option<String>,
18 pub policy: RunPolicy,
19 pub otlp: Option<String>,
20 pub allow_hosts: Option<Vec<String>>,
21 pub mocks: MockSetting,
22 pub artifacts_dir: Option<&'a Path>,
23 pub json: bool,
24 pub offline: bool,
25 pub mock_exec: bool,
26 pub allow_external: bool,
27 pub mock_external: bool,
28 pub mock_external_payload: Option<JsonValue>,
29 pub secrets_seed: Option<&'a Path>,
30}
31
32#[derive(Debug, Clone, Copy)]
33pub enum RunPolicy {
34 Strict,
35 DevOk,
36}
37
38#[derive(Debug, Clone, Copy)]
39pub enum MockSetting {
40 On,
41 Off,
42}
43
44pub fn run(config: PackRunConfig<'_>) -> Result<()> {
45 if config.mock_exec {
46 let input_value = parse_input(config.input.clone())?;
47 let rendered = mock_execute_pack(
48 config.pack_path,
49 config.entry.as_deref().unwrap_or("default"),
50 &input_value,
51 config.offline,
52 config.allow_external,
53 config.mock_external,
54 config
55 .mock_external_payload
56 .clone()
57 .unwrap_or_else(|| json!({ "mocked": true })),
58 config.secrets_seed,
59 )?;
60 let mut rendered = rendered;
61 if let Some(map) = rendered.as_object_mut() {
62 map.insert("exec_mode".to_string(), json!("mock"));
63 }
64 if config.json {
65 println!(
66 "{}",
67 serde_json::to_string(&rendered).context("failed to render mock exec json")?
68 );
69 } else {
70 println!("{}", serde_json::to_string_pretty(&rendered)?);
71 }
72 let status = rendered
73 .get("status")
74 .and_then(|v| v.as_str())
75 .unwrap_or_default();
76 if status != "ok" {
77 bail!("pack run failed");
78 }
79 return Ok(());
80 }
81 let _ = tracing_subscriber::fmt::try_init();
83
84 if std::env::var_os("HOME").is_none() || std::env::var_os("WASMTIME_CACHE_DIR").is_none() {
86 let workspace = std::env::current_dir().context("failed to resolve workspace root")?;
87 let home = workspace.join(".greentic").join("wasmtime-home");
88 let cache_dir = home
89 .join("Library")
90 .join("Caches")
91 .join("BytecodeAlliance.wasmtime");
92 let config_dir = home
93 .join("Library")
94 .join("Application Support")
95 .join("wasmtime");
96 fs::create_dir_all(&cache_dir)
97 .with_context(|| format!("failed to create {}", cache_dir.display()))?;
98 fs::create_dir_all(&config_dir)
99 .with_context(|| format!("failed to create {}", config_dir.display()))?;
100 unsafe {
103 std::env::set_var("HOME", &home);
104 std::env::set_var("WASMTIME_CACHE_DIR", &cache_dir);
105 }
106 }
107
108 let input_value = parse_input(config.input.clone())?;
109 let otlp_hook = if config.offline {
110 None
111 } else {
112 config.otlp.map(|endpoint| OtlpHook {
113 endpoint,
114 headers: Vec::new(),
115 sample_all: true,
116 })
117 };
118
119 unsafe {
121 std::env::set_var("NO_PROXY", "*");
122 std::env::set_var("HTTPS_PROXY", "");
123 std::env::set_var("HTTP_PROXY", "");
124 std::env::set_var("CFNETWORK_DISABLE_SYSTEM_PROXY", "1");
125 }
126
127 let allow_hosts = config.allow_hosts.unwrap_or_default();
128 let mocks_config = build_mocks_config(config.mocks, allow_hosts)?;
129
130 let artifacts_override = config.artifacts_dir.map(|dir| dir.to_path_buf());
131 if let Some(dir) = &artifacts_override {
132 fs::create_dir_all(dir)
133 .with_context(|| format!("failed to create artifacts directory {}", dir.display()))?;
134 }
135
136 let runner = Runner::new();
137 let run_result = runner
138 .run_pack_with(config.pack_path, |opts| {
139 opts.entry_flow = config.entry.clone();
140 opts.input = input_value.clone();
141 opts.signing = signing_policy(config.policy);
142 if let Some(hook) = otlp_hook.clone() {
143 opts.otlp = Some(hook);
144 }
145 opts.mocks = mocks_config.clone();
146 opts.artifacts_dir = artifacts_override.clone();
147 })
148 .context("pack execution failed")?;
149
150 let value = serde_json::to_value(&run_result).context("failed to render run result JSON")?;
151 let mut value = value;
152 if let Some(map) = value.as_object_mut() {
153 map.insert("exec_mode".to_string(), json!("runtime"));
154 }
155 let status = value
156 .get("status")
157 .and_then(|v| v.as_str())
158 .unwrap_or_default();
159 if config.json {
160 println!(
161 "{}",
162 serde_json::to_string(&value).context("failed to render run result JSON")?
163 );
164 } else {
165 let rendered =
166 serde_json::to_string_pretty(&value).context("failed to render run result JSON")?;
167 println!("{rendered}");
168 }
169
170 if status == "Failure" || status == "PartialFailure" {
171 let err = value
172 .get("error")
173 .and_then(|v| v.as_str())
174 .unwrap_or("pack run returned failure status");
175 bail!("pack run failed: {err}");
176 }
177
178 Ok(())
179}
180
181#[allow(clippy::too_many_arguments)]
182fn mock_execute_pack(
183 path: &Path,
184 flow_id: &str,
185 input: &JsonValue,
186 offline: bool,
187 allow_external: bool,
188 mock_external: bool,
189 mock_external_payload: JsonValue,
190 secrets_seed: Option<&Path>,
191) -> Result<JsonValue> {
192 let bytes =
193 std::fs::read(path).with_context(|| format!("failed to read pack {}", path.display()))?;
194 let mut archive = ZipArchive::new(std::io::Cursor::new(bytes)).context("open pack zip")?;
195 let mut manifest_bytes = Vec::new();
196 archive
197 .by_name("manifest.cbor")
198 .context("manifest.cbor missing")?
199 .read_to_end(&mut manifest_bytes)
200 .context("read manifest")?;
201 let manifest: greentic_types::PackManifest =
202 greentic_types::decode_pack_manifest(&manifest_bytes).context("decode manifest")?;
203 let flow = manifest
204 .flows
205 .iter()
206 .find(|f| f.id.as_str() == flow_id)
207 .ok_or_else(|| anyhow!("flow `{flow_id}` not found in pack"))?;
208 let mut exec_builder = crate::tests_exec::ExecOptions::builder();
209 if let Some(seed_path) = secrets_seed {
210 exec_builder = exec_builder
211 .load_seed_file(seed_path)
212 .context("failed to load secrets seed")?;
213 }
214 let exec_opts = exec_builder
215 .offline(offline)
216 .external_enabled(allow_external)
217 .mock_external(mock_external)
218 .mock_external_payload(mock_external_payload)
219 .build()
220 .context("build mock exec options")?;
221 let exec = crate::tests_exec::execute_with_options(&flow.flow, input, &exec_opts)?;
222 Ok(exec)
223}
224
225fn parse_input(input: Option<String>) -> Result<JsonValue> {
226 if let Some(raw) = input {
227 if raw.trim().is_empty() {
228 return Ok(json!({}));
229 }
230 serde_json::from_str(&raw).context("failed to parse --input JSON")
231 } else {
232 Ok(json!({}))
233 }
234}
235
236fn build_mocks_config(setting: MockSetting, allow_hosts: Vec<String>) -> Result<MocksConfig> {
237 let mut config = MocksConfig {
238 net_allowlist: allow_hosts
239 .into_iter()
240 .map(|host| host.trim().to_ascii_lowercase())
241 .filter(|host| !host.is_empty())
242 .collect(),
243 ..MocksConfig::default()
244 };
245
246 if matches!(setting, MockSetting::On) {
247 config.http = Some(HttpMock {
248 record_replay_dir: None,
249 mode: HttpMockMode::RecordReplay,
250 rewrites: Vec::new(),
251 });
252
253 let tools_dir = PathBuf::from(".greentic").join("mocks").join("tools");
254 fs::create_dir_all(&tools_dir)
255 .with_context(|| format!("failed to create {}", tools_dir.display()))?;
256 config.mcp_tools = Some(ToolsMock {
257 directory: None,
258 script_dir: Some(tools_dir),
259 short_circuit: true,
260 });
261 }
262
263 Ok(config)
264}
265
266fn signing_policy(policy: RunPolicy) -> SigningPolicy {
267 match policy {
268 RunPolicy::Strict => SigningPolicy::Strict,
269 RunPolicy::DevOk => SigningPolicy::DevOk,
270 }
271}