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 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    // Print runner diagnostics even if the caller did not configure tracing.
82    let _ = tracing_subscriber::fmt::try_init();
83
84    // Ensure Wasmtime cache/config paths live inside the workspace so sandboxed runs can create them.
85    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        // SAFETY: we scope HOME and cache dir to a workspace-local directory to avoid
101        // writing outside the sandbox; this only affects the child Wasmtime engine.
102        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    // Avoid system proxy discovery (reqwest on macOS can panic in sandboxed CI).
120    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}