observer_core/
providers.rs1use crate::config::{ObserverConfig, ProviderConfig};
5use crate::error::{ObserverError, ObserverResult};
6use crate::inventory::{Inventory, InventoryEntry, InventoryRunner};
7use crate::report::TelemetryEntry;
8use base64::engine::general_purpose::STANDARD;
9use base64::Engine;
10use serde::Deserialize;
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ResolvedProvider {
17 pub command: PathBuf,
18 pub args: Vec<String>,
19 pub cwd: PathBuf,
20 pub inherit_env: bool,
21 pub env: BTreeMap<String, String>,
22}
23
24pub type ResolvedProviders = BTreeMap<String, ResolvedProvider>;
25
26#[derive(Debug, Clone, Deserialize)]
27pub struct ProviderRunResponse {
28 pub provider: String,
29 pub target: String,
30 pub exit: i32,
31 pub out_b64: String,
32 pub err_b64: String,
33 #[serde(default)]
34 pub telemetry: Vec<TelemetryEntry>,
35}
36
37#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
38pub struct ProviderListResponse {
39 pub provider: String,
40 pub tests: Vec<ProviderListEntry>,
41}
42
43#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
44pub struct ProviderListEntry {
45 pub name: String,
46 pub target: String,
47}
48
49pub fn resolve_providers(
50 config: &ObserverConfig,
51 config_path: &Path,
52) -> ObserverResult<ResolvedProviders> {
53 let config_path = if config_path.is_absolute() {
54 config_path.to_path_buf()
55 } else {
56 std::env::current_dir()
57 .map_err(|error| ObserverError::Config(error.to_string()))?
58 .join(config_path)
59 };
60
61 let base_dir = config_path.parent().ok_or_else(|| {
62 ObserverError::Config("observer.toml must have a parent directory".to_owned())
63 })?;
64
65 let mut resolved = BTreeMap::new();
66 for (provider_id, provider) in &config.providers {
67 resolved.insert(
68 provider_id.clone(),
69 resolve_provider_entry(provider, base_dir)?,
70 );
71 }
72 Ok(resolved)
73}
74
75pub fn run_provider_target(
76 resolved: &ResolvedProvider,
77 target: &str,
78 timeout_ms: u32,
79) -> ObserverResult<ProviderRunResponse> {
80 let mut command = Command::new(&resolved.command);
81 command.args(&resolved.args);
82 command.arg("run");
83 command.arg("--target");
84 command.arg(target);
85 command.arg("--timeout-ms");
86 command.arg(timeout_ms.to_string());
87 command.current_dir(&resolved.cwd);
88
89 if !resolved.inherit_env {
90 command.env_clear();
91 }
92 command.envs(&resolved.env);
93
94 let output = command
95 .output()
96 .map_err(|error| ObserverError::Runtime(format!("provider host spawn failed: {error}")))?;
97
98 if !output.status.success() {
99 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
100 if stderr == "timeout" {
101 return Err(ObserverError::Runtime("timeout".to_owned()));
102 }
103 let msg = if stderr.is_empty() {
104 "provider host failed".to_owned()
105 } else {
106 format!("provider host failed: {stderr}")
107 };
108 return Err(ObserverError::Runtime(msg));
109 }
110
111 serde_json::from_slice::<ProviderRunResponse>(&output.stdout)
112 .map_err(|error| ObserverError::Runtime(format!("invalid provider run response: {error}")))
113}
114
115pub fn list_provider_tests(resolved: &ResolvedProvider) -> ObserverResult<ProviderListResponse> {
116 let mut command = Command::new(&resolved.command);
117 command.args(&resolved.args);
118 command.arg("list");
119 command.current_dir(&resolved.cwd);
120
121 if !resolved.inherit_env {
122 command.env_clear();
123 }
124 command.envs(&resolved.env);
125
126 let output = command
127 .output()
128 .map_err(|error| ObserverError::Runtime(format!("provider host spawn failed: {error}")))?;
129
130 if !output.status.success() {
131 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
132 let msg = if stderr.is_empty() {
133 "provider host failed".to_owned()
134 } else {
135 format!("provider host failed: {stderr}")
136 };
137 return Err(ObserverError::Runtime(msg));
138 }
139
140 let response = serde_json::from_slice::<ProviderListResponse>(&output.stdout)
141 .map_err(|error| ObserverError::Runtime(format!("invalid provider list response: {error}")))?;
142
143 let mut previous_name = None::<&str>;
144 for test in &response.tests {
145 if test.name.trim().is_empty() {
146 return Err(ObserverError::Runtime(
147 "provider list returned empty canonical test name".to_owned(),
148 ));
149 }
150 if test.target.trim().is_empty() {
151 return Err(ObserverError::Runtime(
152 "provider list returned empty target".to_owned(),
153 ));
154 }
155 if let Some(previous) = previous_name {
156 if previous.as_bytes().cmp(test.name.as_bytes()).is_gt() {
157 return Err(ObserverError::Runtime(
158 "provider list response is not in canonical name order".to_owned(),
159 ));
160 }
161 }
162 previous_name = Some(test.name.as_str());
163 }
164
165 Ok(response)
166}
167
168pub fn provider_list_to_inventory(response: &ProviderListResponse) -> ObserverResult<Inventory> {
169 let mut entries = Vec::with_capacity(response.tests.len());
170 let mut seen_names = BTreeMap::new();
171 for test in &response.tests {
172 if seen_names.insert(test.name.clone(), ()).is_some() {
173 return Err(ObserverError::Runtime(format!(
174 "provider list returned duplicate canonical test name `{}`",
175 test.name
176 )));
177 }
178 entries.push(InventoryEntry {
179 name: test.name.clone(),
180 runner: InventoryRunner::Provider {
181 provider: response.provider.clone(),
182 target: test.target.clone(),
183 },
184 });
185 }
186 Ok(Inventory { entries })
187}
188
189pub fn decode_b64_bytes(value: &str) -> ObserverResult<Vec<u8>> {
190 STANDARD
191 .decode(value)
192 .map_err(|error| ObserverError::Runtime(format!("invalid base64 payload: {error}")))
193}
194
195fn resolve_provider_entry(provider: &ProviderConfig, base_dir: &Path) -> ObserverResult<ResolvedProvider> {
196 if provider.command.trim().is_empty() {
197 return Err(ObserverError::Config(
198 "provider command must not be empty".to_owned(),
199 ));
200 }
201
202 let command = resolve_path(&provider.command, base_dir);
203 let cwd = provider
204 .cwd
205 .as_deref()
206 .map(|cwd| resolve_path(cwd, base_dir))
207 .unwrap_or_else(|| base_dir.to_path_buf());
208
209 Ok(ResolvedProvider {
210 command,
211 args: provider.args.clone(),
212 cwd,
213 inherit_env: provider.inherit_env,
214 env: provider.env.clone(),
215 })
216}
217
218fn resolve_path(value: &str, base_dir: &Path) -> PathBuf {
219 let path = PathBuf::from(value);
220 if path.is_absolute() {
221 path
222 } else {
223 base_dir.join(path)
224 }
225}