1use std::collections::{BTreeMap, HashMap};
2use std::path::PathBuf;
3use std::sync::mpsc;
4
5use optative::reconcile::ReconcileErrors;
6use tempfile::NamedTempFile;
7
8use crate::process::{ProcessIdentity, ProcessSource, ProcessState, SpawnError};
9use crate::resource::Resource;
10use crate::{ProcessPool, StreamItem};
11
12#[derive(Clone, Debug, PartialEq)]
13pub struct ProcessSpec {
14 pub identity: ProcessIdentity,
15 pub args: Vec<Resource>,
16 pub env: BTreeMap<String, Resource>,
17 pub current_dir: Option<PathBuf>,
18 pub props: Option<serde_json::Value>,
19}
20
21struct CachedResolution {
22 spec: ProcessSpec,
23 source: ProcessSource,
24 _handles: Vec<NamedTempFile>,
25}
26
27fn resolve_spec(spec: &ProcessSpec) -> Result<(ProcessSource, Vec<NamedTempFile>), std::io::Error> {
28 let mut args = Vec::new();
29 let mut handles = Vec::new();
30
31 for resource in &spec.args {
32 let resolved = resource.resolve()?;
33 args.push(resolved.value);
34 if let Some(h) = resolved.handle {
35 handles.push(h);
36 }
37 }
38
39 let mut env = BTreeMap::new();
40 for (key, resource) in &spec.env {
41 let resolved = resource.resolve()?;
42 env.insert(key.clone(), resolved.value);
43 if let Some(h) = resolved.handle {
44 handles.push(h);
45 }
46 }
47
48 let source = ProcessSource {
49 identity: spec.identity.clone(),
50 args,
51 env,
52 current_dir: spec.current_dir.clone(),
53 props: spec.props.clone(),
54 };
55
56 Ok((source, handles))
57}
58
59pub struct ProcessSupervisor {
60 pool: ProcessPool,
61 states: HashMap<ProcessIdentity, CachedResolution>,
62}
63
64impl ProcessSupervisor {
65 pub fn new(stream_tx: mpsc::Sender<StreamItem>) -> Self {
66 Self {
67 pool: ProcessPool::new(stream_tx),
68 states: HashMap::new(),
69 }
70 }
71
72 pub fn reconcile(
73 &mut self,
74 desired: Vec<ProcessSpec>,
75 ) -> ReconcileErrors<ProcessIdentity, SpawnError> {
76 let mut resolved = Vec::new();
77 let mut new_states: HashMap<ProcessIdentity, CachedResolution> = HashMap::new();
78 let mut errors = ReconcileErrors::new();
79 let mut needs_restart = Vec::new();
80
81 for spec in desired {
82 let identity = spec.identity.clone();
83
84 if let Some(cached) = self.states.remove(&identity) {
85 if cached.spec == spec {
86 resolved.push(cached.source.clone());
87 new_states.insert(identity, cached);
88 continue;
89 }
90 needs_restart.push(identity.clone());
91 }
92
93 match resolve_spec(&spec) {
94 Ok((source, handles)) => {
95 resolved.push(source.clone());
96 new_states.insert(
97 identity,
98 CachedResolution {
99 spec,
100 source,
101 _handles: handles,
102 },
103 );
104 }
105 Err(e) => {
106 errors.push((identity, SpawnError::ResourceResolutionFailed { source: e }));
107 }
108 }
109 }
110
111 if !needs_restart.is_empty() {
116 let without: Vec<ProcessSource> = resolved
117 .iter()
118 .filter(|s| !needs_restart.contains(&s.identity))
119 .cloned()
120 .collect();
121 self.pool.reconcile(without);
122 }
123
124 let pool_errors = self.pool.reconcile(resolved);
125 errors.extend(pool_errors);
126
127 self.states = new_states;
128 errors
129 }
130
131 pub fn get(&self, identity: &ProcessIdentity) -> Option<&ProcessState> {
132 self.pool.get(identity)
133 }
134
135 pub fn iter(&self) -> impl Iterator<Item = (&ProcessIdentity, &ProcessState)> {
136 self.pool.iter()
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use std::time::Duration;
144
145 fn wait_for_line(rx: &mpsc::Receiver<StreamItem>, timeout: Duration) -> Option<StreamItem> {
146 rx.recv_timeout(timeout).ok()
147 }
148
149 #[test]
150 fn string_args_work_identically_to_process_pool() {
151 let (tx, rx) = mpsc::channel();
152 let mut supervisor = ProcessSupervisor::new(tx);
153
154 let errors = supervisor.reconcile(vec![ProcessSpec {
155 identity: ProcessIdentity {
156 bin: "/bin/sh".into(),
157 key: "echo".into(),
158 },
159 args: vec!["-c".into(), "echo hello_from_supervisor".into()],
160 env: BTreeMap::new(),
161 current_dir: None,
162 props: None,
163 }]);
164 assert!(errors.is_empty());
165
166 let item = wait_for_line(&rx, Duration::from_secs(2)).expect("should receive stdout");
167 assert_eq!(item.line, "hello_from_supervisor");
168 }
169
170 #[test]
171 fn file_resource_is_passed_as_executable_script() {
172 let (tx, rx) = mpsc::channel();
173 let mut supervisor = ProcessSupervisor::new(tx);
174
175 let errors = supervisor.reconcile(vec![ProcessSpec {
176 identity: ProcessIdentity {
177 bin: "/bin/sh".into(),
178 key: "file-test".into(),
179 },
180 args: vec![Resource::File {
181 content: "echo from_file_resource".into(),
182 }],
183 env: BTreeMap::new(),
184 current_dir: None,
185 props: None,
186 }]);
187 assert!(errors.is_empty());
188
189 let item = wait_for_line(&rx, Duration::from_secs(2)).expect("should receive stdout");
190 assert_eq!(item.line, "from_file_resource");
191 }
192
193 #[test]
194 fn unchanged_spec_does_not_restart_process() {
195 let (tx, rx) = mpsc::channel();
196 let mut supervisor = ProcessSupervisor::new(tx);
197
198 let spec = ProcessSpec {
199 identity: ProcessIdentity {
200 bin: "/bin/sh".into(),
201 key: "stable".into(),
202 },
203 args: vec!["-c".into(), "echo started; sleep 60".into()],
204 env: BTreeMap::new(),
205 current_dir: None,
206 props: None,
207 };
208
209 supervisor.reconcile(vec![spec.clone()]);
210 let item = wait_for_line(&rx, Duration::from_secs(2)).expect("first start");
211 assert_eq!(item.line, "started");
212
213 supervisor.reconcile(vec![spec]);
214 let next = rx.recv_timeout(Duration::from_millis(300));
216 assert!(
217 next.is_err(),
218 "process should not have restarted for unchanged spec"
219 );
220 }
221
222 #[test]
223 fn changed_file_content_restarts_process() {
224 let (tx, rx) = mpsc::channel();
225 let mut supervisor = ProcessSupervisor::new(tx);
226
227 let mk = |content: &str| ProcessSpec {
228 identity: ProcessIdentity {
229 bin: "/bin/sh".into(),
230 key: "versioned".into(),
231 },
232 args: vec![Resource::File {
233 content: content.into(),
234 }],
235 env: BTreeMap::new(),
236 current_dir: None,
237 props: None,
238 };
239
240 supervisor.reconcile(vec![mk("echo v1")]);
241 let item = wait_for_line(&rx, Duration::from_secs(2)).expect("v1");
242 assert_eq!(item.line, "v1");
243
244 supervisor.reconcile(vec![mk("echo v2")]);
245 let item = wait_for_line(&rx, Duration::from_secs(2)).expect("v2");
246 assert_eq!(item.line, "v2");
247 }
248
249 #[test]
250 fn removing_process_cleans_up_file_resources() {
251 let (tx, _rx) = mpsc::channel();
252 let mut supervisor = ProcessSupervisor::new(tx);
253
254 let spec = ProcessSpec {
255 identity: ProcessIdentity {
256 bin: "/bin/sh".into(),
257 key: "cleanup".into(),
258 },
259 args: vec![Resource::File {
260 content: "sleep 60".into(),
261 }],
262 env: BTreeMap::new(),
263 current_dir: None,
264 props: None,
265 };
266
267 supervisor.reconcile(vec![spec]);
268
269 let file_paths: Vec<String> = supervisor
270 .states
271 .values()
272 .flat_map(|c| c._handles.iter())
273 .map(|h| h.path().to_string_lossy().into_owned())
274 .collect();
275 assert!(!file_paths.is_empty(), "should have at least one temp file");
276 for p in &file_paths {
277 assert!(
278 std::path::Path::new(p).exists(),
279 "file should exist while process is running"
280 );
281 }
282
283 supervisor.reconcile(vec![]);
284
285 for p in &file_paths {
286 assert!(
287 !std::path::Path::new(p).exists(),
288 "file should be cleaned up after process exits"
289 );
290 }
291 }
292}