Skip to main content

greentic_runner/info/
report.rs

1//! `InfoReport` — the canonical capability/version surface of `greentic-runner`.
2//!
3//! The `info` subcommand (E5, separate commit) serialises this to JSON or
4//! renders it via the sibling `human` module. Fields stay stable across patch
5//! releases; add new ones with `#[serde(default)]` and bump
6//! `info_schema_version` only on breaking changes.
7
8use serde::{Deserialize, Serialize};
9
10/// Top-level report emitted by `greentic-runner info`.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct InfoReport {
13    pub info_schema_version: u32,
14    pub runner_version: String,
15    pub wasmtime_version: String,
16    pub target_triple: String,
17    pub build_profile: String,
18    pub build_timestamp_utc: String,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub git_sha: Option<String>,
21    pub pack_format_versions: Vec<u32>,
22    pub features: Features,
23    pub wasi_imports: Vec<InterfaceBinding>,
24    pub greentic_imports: Vec<InterfaceBinding>,
25}
26
27/// Compile-time Cargo feature partition.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Features {
30    pub enabled: Vec<String>,
31    pub disabled: Vec<String>,
32}
33
34/// A single WASI or Greentic interface the host links into guest components.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct InterfaceBinding {
37    pub interface: String,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub version: Option<String>,
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub versions: Vec<String>,
42    #[serde(default, skip_serializing_if = "is_false")]
43    pub opt_in_per_pack: bool,
44}
45
46fn is_false(b: &bool) -> bool {
47    !*b
48}
49
50/// Pack format versions the runner understands. Single-pinned today — sourced
51/// from `greentic_pack::builder::PACK_VERSION` so bumping the pack spec flows
52/// through without touching this file.
53pub const PACK_FORMAT_VERSIONS: &[u32] = &[greentic_pack::builder::PACK_VERSION];
54
55/// WASI preview-2 interfaces the host registers via `register_all` in
56/// `crates/greentic-runner-host/src/pack.rs`.
57pub const WASI_IMPORTS: &[(&str, &str)] = &[
58    ("wasi:io", "0.2.0"),
59    ("wasi:clocks", "0.2.0"),
60    ("wasi:filesystem", "0.2.0"),
61    ("wasi:random", "0.2.0"),
62    ("wasi:sockets", "0.2.0"),
63    ("wasi:http", "0.2.0"),
64    ("wasi:tls", "0.2.0"),
65];
66
67/// Hand-maintained description of a Greentic host-provided interface.
68pub struct GreenticImport {
69    pub interface: &'static str,
70    pub versions: &'static [&'static str],
71    pub opt_in_per_pack: bool,
72}
73
74/// Greentic-custom imports the host registers. Must stay in sync with
75/// `register_all` at `crates/greentic-runner-host/src/pack.rs:1277`. A
76/// consistency test below guards against drift.
77pub const GREENTIC_IMPORTS: &[GreenticImport] = &[
78    GreenticImport {
79        interface: "greentic:component/control",
80        versions: &["0.4.0", "0.5.0"],
81        opt_in_per_pack: false,
82    },
83    GreenticImport {
84        interface: "greentic:http/client",
85        versions: &["1.0.0", "1.1.0"],
86        opt_in_per_pack: false,
87    },
88    GreenticImport {
89        interface: "greentic:interfaces/host",
90        versions: &["0.6.0"],
91        opt_in_per_pack: false,
92    },
93    GreenticImport {
94        interface: "greentic:interfaces/telemetry",
95        versions: &["0.6.0"],
96        opt_in_per_pack: false,
97    },
98    GreenticImport {
99        interface: "greentic:interfaces/state-store",
100        versions: &["0.6.0"],
101        opt_in_per_pack: true,
102    },
103    GreenticImport {
104        interface: "greentic:interfaces/secrets",
105        versions: &["1.1.0"],
106        opt_in_per_pack: false,
107    },
108];
109
110/// Assemble the [`InfoReport`] from compile-time metadata.
111pub fn collect() -> InfoReport {
112    let mut enabled = Vec::new();
113    let mut disabled = Vec::new();
114    for (feat, on) in [
115        ("verify", cfg!(feature = "verify")),
116        ("telemetry", true),
117        ("session-redis", cfg!(feature = "session-redis")),
118        ("fault-injection", cfg!(feature = "fault-injection")),
119        (
120            "component-v0-6-introspection",
121            cfg!(feature = "component-v0-6-introspection"),
122        ),
123    ] {
124        if on {
125            enabled.push(feat.to_string());
126        } else {
127            disabled.push(feat.to_string());
128        }
129    }
130
131    InfoReport {
132        info_schema_version: 1,
133        runner_version: env!("CARGO_PKG_VERSION").to_string(),
134        wasmtime_version: wasmtime_environ::VERSION.to_string(),
135        target_triple: env!("TARGET").to_string(),
136        build_profile: env!("BUILD_PROFILE").to_string(),
137        build_timestamp_utc: env!("BUILD_TIMESTAMP_UTC").to_string(),
138        git_sha: option_env!("GIT_SHA").map(String::from),
139        pack_format_versions: PACK_FORMAT_VERSIONS.to_vec(),
140        features: Features { enabled, disabled },
141        wasi_imports: WASI_IMPORTS
142            .iter()
143            .map(|(iface, ver)| InterfaceBinding {
144                interface: (*iface).to_string(),
145                version: Some((*ver).to_string()),
146                versions: Vec::new(),
147                opt_in_per_pack: false,
148            })
149            .collect(),
150        greentic_imports: GREENTIC_IMPORTS
151            .iter()
152            .map(|g| InterfaceBinding {
153                interface: g.interface.to_string(),
154                version: None,
155                versions: g.versions.iter().map(|v| (*v).to_string()).collect(),
156                opt_in_per_pack: g.opt_in_per_pack,
157            })
158            .collect(),
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn schema_version_is_one() {
168        let r = collect();
169        assert_eq!(r.info_schema_version, 1);
170    }
171
172    #[test]
173    fn features_are_disjoint() {
174        let r = collect();
175        for f in &r.features.enabled {
176            assert!(
177                !r.features.disabled.contains(f),
178                "feature {f} appears in both enabled and disabled"
179            );
180        }
181    }
182
183    #[test]
184    fn runner_version_is_non_empty() {
185        let r = collect();
186        assert!(!r.runner_version.is_empty());
187        assert!(!r.wasmtime_version.is_empty());
188    }
189
190    /// Guards against drift between the hand-maintained `GREENTIC_IMPORTS`
191    /// list and the `register_all()` call site in pack.rs. Reads pack.rs as
192    /// text and asserts each interface name here actually appears somewhere
193    /// in that source. A failure means the list is stale — update it or
194    /// update register_all.
195    #[test]
196    fn greentic_imports_listed_here_are_registered() {
197        let path = concat!(
198            env!("CARGO_MANIFEST_DIR"),
199            "/../greentic-runner-host/src/pack.rs"
200        );
201        let source = std::fs::read_to_string(path).expect("read pack.rs");
202        for g in GREENTIC_IMPORTS {
203            let found_explicit = g
204                .versions
205                .iter()
206                .any(|v| source.contains(&format!("{}@{}", g.interface, v)));
207            // Some entries are registered via macro-generated bindings where
208            // the literal "interface@version" string never appears verbatim
209            // (e.g. `state_store`, `telemetry_logger`, `runner_host_http`,
210            // `secrets_store_v1_1`). For those, look for the underscored
211            // binding tag instead.
212            let fallback = match g.interface {
213                "greentic:interfaces/host" => {
214                    source.contains("runner_host_http") || source.contains("runner_host_kv")
215                }
216                "greentic:interfaces/telemetry" => source.contains("telemetry_logger"),
217                "greentic:interfaces/state-store" => source.contains("state_store"),
218                "greentic:interfaces/secrets" => source.contains("secrets_store_v1_1"),
219                _ => false,
220            };
221            assert!(
222                found_explicit || fallback,
223                "GREENTIC_IMPORTS entry {} not referenced in register_all() source — \
224                 either the interface was renamed/removed upstream (update this list) \
225                 or the test is looking at the wrong file.",
226                g.interface,
227            );
228        }
229    }
230}