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_env_prefix: Option<String>,
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_env_prefix.as_deref(),
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_env_prefix: Option<&str>,
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 exec_opts = crate::tests_exec::ExecOptions::builder()
212        .offline(offline)
213        .external_enabled(allow_external)
214        .mock_external(mock_external)
215        .mock_external_payload(mock_external_payload)
216        .secrets_env_prefix(secrets_env_prefix.unwrap_or_default())
217        .build();
218    let exec = crate::tests_exec::execute_with_options(&flow.flow, input, &exec_opts)?;
219    Ok(exec)
220}
221
222fn parse_input(input: Option<String>) -> Result<JsonValue> {
223    if let Some(raw) = input {
224        if raw.trim().is_empty() {
225            return Ok(json!({}));
226        }
227        serde_json::from_str(&raw).context("failed to parse --input JSON")
228    } else {
229        Ok(json!({}))
230    }
231}
232
233fn build_mocks_config(setting: MockSetting, allow_hosts: Vec<String>) -> Result<MocksConfig> {
234    let mut config = MocksConfig {
235        net_allowlist: allow_hosts
236            .into_iter()
237            .map(|host| host.trim().to_ascii_lowercase())
238            .filter(|host| !host.is_empty())
239            .collect(),
240        ..MocksConfig::default()
241    };
242
243    if matches!(setting, MockSetting::On) {
244        config.http = Some(HttpMock {
245            record_replay_dir: None,
246            mode: HttpMockMode::RecordReplay,
247            rewrites: Vec::new(),
248        });
249
250        let tools_dir = PathBuf::from(".greentic").join("mocks").join("tools");
251        fs::create_dir_all(&tools_dir)
252            .with_context(|| format!("failed to create {}", tools_dir.display()))?;
253        config.mcp_tools = Some(ToolsMock {
254            directory: None,
255            script_dir: Some(tools_dir),
256            short_circuit: true,
257        });
258    }
259
260    Ok(config)
261}
262
263fn signing_policy(policy: RunPolicy) -> SigningPolicy {
264    match policy {
265        RunPolicy::Strict => SigningPolicy::Strict,
266        RunPolicy::DevOk => SigningPolicy::DevOk,
267    }
268}
269
270/// Run a config flow and return the final payload as a JSON string.
271#[allow(dead_code)]
272pub fn run_config_flow(flow_path: &Path) -> Result<String> {
273    let source = std::fs::read_to_string(flow_path)
274        .with_context(|| format!("failed to read config flow {}", flow_path.display()))?;
275    // Validate against embedded schema to catch malformed flows.
276    load_and_validate_bundle(&source, Some(flow_path)).context("config flow validation failed")?;
277
278    let doc: serde_yaml::Value = serde_yaml::from_str(&source)
279        .with_context(|| format!("invalid YAML in {}", flow_path.display()))?;
280    let nodes = doc
281        .get("nodes")
282        .and_then(|v| v.as_mapping())
283        .ok_or_else(|| anyhow!("config flow missing nodes map"))?;
284
285    let mut current = nodes
286        .iter()
287        .next()
288        .map(|(k, _)| k.as_str().unwrap_or_default().to_string())
289        .ok_or_else(|| anyhow!("config flow has no nodes to execute"))?;
290    let mut state: BTreeMap<String, String> = BTreeMap::new();
291    let mut visited: BTreeSet<String> = BTreeSet::new();
292    let is_tty = io::stdin().is_terminal();
293
294    loop {
295        if !visited.insert(current.clone()) {
296            bail!("config flow routing loop detected at {}", current);
297        }
298
299        let node_val = nodes
300            .get(serde_yaml::Value::String(current.clone(), None))
301            .ok_or_else(|| anyhow!("node `{current}` not found in config flow"))?;
302        let mapping = node_val
303            .as_mapping()
304            .ok_or_else(|| anyhow!("node `{current}` is not a mapping"))?;
305
306        // questions node
307        if let Some(fields) = mapping
308            .get(serde_yaml::Value::String("questions".to_string(), None))
309            .and_then(|q| {
310                q.as_mapping()
311                    .and_then(|m| m.get(serde_yaml::Value::String("fields".to_string(), None)))
312            })
313            .and_then(|v| v.as_sequence())
314        {
315            for field in fields {
316                let Some(field_map) = field.as_mapping() else {
317                    continue;
318                };
319                let id = field_map
320                    .get(serde_yaml::Value::String("id".to_string(), None))
321                    .and_then(|v| v.as_str())
322                    .unwrap_or("")
323                    .to_string();
324                if id.is_empty() {
325                    continue;
326                }
327                let prompt = field_map
328                    .get(serde_yaml::Value::String("prompt".to_string(), None))
329                    .and_then(|v| v.as_str())
330                    .unwrap_or(&id);
331                let default = field_map
332                    .get(serde_yaml::Value::String("default".to_string(), None))
333                    .and_then(|v| v.as_str())
334                    .unwrap_or("");
335                let value = if is_tty {
336                    print!("{prompt} [{default}]: ");
337                    let _ = io::stdout().flush();
338                    let mut buf = String::new();
339                    io::stdin().read_line(&mut buf).ok();
340                    let trimmed = buf.trim();
341                    if trimmed.is_empty() {
342                        default.to_string()
343                    } else {
344                        trimmed.to_string()
345                    }
346                } else {
347                    default.to_string()
348                };
349                state.insert(id, value);
350            }
351        }
352
353        // template string path
354        if let Some(template) = mapping
355            .get(serde_yaml::Value::String("template".to_string(), None))
356            .and_then(|v| v.as_str())
357        {
358            let mut rendered = template.to_string();
359            for (k, v) in &state {
360                let needle = format!("{{{{state.{k}}}}}");
361                rendered = rendered.replace(&needle, v);
362            }
363            return Ok(rendered);
364        }
365
366        // payload with node_id/node
367        if let Some(payload) = mapping.get(serde_yaml::Value::String("payload".to_string(), None)) {
368            let json_str = serde_json::to_string(&serde_yaml::from_value::<serde_json::Value>(
369                payload.clone(),
370            )?)
371            .context("failed to render config flow payload")?;
372            return Ok(json_str);
373        }
374
375        // follow routing if present
376        if let Some(next) = mapping
377            .get(serde_yaml::Value::String("routing".to_string(), None))
378            .and_then(|r| r.as_sequence())
379            .and_then(|seq| seq.first())
380            .and_then(|entry| {
381                entry
382                    .as_mapping()
383                    .and_then(|m| m.get(serde_yaml::Value::String("to".to_string(), None)))
384                    .and_then(|v| v.as_str())
385            })
386        {
387            current = next.to_string();
388            continue;
389        }
390
391        bail!("config flow ended without producing template or payload");
392    }
393}