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