Skip to main content

greentic_dev/
pack_run.rs

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    // Print runner diagnostics even if the caller did not configure tracing.
86    let log_path = init_run_logging()?;
87    println!("Run logs: {}", log_path.display());
88
89    // Ensure Wasmtime cache/config paths live inside the workspace so sandboxed runs can create them.
90    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        // SAFETY: we scope HOME and cache dir to a workspace-local directory to avoid
106        // writing outside the sandbox; this only affects the child Wasmtime engine.
107        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    // Avoid system proxy discovery (reqwest on macOS can panic in sandboxed CI).
125    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}