greentic_dev/
pack_run.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::io::{self, IsTerminal, Read, Write};
4use std::path::{Path, PathBuf};
5
6use anyhow::bail;
7use anyhow::{Context, Result, anyhow};
8use greentic_flow::flow_bundle::load_and_validate_bundle;
9use greentic_runner::desktop::{
10    HttpMock, HttpMockMode, MocksConfig, OtlpHook, Runner, SigningPolicy, ToolsMock,
11};
12use serde_json::{Value as JsonValue, json};
13use serde_yaml_bw as serde_yaml;
14use zip::ZipArchive;
15
16#[derive(Debug, Clone)]
17pub struct PackRunConfig<'a> {
18    pub pack_path: &'a Path,
19    pub entry: Option<String>,
20    pub input: Option<String>,
21    pub policy: RunPolicy,
22    pub otlp: Option<String>,
23    pub allow_hosts: Option<Vec<String>>,
24    pub mocks: MockSetting,
25    pub artifacts_dir: Option<&'a Path>,
26    pub json: bool,
27    pub offline: bool,
28    pub mock_exec: bool,
29    pub allow_external: bool,
30    pub mock_external: bool,
31    pub mock_external_payload: Option<JsonValue>,
32    pub secrets_seed: Option<&'a Path>,
33}
34
35#[derive(Debug, Clone, Copy)]
36pub enum RunPolicy {
37    Strict,
38    DevOk,
39}
40
41#[derive(Debug, Clone, Copy)]
42pub enum MockSetting {
43    On,
44    Off,
45}
46
47pub fn run(config: PackRunConfig<'_>) -> Result<()> {
48    if config.mock_exec {
49        let input_value = parse_input(config.input.clone())?;
50        let rendered = mock_execute_pack(
51            config.pack_path,
52            config.entry.as_deref().unwrap_or("default"),
53            &input_value,
54            config.offline,
55            config.allow_external,
56            config.mock_external,
57            config
58                .mock_external_payload
59                .clone()
60                .unwrap_or_else(|| json!({ "mocked": true })),
61            config.secrets_seed,
62        )?;
63        let mut rendered = rendered;
64        if let Some(map) = rendered.as_object_mut() {
65            map.insert("exec_mode".to_string(), json!("mock"));
66        }
67        if config.json {
68            println!(
69                "{}",
70                serde_json::to_string(&rendered).context("failed to render mock exec json")?
71            );
72        } else {
73            println!("{}", serde_json::to_string_pretty(&rendered)?);
74        }
75        let status = rendered
76            .get("status")
77            .and_then(|v| v.as_str())
78            .unwrap_or_default();
79        if status != "ok" {
80            bail!("pack run failed");
81        }
82        return Ok(());
83    }
84    // Print runner diagnostics even if the caller did not configure tracing.
85    let _ = tracing_subscriber::fmt::try_init();
86
87    // Ensure Wasmtime cache/config paths live inside the workspace so sandboxed runs can create them.
88    if std::env::var_os("HOME").is_none() || std::env::var_os("WASMTIME_CACHE_DIR").is_none() {
89        let workspace = std::env::current_dir().context("failed to resolve workspace root")?;
90        let home = workspace.join(".greentic").join("wasmtime-home");
91        let cache_dir = home
92            .join("Library")
93            .join("Caches")
94            .join("BytecodeAlliance.wasmtime");
95        let config_dir = home
96            .join("Library")
97            .join("Application Support")
98            .join("wasmtime");
99        fs::create_dir_all(&cache_dir)
100            .with_context(|| format!("failed to create {}", cache_dir.display()))?;
101        fs::create_dir_all(&config_dir)
102            .with_context(|| format!("failed to create {}", config_dir.display()))?;
103        // SAFETY: we scope HOME and cache dir to a workspace-local directory to avoid
104        // writing outside the sandbox; this only affects the child Wasmtime engine.
105        unsafe {
106            std::env::set_var("HOME", &home);
107            std::env::set_var("WASMTIME_CACHE_DIR", &cache_dir);
108        }
109    }
110
111    let input_value = parse_input(config.input.clone())?;
112    let otlp_hook = if config.offline {
113        None
114    } else {
115        config.otlp.map(|endpoint| OtlpHook {
116            endpoint,
117            headers: Vec::new(),
118            sample_all: true,
119        })
120    };
121
122    // Avoid system proxy discovery (reqwest on macOS can panic in sandboxed CI).
123    unsafe {
124        std::env::set_var("NO_PROXY", "*");
125        std::env::set_var("HTTPS_PROXY", "");
126        std::env::set_var("HTTP_PROXY", "");
127        std::env::set_var("CFNETWORK_DISABLE_SYSTEM_PROXY", "1");
128    }
129
130    let allow_hosts = config.allow_hosts.unwrap_or_default();
131    let mocks_config = build_mocks_config(config.mocks, allow_hosts)?;
132
133    let artifacts_override = config.artifacts_dir.map(|dir| dir.to_path_buf());
134    if let Some(dir) = &artifacts_override {
135        fs::create_dir_all(dir)
136            .with_context(|| format!("failed to create artifacts directory {}", dir.display()))?;
137    }
138
139    let runner = Runner::new();
140    let run_result = runner
141        .run_pack_with(config.pack_path, |opts| {
142            opts.entry_flow = config.entry.clone();
143            opts.input = input_value.clone();
144            opts.signing = signing_policy(config.policy);
145            if let Some(hook) = otlp_hook.clone() {
146                opts.otlp = Some(hook);
147            }
148            opts.mocks = mocks_config.clone();
149            opts.artifacts_dir = artifacts_override.clone();
150        })
151        .context("pack execution failed")?;
152
153    let value = serde_json::to_value(&run_result).context("failed to render run result JSON")?;
154    let mut value = value;
155    if let Some(map) = value.as_object_mut() {
156        map.insert("exec_mode".to_string(), json!("runtime"));
157    }
158    let status = value
159        .get("status")
160        .and_then(|v| v.as_str())
161        .unwrap_or_default();
162    if config.json {
163        println!(
164            "{}",
165            serde_json::to_string(&value).context("failed to render run result JSON")?
166        );
167    } else {
168        let rendered =
169            serde_json::to_string_pretty(&value).context("failed to render run result JSON")?;
170        println!("{rendered}");
171    }
172
173    if status == "Failure" || status == "PartialFailure" {
174        let err = value
175            .get("error")
176            .and_then(|v| v.as_str())
177            .unwrap_or("pack run returned failure status");
178        bail!("pack run failed: {err}");
179    }
180
181    Ok(())
182}
183
184#[allow(clippy::too_many_arguments)]
185fn mock_execute_pack(
186    path: &Path,
187    flow_id: &str,
188    input: &JsonValue,
189    offline: bool,
190    allow_external: bool,
191    mock_external: bool,
192    mock_external_payload: JsonValue,
193    secrets_seed: Option<&Path>,
194) -> Result<JsonValue> {
195    let bytes =
196        std::fs::read(path).with_context(|| format!("failed to read pack {}", path.display()))?;
197    let mut archive = ZipArchive::new(std::io::Cursor::new(bytes)).context("open pack zip")?;
198    let mut manifest_bytes = Vec::new();
199    archive
200        .by_name("manifest.cbor")
201        .context("manifest.cbor missing")?
202        .read_to_end(&mut manifest_bytes)
203        .context("read manifest")?;
204    let manifest: greentic_types::PackManifest =
205        greentic_types::decode_pack_manifest(&manifest_bytes).context("decode manifest")?;
206    let flow = manifest
207        .flows
208        .iter()
209        .find(|f| f.id.as_str() == flow_id)
210        .ok_or_else(|| anyhow!("flow `{flow_id}` not found in pack"))?;
211    let mut exec_builder = crate::tests_exec::ExecOptions::builder();
212    if let Some(seed_path) = secrets_seed {
213        exec_builder = exec_builder
214            .load_seed_file(seed_path)
215            .context("failed to load secrets seed")?;
216    }
217    let exec_opts = exec_builder
218        .offline(offline)
219        .external_enabled(allow_external)
220        .mock_external(mock_external)
221        .mock_external_payload(mock_external_payload)
222        .build()
223        .context("build mock exec options")?;
224    let exec = crate::tests_exec::execute_with_options(&flow.flow, input, &exec_opts)?;
225    Ok(exec)
226}
227
228fn parse_input(input: Option<String>) -> Result<JsonValue> {
229    if let Some(raw) = input {
230        if raw.trim().is_empty() {
231            return Ok(json!({}));
232        }
233        serde_json::from_str(&raw).context("failed to parse --input JSON")
234    } else {
235        Ok(json!({}))
236    }
237}
238
239fn build_mocks_config(setting: MockSetting, allow_hosts: Vec<String>) -> Result<MocksConfig> {
240    let mut config = MocksConfig {
241        net_allowlist: allow_hosts
242            .into_iter()
243            .map(|host| host.trim().to_ascii_lowercase())
244            .filter(|host| !host.is_empty())
245            .collect(),
246        ..MocksConfig::default()
247    };
248
249    if matches!(setting, MockSetting::On) {
250        config.http = Some(HttpMock {
251            record_replay_dir: None,
252            mode: HttpMockMode::RecordReplay,
253            rewrites: Vec::new(),
254        });
255
256        let tools_dir = PathBuf::from(".greentic").join("mocks").join("tools");
257        fs::create_dir_all(&tools_dir)
258            .with_context(|| format!("failed to create {}", tools_dir.display()))?;
259        config.mcp_tools = Some(ToolsMock {
260            directory: None,
261            script_dir: Some(tools_dir),
262            short_circuit: true,
263        });
264    }
265
266    Ok(config)
267}
268
269fn signing_policy(policy: RunPolicy) -> SigningPolicy {
270    match policy {
271        RunPolicy::Strict => SigningPolicy::Strict,
272        RunPolicy::DevOk => SigningPolicy::DevOk,
273    }
274}
275
276/// Run a config flow and return the final payload as a JSON string.
277#[allow(dead_code)]
278pub fn run_config_flow(flow_path: &Path) -> Result<String> {
279    let source = std::fs::read_to_string(flow_path)
280        .with_context(|| format!("failed to read config flow {}", flow_path.display()))?;
281    // Validate against embedded schema to catch malformed flows.
282    load_and_validate_bundle(&source, Some(flow_path)).context("config flow validation failed")?;
283
284    let doc: serde_yaml::Value = serde_yaml::from_str(&source)
285        .with_context(|| format!("invalid YAML in {}", flow_path.display()))?;
286    let nodes = doc
287        .get("nodes")
288        .and_then(|v| v.as_mapping())
289        .ok_or_else(|| anyhow!("config flow missing nodes map"))?;
290
291    let mut current = nodes
292        .iter()
293        .next()
294        .map(|(k, _)| k.as_str().unwrap_or_default().to_string())
295        .ok_or_else(|| anyhow!("config flow has no nodes to execute"))?;
296    let mut state: BTreeMap<String, String> = BTreeMap::new();
297    let mut visited: BTreeSet<String> = BTreeSet::new();
298    let is_tty = io::stdin().is_terminal();
299
300    loop {
301        if !visited.insert(current.clone()) {
302            bail!("config flow routing loop detected at {}", current);
303        }
304
305        let node_val = nodes
306            .get(serde_yaml::Value::String(current.clone(), None))
307            .ok_or_else(|| anyhow!("node `{current}` not found in config flow"))?;
308        let mapping = node_val
309            .as_mapping()
310            .ok_or_else(|| anyhow!("node `{current}` is not a mapping"))?;
311
312        // questions node
313        if let Some(fields) = mapping
314            .get(serde_yaml::Value::String("questions".to_string(), None))
315            .and_then(|q| {
316                q.as_mapping()
317                    .and_then(|m| m.get(serde_yaml::Value::String("fields".to_string(), None)))
318            })
319            .and_then(|v| v.as_sequence())
320        {
321            for field in fields {
322                let Some(field_map) = field.as_mapping() else {
323                    continue;
324                };
325                let id = field_map
326                    .get(serde_yaml::Value::String("id".to_string(), None))
327                    .and_then(|v| v.as_str())
328                    .unwrap_or("")
329                    .to_string();
330                if id.is_empty() {
331                    continue;
332                }
333                let prompt = field_map
334                    .get(serde_yaml::Value::String("prompt".to_string(), None))
335                    .and_then(|v| v.as_str())
336                    .unwrap_or(&id);
337                let default = field_map
338                    .get(serde_yaml::Value::String("default".to_string(), None))
339                    .and_then(|v| v.as_str())
340                    .unwrap_or("");
341                let value = if is_tty {
342                    print!("{prompt} [{default}]: ");
343                    let _ = io::stdout().flush();
344                    let mut buf = String::new();
345                    io::stdin().read_line(&mut buf).ok();
346                    let trimmed = buf.trim();
347                    if trimmed.is_empty() {
348                        default.to_string()
349                    } else {
350                        trimmed.to_string()
351                    }
352                } else {
353                    default.to_string()
354                };
355                state.insert(id, value);
356            }
357        }
358
359        // template string path
360        if let Some(template) = mapping
361            .get(serde_yaml::Value::String("template".to_string(), None))
362            .and_then(|v| v.as_str())
363        {
364            let mut rendered = template.to_string();
365            for (k, v) in &state {
366                let needle = format!("{{{{state.{k}}}}}");
367                rendered = rendered.replace(&needle, v);
368            }
369            return Ok(rendered);
370        }
371
372        // payload with node_id/node
373        if let Some(payload) = mapping.get(serde_yaml::Value::String("payload".to_string(), None)) {
374            let json_str = serde_json::to_string(&serde_yaml::from_value::<serde_json::Value>(
375                payload.clone(),
376            )?)
377            .context("failed to render config flow payload")?;
378            return Ok(json_str);
379        }
380
381        // follow routing if present
382        if let Some(next) = mapping
383            .get(serde_yaml::Value::String("routing".to_string(), None))
384            .and_then(|r| r.as_sequence())
385            .and_then(|seq| seq.first())
386            .and_then(|entry| {
387                entry
388                    .as_mapping()
389                    .and_then(|m| m.get(serde_yaml::Value::String("to".to_string(), None)))
390                    .and_then(|v| v.as_str())
391            })
392        {
393            current = next.to_string();
394            continue;
395        }
396
397        bail!("config flow ended without producing template or payload");
398    }
399}