greentic_component/cmd/
test.rs

1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use base64::Engine as _;
7use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
8use clap::{ArgAction, Args};
9use serde_json::Value;
10use uuid::Uuid;
11
12use crate::capabilities::FilesystemMode;
13use crate::manifest::ComponentManifest;
14use crate::manifest::parse_manifest;
15use crate::test_harness::{HarnessConfig, TestHarness, WasiPreopen};
16use greentic_types::{EnvId, TeamId, TenantCtx, TenantId, UserId};
17
18#[derive(Args, Debug)]
19pub struct TestArgs {
20    /// Path to the component wasm binary.
21    #[arg(long, value_name = "PATH")]
22    pub wasm: PathBuf,
23    /// Optional manifest path (defaults to component.manifest.json next to the wasm).
24    #[arg(long, value_name = "PATH")]
25    pub manifest: Option<PathBuf>,
26    /// Operation to invoke (repeat for multi-step runs).
27    #[arg(long, value_name = "OP", action = ArgAction::Append)]
28    pub op: Vec<String>,
29    /// Input JSON file path (repeat for multi-step runs).
30    #[arg(long, value_name = "PATH", action = ArgAction::Append, conflicts_with = "input_json")]
31    pub input: Vec<PathBuf>,
32    /// Inline input JSON string (repeat for multi-step runs).
33    #[arg(long, value_name = "JSON", action = ArgAction::Append, conflicts_with = "input")]
34    pub input_json: Vec<String>,
35    /// Write output JSON to a file.
36    #[arg(long, value_name = "PATH")]
37    pub output: Option<PathBuf>,
38    /// Pretty-print JSON output.
39    #[arg(long)]
40    pub pretty: bool,
41    /// Dump in-memory state after invocation.
42    #[arg(long)]
43    pub state_dump: bool,
44    /// Seed in-memory state as KEY=BASE64 (repeatable).
45    #[arg(long = "state-set", value_name = "KEY=BASE64")]
46    pub state_set: Vec<String>,
47    /// Repeatable step marker for multi-step runs.
48    #[arg(long, action = ArgAction::Count)]
49    pub step: u8,
50    /// Load secrets from a .env style file.
51    #[arg(long, value_name = "PATH")]
52    pub secrets: Option<PathBuf>,
53    /// Load secrets from a JSON map file.
54    #[arg(long, value_name = "PATH")]
55    pub secrets_json: Option<PathBuf>,
56    /// Provide a secret inline as KEY=VALUE (repeatable).
57    #[arg(long = "secret", value_name = "KEY=VALUE")]
58    pub secret: Vec<String>,
59    /// Environment identifier for the exec context.
60    #[arg(long, default_value = "dev")]
61    pub env: String,
62    /// Tenant identifier for the exec context.
63    #[arg(long, default_value = "default")]
64    pub tenant: String,
65    /// Optional team identifier for the exec context.
66    #[arg(long)]
67    pub team: Option<String>,
68    /// Optional user identifier for the exec context.
69    #[arg(long)]
70    pub user: Option<String>,
71    /// Optional flow identifier for the exec context.
72    #[arg(long)]
73    pub flow: Option<String>,
74    /// Optional node identifier for the exec context.
75    #[arg(long)]
76    pub node: Option<String>,
77    /// Optional session identifier for the exec context.
78    #[arg(long)]
79    pub session: Option<String>,
80    /// Emit extra diagnostic output (e.g. generated session id).
81    #[arg(long)]
82    pub verbose: bool,
83}
84
85pub fn run(args: TestArgs) -> Result<()> {
86    let manifest_path = resolve_manifest_path(&args.wasm, args.manifest.as_deref())?;
87    let manifest_raw = fs::read_to_string(&manifest_path)
88        .with_context(|| format!("read manifest {}", manifest_path.display()))?;
89    let manifest_value: Value =
90        serde_json::from_str(&manifest_raw).context("manifest must be valid JSON")?;
91    let manifest = parse_manifest(&manifest_raw).context("parse manifest")?;
92
93    let steps = collect_steps(&args)?;
94    for (op, _) in &steps {
95        if !manifest
96            .operations
97            .iter()
98            .any(|operation| operation.name == *op)
99        {
100            bail!("operation `{op}` not declared in manifest");
101        }
102    }
103    let wasm_bytes =
104        fs::read(&args.wasm).with_context(|| format!("read wasm {}", args.wasm.display()))?;
105
106    let (tenant_ctx, session_id, generated_session) = build_tenant_ctx(&args)?;
107    if args.verbose && generated_session {
108        eprintln!("generated session id: {session_id}");
109    }
110
111    let (allow_state_read, allow_state_write, allow_state_delete) =
112        state_permissions(&manifest_value, &manifest);
113    if !args.state_set.is_empty() && !allow_state_write {
114        bail!("manifest does not declare host.state.write; add it to use --state-set");
115    }
116    let (allow_secrets, allowed_secrets) = secret_permissions(&manifest);
117
118    let secrets = load_secrets(&args)?;
119    if !allow_secrets && !secrets.is_empty() {
120        bail!("manifest does not declare host.secrets; add host.secrets to enable secrets access");
121    }
122
123    let state_seeds = parse_state_seeds(&args)?;
124    let wasi_preopens = resolve_wasi_preopens(&manifest)?;
125    let prefix = state_prefix(args.flow.as_deref(), &session_id);
126    let flow_id = args.flow.clone().unwrap_or_else(|| "test".to_string());
127    let harness = TestHarness::new(HarnessConfig {
128        wasm_bytes,
129        tenant_ctx: tenant_ctx.clone(),
130        flow_id,
131        node_id: args.node.clone(),
132        state_prefix: prefix,
133        state_seeds,
134        allow_state_read,
135        allow_state_write,
136        allow_state_delete,
137        allow_secrets,
138        allowed_secrets,
139        secrets,
140        wasi_preopens,
141    })?;
142
143    if steps.len() > 1 && args.output.is_some() {
144        bail!("--output is only supported for single-step runs");
145    }
146
147    for (idx, (op, input)) in steps.iter().enumerate() {
148        let output = harness.invoke(op, input)?;
149        let output = format_output(&output, args.pretty)?;
150        if let Some(path) = &args.output {
151            fs::write(path, output.as_bytes())
152                .with_context(|| format!("write output {}", path.display()))?;
153        }
154        if steps.len() > 1 {
155            println!("step {} output:\n{output}", idx + 1);
156        } else {
157            println!("{output}");
158        }
159    }
160
161    if args.state_dump {
162        let dump = harness.state_dump();
163        let dump_json = serde_json::to_string_pretty(&dump).unwrap_or_else(|_| "{}".into());
164        eprintln!("state dump:\n{dump_json}");
165    }
166
167    Ok(())
168}
169
170fn resolve_manifest_path(wasm: &Path, manifest: Option<&Path>) -> Result<PathBuf> {
171    if let Some(path) = manifest {
172        return Ok(path.to_path_buf());
173    }
174    let dir = wasm
175        .parent()
176        .ok_or_else(|| anyhow::anyhow!("wasm path has no parent directory"))?;
177    let candidate = dir.join("component.manifest.json");
178    if candidate.exists() {
179        Ok(candidate)
180    } else {
181        bail!(
182            "manifest not found; pass --manifest or place component.manifest.json next to the wasm"
183        );
184    }
185}
186
187fn collect_steps(args: &TestArgs) -> Result<Vec<(String, Value)>> {
188    if args.op.is_empty() {
189        bail!("--op is required");
190    }
191    let inputs = if !args.input.is_empty() {
192        let mut values = Vec::new();
193        for path in &args.input {
194            let raw = fs::read_to_string(path)
195                .with_context(|| format!("read input {}", path.display()))?;
196            values.push(serde_json::from_str(&raw).context("input file must be valid JSON")?);
197        }
198        values
199    } else if !args.input_json.is_empty() {
200        let mut values = Vec::new();
201        for raw in &args.input_json {
202            values.push(serde_json::from_str(raw).context("input-json must be valid JSON")?);
203        }
204        values
205    } else {
206        bail!("--input or --input-json is required");
207    };
208
209    if args.op.len() != inputs.len() {
210        bail!("provide the same number of --op and --input/--input-json values");
211    }
212    if args.op.len() > 1 {
213        let expected_steps = args.op.len().saturating_sub(1);
214        if args.step == 0 {
215            bail!("use --step to indicate a multi-step run");
216        }
217        if args.step as usize != expected_steps {
218            bail!(
219                "expected {expected_steps} --step flags for {} operations",
220                args.op.len()
221            );
222        }
223    }
224
225    Ok(args.op.clone().into_iter().zip(inputs).collect())
226}
227
228fn build_tenant_ctx(args: &TestArgs) -> Result<(TenantCtx, String, bool)> {
229    let env: EnvId = args.env.clone().try_into().context("invalid --env")?;
230    let tenant: TenantId = args.tenant.clone().try_into().context("invalid --tenant")?;
231    let mut ctx = TenantCtx::new(env, tenant);
232    if let Some(team) = &args.team {
233        let team: TeamId = team.clone().try_into().context("invalid --team")?;
234        ctx = ctx.with_team(Some(team));
235    }
236    if let Some(user) = &args.user {
237        let user: UserId = user.clone().try_into().context("invalid --user")?;
238        ctx = ctx.with_user(Some(user));
239    }
240
241    let (session_id, generated) = match &args.session {
242        Some(session) => (session.clone(), false),
243        None => (Uuid::new_v4().to_string(), true),
244    };
245    ctx = ctx.with_session(session_id.clone());
246
247    if let Some(flow) = &args.flow {
248        ctx = ctx.with_flow(flow.clone());
249    }
250    if let Some(node) = &args.node {
251        ctx = ctx.with_node(node.clone());
252    }
253
254    Ok((ctx, session_id, generated))
255}
256
257fn state_prefix(flow: Option<&str>, session: &str) -> String {
258    if let Some(flow) = flow {
259        format!("flow/{flow}/{session}")
260    } else {
261        format!("test/{session}")
262    }
263}
264
265fn resolve_wasi_preopens(manifest: &ComponentManifest) -> Result<Vec<WasiPreopen>> {
266    let Some(fs) = manifest.capabilities.wasi.filesystem.as_ref() else {
267        return Ok(Vec::new());
268    };
269    if fs.mode == FilesystemMode::None {
270        return Ok(Vec::new());
271    }
272    let host_root =
273        std::env::current_dir().context("resolve current working directory for mounts")?;
274    let meta = fs::metadata(&host_root)
275        .with_context(|| format!("failed to stat preopen {}", host_root.display()))?;
276    if !meta.is_dir() {
277        bail!("preopen {} must be a directory", host_root.display());
278    }
279    let read_only = matches!(fs.mode, FilesystemMode::ReadOnly);
280    let mut preopens = Vec::new();
281    for mount in &fs.mounts {
282        preopens.push(WasiPreopen::new(&host_root, mount.guest_path.clone()).read_only(read_only));
283    }
284    Ok(preopens)
285}
286
287fn state_permissions(
288    manifest_value: &Value,
289    manifest: &crate::manifest::ComponentManifest,
290) -> (bool, bool, bool) {
291    let mut allow_state_read = false;
292    let mut allow_state_write = false;
293    if let Some(state) = manifest.capabilities.host.state.as_ref() {
294        allow_state_read = state.read;
295        allow_state_write = state.write;
296    }
297    let allow_state_delete = manifest_value
298        .get("capabilities")
299        .and_then(|caps| caps.get("host"))
300        .and_then(|host| host.get("state"))
301        .and_then(|state| state.get("delete"))
302        .and_then(|value| value.as_bool())
303        .unwrap_or(false);
304    if allow_state_delete && !allow_state_write {
305        allow_state_write = true;
306    }
307    (allow_state_read, allow_state_write, allow_state_delete)
308}
309
310fn secret_permissions(manifest: &crate::manifest::ComponentManifest) -> (bool, HashSet<String>) {
311    let Some(secrets) = manifest.capabilities.host.secrets.as_ref() else {
312        return (false, HashSet::new());
313    };
314    let allowed = secrets
315        .required
316        .iter()
317        .map(|req| req.key.as_str().to_string())
318        .collect::<HashSet<_>>();
319    (true, allowed)
320}
321
322fn load_secrets(args: &TestArgs) -> Result<HashMap<String, String>> {
323    let mut secrets = HashMap::new();
324    if let Some(path) = &args.secrets {
325        let entries = parse_env_file(path)?;
326        secrets.extend(entries);
327    }
328    if let Some(path) = &args.secrets_json {
329        let entries = parse_json_secrets(path)?;
330        secrets.extend(entries);
331    }
332    for entry in &args.secret {
333        let (key, value) = entry
334            .split_once('=')
335            .ok_or_else(|| anyhow::anyhow!("invalid --secret `{entry}`; use KEY=VALUE"))?;
336        secrets.insert(key.to_string(), value.to_string());
337    }
338    Ok(secrets)
339}
340
341fn parse_state_seeds(args: &TestArgs) -> Result<Vec<(String, Vec<u8>)>> {
342    let mut seeds = Vec::new();
343    for entry in &args.state_set {
344        let (key, value) = entry
345            .split_once('=')
346            .ok_or_else(|| anyhow::anyhow!("invalid --state-set `{entry}`; use KEY=BASE64"))?;
347        let bytes = BASE64_STANDARD
348            .decode(value)
349            .with_context(|| format!("invalid base64 for state key `{key}`"))?;
350        seeds.push((key.to_string(), bytes));
351    }
352    Ok(seeds)
353}
354
355fn parse_env_file(path: &Path) -> Result<HashMap<String, String>> {
356    let contents =
357        fs::read_to_string(path).with_context(|| format!("read secrets {}", path.display()))?;
358    let mut secrets = HashMap::new();
359    for (idx, line) in contents.lines().enumerate() {
360        let line = line.trim();
361        if line.is_empty() || line.starts_with('#') {
362            continue;
363        }
364        let (key, value) = line.split_once('=').ok_or_else(|| {
365            anyhow::anyhow!(
366                "invalid secrets line {} in {} (expected KEY=VALUE)",
367                idx + 1,
368                path.display()
369            )
370        })?;
371        secrets.insert(key.trim().to_string(), value.trim().to_string());
372    }
373    Ok(secrets)
374}
375
376fn parse_json_secrets(path: &Path) -> Result<HashMap<String, String>> {
377    let contents =
378        fs::read_to_string(path).with_context(|| format!("read secrets {}", path.display()))?;
379    let value: Value = serde_json::from_str(&contents).context("secrets JSON must be valid")?;
380    let obj = value
381        .as_object()
382        .ok_or_else(|| anyhow::anyhow!("secrets JSON must be an object map"))?;
383    let mut secrets = HashMap::new();
384    for (key, value) in obj {
385        let value = value
386            .as_str()
387            .ok_or_else(|| anyhow::anyhow!("secret `{key}` must be a string value"))?;
388        secrets.insert(key.clone(), value.to_string());
389    }
390    Ok(secrets)
391}
392
393fn format_output(raw: &str, pretty: bool) -> Result<String> {
394    if !pretty {
395        return Ok(raw.to_string());
396    }
397    let value: Value = serde_json::from_str(raw).context("output is not valid JSON")?;
398    Ok(serde_json::to_string_pretty(&value)?)
399}