1use std::collections::BTreeMap;
11use std::env;
12use std::ffi::OsStr;
13use std::fmt::Debug;
14use std::fs::Permissions;
15use std::future::Future;
16use std::net::{IpAddr, SocketAddr, TcpListener as StdTcpListener};
17use std::num::NonZero;
18use std::os::unix::fs::PermissionsExt;
19use std::os::unix::process::ExitStatusExt;
20use std::path::{Path, PathBuf};
21use std::process::{ExitStatus, Stdio};
22use std::str::FromStr;
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::sync::{Arc, Mutex};
25
26use anyhow::{Context, anyhow, bail};
27use async_stream::stream;
28use async_trait::async_trait;
29use chrono::{DateTime, Utc};
30use futures::StreamExt;
31use futures::stream::{BoxStream, FuturesUnordered};
32use itertools::Itertools;
33use maplit::btreemap;
34use mz_orchestrator::scheduling_config::ServiceSchedulingConfig;
35use mz_orchestrator::{
36 CpuLimit, DiskLimit, MemoryLimit, NamespacedOrchestrator, Orchestrator, Service,
37 ServiceAssignments, ServiceConfig, ServiceEvent, ServicePort, ServiceProcessMetrics,
38 ServiceStatus,
39};
40use mz_ore::cast::{CastFrom, TryCastFrom};
41use mz_ore::error::ErrorExt;
42use mz_ore::netio::UnixSocketAddr;
43use mz_ore::result::ResultExt;
44use mz_ore::task::AbortOnDropHandle;
45use nix::sys::signal::Signal;
46use scopeguard::defer;
47use serde::Serialize;
48use sha1::{Digest, Sha1};
49use sysinfo::{Pid, Process, ProcessRefreshKind, ProcessesToUpdate, System};
50use tokio::fs::remove_dir_all;
51use tokio::net::{TcpListener, UnixStream};
52use tokio::process::{Child, Command};
53use tokio::sync::{broadcast, mpsc, oneshot};
54use tokio::time::{self, Duration};
55use tokio::{fs, io, select};
56use tracing::{debug, error, info, warn};
57
58pub mod secrets;
59
60#[derive(Debug, Clone)]
62pub struct ProcessOrchestratorConfig {
63 pub image_dir: PathBuf,
66 pub suppress_output: bool,
68 pub environment_id: String,
70 pub secrets_dir: PathBuf,
72 pub command_wrapper: Vec<String>,
74 pub propagate_crashes: bool,
76 pub tcp_proxy: Option<ProcessOrchestratorTcpProxyConfig>,
87 pub scratch_directory: PathBuf,
89}
90
91#[derive(Debug, Clone)]
95pub struct ProcessOrchestratorTcpProxyConfig {
96 pub listen_addr: IpAddr,
98 pub prometheus_service_discovery_dir: Option<PathBuf>,
108}
109
110#[derive(Debug)]
116pub struct ProcessOrchestrator {
117 image_dir: PathBuf,
118 suppress_output: bool,
119 namespaces: Mutex<BTreeMap<String, Arc<dyn NamespacedOrchestrator>>>,
120 metadata_dir: PathBuf,
121 secrets_dir: PathBuf,
122 command_wrapper: Vec<String>,
123 propagate_crashes: bool,
124 tcp_proxy: Option<ProcessOrchestratorTcpProxyConfig>,
125 scratch_directory: PathBuf,
126 launch_spec: LaunchSpec,
127}
128
129#[derive(Debug, Clone, Copy)]
130enum LaunchSpec {
131 Direct,
133 Systemd,
135}
136
137impl LaunchSpec {
138 fn determine_implementation() -> Result<Self, anyhow::Error> {
139 match Path::new("/run/systemd/system/").try_exists()? {
143 true => Ok(Self::Systemd),
144 false => Ok(Self::Direct),
145 }
146 }
147
148 fn refine_command(
149 &self,
150 image: impl AsRef<OsStr>,
151 args: &[impl AsRef<OsStr>],
152 wrapper: &[String],
153 memory_limit: Option<&MemoryLimit>,
154 cpu_limit: Option<&CpuLimit>,
155 ) -> Command {
156 let mut cmd = match self {
157 Self::Direct => {
158 if let Some((program, wrapper_args)) = wrapper.split_first() {
159 let mut cmd = Command::new(program);
160 cmd.args(wrapper_args);
161 cmd.arg(image);
162 cmd
163 } else {
164 Command::new(image)
165 }
166 }
167 Self::Systemd => {
168 let mut cmd = Command::new("systemd-run");
169 cmd.args(["--user", "--scope", "--quiet"]);
170 if let Some(memory_limit) = memory_limit {
171 let memory_limit = memory_limit.0.as_u64();
172 cmd.args(["-p", &format!("MemoryMax={memory_limit}")]);
173 }
175 if let Some(cpu_limit) = cpu_limit {
176 let cpu_limit = (cpu_limit.as_millicpus() + 9) / 10;
177 cmd.args(["-p", &format!("CPUQuota={cpu_limit}%")]);
178 }
179
180 cmd.args(wrapper);
181 cmd.arg(image);
182 cmd
183 }
184 };
185 cmd.args(args);
186 cmd
187 }
188}
189
190impl ProcessOrchestrator {
191 pub async fn new(
193 ProcessOrchestratorConfig {
194 image_dir,
195 suppress_output,
196 environment_id,
197 secrets_dir,
198 command_wrapper,
199 propagate_crashes,
200 tcp_proxy,
201 scratch_directory,
202 }: ProcessOrchestratorConfig,
203 ) -> Result<ProcessOrchestrator, anyhow::Error> {
204 let metadata_dir = env::temp_dir().join(format!("environmentd-{environment_id}"));
205 fs::create_dir_all(&metadata_dir)
206 .await
207 .context("creating metadata directory")?;
208 fs::create_dir_all(&secrets_dir)
209 .await
210 .context("creating secrets directory")?;
211 fs::set_permissions(&secrets_dir, Permissions::from_mode(0o700))
212 .await
213 .context("setting secrets directory permissions")?;
214 if let Some(prometheus_dir) = tcp_proxy
215 .as_ref()
216 .and_then(|p| p.prometheus_service_discovery_dir.as_ref())
217 {
218 fs::create_dir_all(&prometheus_dir)
219 .await
220 .context("creating prometheus directory")?;
221 }
222
223 let launch_spec = LaunchSpec::determine_implementation()?;
224 info!(driver = ?launch_spec, "Process orchestrator launch spec");
225
226 Ok(ProcessOrchestrator {
227 image_dir: fs::canonicalize(image_dir).await?,
228 suppress_output,
229 namespaces: Mutex::new(BTreeMap::new()),
230 metadata_dir: fs::canonicalize(metadata_dir).await?,
231 secrets_dir: fs::canonicalize(secrets_dir).await?,
232 command_wrapper,
233 propagate_crashes,
234 tcp_proxy,
235 scratch_directory,
236 launch_spec,
237 })
238 }
239}
240
241impl Orchestrator for ProcessOrchestrator {
242 fn namespace(&self, namespace: &str) -> Arc<dyn NamespacedOrchestrator> {
243 let mut namespaces = self.namespaces.lock().expect("lock poisoned");
244 Arc::clone(namespaces.entry(namespace.into()).or_insert_with(|| {
245 let config = Arc::new(NamespacedProcessOrchestratorConfig {
246 namespace: namespace.into(),
247 image_dir: self.image_dir.clone(),
248 suppress_output: self.suppress_output,
249 metadata_dir: self.metadata_dir.clone(),
250 command_wrapper: self.command_wrapper.clone(),
251 propagate_crashes: self.propagate_crashes,
252 tcp_proxy: self.tcp_proxy.clone(),
253 scratch_directory: self.scratch_directory.clone(),
254 launch_spec: self.launch_spec,
255 });
256
257 let services = Arc::new(Mutex::new(BTreeMap::new()));
258 let (service_event_tx, service_event_rx) = broadcast::channel(16384);
259 let (command_tx, command_rx) = mpsc::unbounded_channel();
260
261 let worker = OrchestratorWorker {
262 config: Arc::clone(&config),
263 services: Arc::clone(&services),
264 service_event_tx,
265 system: System::new(),
266 command_rx,
267 }
268 .spawn();
269
270 Arc::new(NamespacedProcessOrchestrator {
271 config,
272 services,
273 service_event_rx,
274 command_tx,
275 scheduling_config: Default::default(),
276 _worker: worker,
277 })
278 }))
279 }
280}
281
282#[derive(Debug)]
284struct NamespacedProcessOrchestratorConfig {
285 namespace: String,
286 image_dir: PathBuf,
287 suppress_output: bool,
288 metadata_dir: PathBuf,
289 command_wrapper: Vec<String>,
290 propagate_crashes: bool,
291 tcp_proxy: Option<ProcessOrchestratorTcpProxyConfig>,
292 scratch_directory: PathBuf,
293 launch_spec: LaunchSpec,
294}
295
296impl NamespacedProcessOrchestratorConfig {
297 fn full_id(&self, id: &str) -> String {
298 format!("{}-{}", self.namespace, id)
299 }
300
301 fn service_run_dir(&self, id: &str) -> PathBuf {
302 self.metadata_dir.join(&self.full_id(id))
303 }
304
305 fn service_scratch_dir(&self, id: &str) -> PathBuf {
306 self.scratch_directory.join(&self.full_id(id))
307 }
308}
309
310#[derive(Debug)]
311struct NamespacedProcessOrchestrator {
312 config: Arc<NamespacedProcessOrchestratorConfig>,
313 services: Arc<Mutex<BTreeMap<String, Vec<ProcessState>>>>,
314 service_event_rx: broadcast::Receiver<ServiceEvent>,
315 command_tx: mpsc::UnboundedSender<WorkerCommand>,
316 scheduling_config: std::sync::RwLock<ServiceSchedulingConfig>,
317 _worker: AbortOnDropHandle<()>,
318}
319
320impl NamespacedProcessOrchestrator {
321 fn send_command(&self, cmd: WorkerCommand) {
322 self.command_tx.send(cmd).expect("worker task not dropped");
323 }
324}
325
326#[async_trait]
327impl NamespacedOrchestrator for NamespacedProcessOrchestrator {
328 fn ensure_service(
329 &self,
330 id: &str,
331 config: ServiceConfig,
332 ) -> Result<Box<dyn Service>, anyhow::Error> {
333 let service = ProcessService {
334 run_dir: self.config.service_run_dir(id),
335 scale: config.scale,
336 };
337
338 let disk = config.disk_limit != Some(DiskLimit::ZERO);
340
341 let config = EnsureServiceConfig {
342 image: config.image,
343 args: config.args,
344 ports: config.ports,
345 memory_limit: config.memory_limit,
346 cpu_limit: config.cpu_limit,
347 scale: config.scale,
348 labels: config.labels,
349 disk,
350 };
351
352 self.send_command(WorkerCommand::EnsureService {
353 id: id.to_string(),
354 config,
355 });
356
357 Ok(Box::new(service))
358 }
359
360 fn drop_service(&self, id: &str) -> Result<(), anyhow::Error> {
361 self.send_command(WorkerCommand::DropService { id: id.to_string() });
362 Ok(())
363 }
364
365 async fn list_services(&self) -> Result<Vec<String>, anyhow::Error> {
366 let (result_tx, result_rx) = oneshot::channel();
367 self.send_command(WorkerCommand::ListServices { result_tx });
368
369 result_rx.await.expect("worker task not dropped")
370 }
371
372 async fn flush(&self) -> Result<(), anyhow::Error> {
373 let (result_tx, result_rx) = oneshot::channel();
374 self.send_command(WorkerCommand::Flush { result_tx });
375 result_rx.await.expect("worker task not dropped");
376 Ok(())
377 }
378
379 fn watch_services(&self) -> BoxStream<'static, Result<ServiceEvent, anyhow::Error>> {
380 let mut initial_events = vec![];
381 let mut service_event_rx = {
382 let services = self.services.lock().expect("lock poisoned");
383 for (service_id, process_states) in &*services {
384 for (process_id, process_state) in process_states.iter().enumerate() {
385 initial_events.push(ServiceEvent {
386 service_id: service_id.clone(),
387 process_id: u64::cast_from(process_id),
388 status: process_state.status.into(),
389 restart_count: process_state.restart_count,
390 time: process_state.status_time,
391 });
392 }
393 }
394 self.service_event_rx.resubscribe()
395 };
396 Box::pin(stream! {
397 for event in initial_events {
398 yield Ok(event);
399 }
400 loop {
401 yield service_event_rx.recv().await.err_into();
402 }
403 })
404 }
405
406 async fn fetch_service_metrics(
407 &self,
408 id: &str,
409 ) -> Result<Vec<ServiceProcessMetrics>, anyhow::Error> {
410 let (result_tx, result_rx) = oneshot::channel();
411 self.send_command(WorkerCommand::FetchServiceMetrics {
412 id: id.to_string(),
413 result_tx,
414 });
415
416 result_rx.await.expect("worker task not dropped")
417 }
418
419 fn update_scheduling_config(
420 &self,
421 config: mz_orchestrator::scheduling_config::ServiceSchedulingConfig,
422 ) {
423 *self.scheduling_config.write().expect("poisoned") = config;
424 }
425}
426
427enum WorkerCommand {
433 EnsureService {
434 id: String,
435 config: EnsureServiceConfig,
436 },
437 DropService {
438 id: String,
439 },
440 ListServices {
441 result_tx: oneshot::Sender<Result<Vec<String>, anyhow::Error>>,
442 },
443 Flush {
444 result_tx: oneshot::Sender<()>,
445 },
446 FetchServiceMetrics {
447 id: String,
448 result_tx: oneshot::Sender<Result<Vec<ServiceProcessMetrics>, anyhow::Error>>,
449 },
450}
451
452struct EnsureServiceConfig {
454 pub image: String,
458 pub args: Box<dyn Fn(ServiceAssignments) -> Vec<String> + Send + Sync>,
461 pub ports: Vec<ServicePort>,
463 pub memory_limit: Option<MemoryLimit>,
465 pub cpu_limit: Option<CpuLimit>,
467 pub scale: NonZero<u16>,
469 pub labels: BTreeMap<String, String>,
474 pub disk: bool,
476}
477
478struct OrchestratorWorker {
490 config: Arc<NamespacedProcessOrchestratorConfig>,
491 services: Arc<Mutex<BTreeMap<String, Vec<ProcessState>>>>,
492 service_event_tx: broadcast::Sender<ServiceEvent>,
493 system: System,
494 command_rx: mpsc::UnboundedReceiver<WorkerCommand>,
495}
496
497impl OrchestratorWorker {
498 fn spawn(self) -> AbortOnDropHandle<()> {
499 let name = format!("process-orchestrator:{}", self.config.namespace);
500 mz_ore::task::spawn(|| name, self.run()).abort_on_drop()
501 }
502
503 async fn run(mut self) {
504 while let Some(cmd) = self.command_rx.recv().await {
505 use WorkerCommand::*;
506 let result = match cmd {
507 EnsureService { id, config } => self.ensure_service(id, config).await,
508 DropService { id } => self.drop_service(&id).await,
509 ListServices { result_tx } => {
510 let _ = result_tx.send(self.list_services().await);
511 Ok(())
512 }
513 Flush { result_tx } => {
514 let _ = result_tx.send(());
515 Ok(())
516 }
517 FetchServiceMetrics { id, result_tx } => {
518 let _ = result_tx.send(self.fetch_service_metrics(&id));
519 Ok(())
520 }
521 };
522
523 if let Err(error) = result {
524 panic!("process orchestrator worker failed: {error}");
525 }
526 }
527 }
528
529 fn fetch_service_metrics(
530 &mut self,
531 id: &str,
532 ) -> Result<Vec<ServiceProcessMetrics>, anyhow::Error> {
533 let pids: Vec<_> = {
534 let services = self.services.lock().expect("lock poisoned");
535 let Some(service) = services.get(id) else {
536 bail!("unknown service {id}")
537 };
538 service.iter().map(|p| p.pid()).collect()
539 };
540
541 let mut metrics = vec![];
542 for pid in pids {
543 let (cpu_nano_cores, memory_bytes) = match pid {
544 None => (None, None),
545 Some(pid) => {
546 self.system.refresh_processes_specifics(
547 ProcessesToUpdate::Some(&[pid]),
548 true,
549 ProcessRefreshKind::nothing().with_cpu().with_memory(),
550 );
551 match self.system.process(pid) {
552 None => (None, None),
553 Some(process) => {
554 let cpu = u64::try_cast_from(
563 (f64::from(process.cpu_usage()) * 10_000_000.0).trunc(),
564 )
565 .expect("sane value of process.cpu_usage()");
566 let memory = process.memory();
567 (Some(cpu), Some(memory))
568 }
569 }
570 }
571 };
572 metrics.push(ServiceProcessMetrics {
573 cpu_nano_cores,
574 memory_bytes,
575 disk_bytes: None,
577 heap_bytes: None,
578 heap_limit: None,
579 swap_bytes: None,
580 });
581 }
582 Ok(metrics)
583 }
584
585 async fn ensure_service(
586 &self,
587 id: String,
588 EnsureServiceConfig {
589 image,
590 args,
591 ports: ports_in,
592 memory_limit,
593 cpu_limit,
594 scale,
595 labels,
596 disk,
597 }: EnsureServiceConfig,
598 ) -> Result<(), anyhow::Error> {
599 let full_id = self.config.full_id(&id);
600
601 let run_dir = self.config.service_run_dir(&id);
602 fs::create_dir_all(&run_dir)
603 .await
604 .context("creating run directory")?;
605 let scratch_dir = if disk {
606 let scratch_dir = self.config.service_scratch_dir(&id);
607 fs::create_dir_all(&scratch_dir)
608 .await
609 .context("creating scratch directory")?;
610 Some(fs::canonicalize(&scratch_dir).await?)
611 } else {
612 None
613 };
614
615 let old_scale = {
618 let services = self.services.lock().expect("poisoned");
619 services.get(&id).map(|states| states.len())
620 };
621 match old_scale {
622 Some(old) if old == usize::cast_from(scale) => return Ok(()),
623 Some(_) => self.drop_service(&id).await?,
624 None => (),
625 }
626
627 let mut peer_addrs = Vec::new();
629 for i in 0..scale.into() {
630 let addresses = ports_in
631 .iter()
632 .map(|port| {
633 let addr = socket_path(&run_dir, &port.name, i);
634 (port.name.clone(), addr)
635 })
636 .collect();
637 peer_addrs.push(addresses);
638 }
639
640 {
641 let mut services = self.services.lock().expect("lock poisoned");
642
643 let mut process_states = vec![];
645 for i in 0..usize::cast_from(scale) {
646 let listen_addrs = &peer_addrs[i];
647
648 let mut command_wrapper = self.config.command_wrapper.clone();
650 if let Some(parts) = command_wrapper.get_mut(1..) {
651 for part in parts {
652 *part = interpolate_command(&part[..], &full_id, listen_addrs);
653 }
654 }
655
656 let mut ports = vec![];
658 let mut tcp_proxy_addrs = BTreeMap::new();
659 for port in &ports_in {
660 let tcp_proxy_listener = match &self.config.tcp_proxy {
661 None => None,
662 Some(tcp_proxy) => {
663 let listener = StdTcpListener::bind((tcp_proxy.listen_addr, 0))
664 .with_context(|| format!("binding to {}", tcp_proxy.listen_addr))?;
665 listener.set_nonblocking(true)?;
666 let listener = TcpListener::from_std(listener)?;
667 let local_addr = listener.local_addr()?;
668 tcp_proxy_addrs.insert(port.name.clone(), local_addr);
669 Some(AddressedTcpListener {
670 listener,
671 local_addr,
672 })
673 }
674 };
675 ports.push(ServiceProcessPort {
676 name: port.name.clone(),
677 listen_addr: listen_addrs[&port.name].clone(),
678 tcp_proxy_listener,
679 });
680 }
681
682 let mut args = args(ServiceAssignments {
683 listen_addrs,
684 peer_addrs: &peer_addrs,
685 });
686 args.push(format!("--process={i}"));
687 if disk {
688 if let Some(scratch) = &scratch_dir {
689 args.push(format!("--scratch-directory={}", scratch.display()));
690 } else {
691 panic!(
692 "internal error: service requested disk but no scratch directory was configured"
693 );
694 }
695 }
696
697 let handle = mz_ore::task::spawn(
699 || format!("process-orchestrator:{full_id}-{i}"),
700 self.supervise_service_process(ServiceProcessConfig {
701 id: id.to_string(),
702 run_dir: run_dir.clone(),
703 i,
704 image: image.clone(),
705 args,
706 command_wrapper,
707 ports,
708 memory_limit,
709 cpu_limit,
710 launch_spec: self.config.launch_spec,
711 }),
712 );
713
714 process_states.push(ProcessState {
715 _handle: handle.abort_on_drop(),
716 status: ProcessStatus::NotReady,
717 status_time: Utc::now(),
718 restart_count: 0,
719 labels: labels.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
720 tcp_proxy_addrs,
721 });
722 }
723
724 services.insert(id, process_states);
727 }
728
729 self.maybe_write_prometheus_service_discovery_file().await;
730
731 Ok(())
732 }
733
734 async fn drop_service(&self, id: &str) -> Result<(), anyhow::Error> {
735 let full_id = self.config.full_id(id);
736 let run_dir = self.config.service_run_dir(id);
737 let scratch_dir = self.config.service_scratch_dir(id);
738
739 let supervised = {
743 let mut supervisors = self.services.lock().expect("lock poisoned");
744 supervisors.remove(id).is_some()
745 };
746
747 if let Ok(mut entries) = fs::read_dir(&run_dir).await {
752 while let Some(entry) = entries.next_entry().await? {
753 let path = entry.path();
754 if path.extension() == Some(OsStr::new("pid")) {
755 let mut system = System::new();
756 let Some(process) = find_process_from_pid_file(&mut system, &path).await else {
757 if !supervised {
764 warn!(
765 "not terminating orphaned process for {full_id}: no live process \
766 matches {}",
767 path.display()
768 );
769 }
770 continue;
771 };
772 let pid = process.pid();
773 info!("terminating orphaned process for {full_id} with PID {pid}");
774 process.kill();
775 }
776 }
777 }
778
779 if let Err(e) = remove_dir_all(run_dir).await {
781 if e.kind() != io::ErrorKind::NotFound {
782 warn!(
783 "error cleaning up run directory for {full_id}: {}",
784 e.display_with_causes()
785 );
786 }
787 }
788 if let Err(e) = remove_dir_all(scratch_dir).await {
789 if e.kind() != io::ErrorKind::NotFound {
790 warn!(
791 "error cleaning up scratch directory for {full_id}: {}",
792 e.display_with_causes()
793 );
794 }
795 }
796
797 self.maybe_write_prometheus_service_discovery_file().await;
798 Ok(())
799 }
800
801 async fn list_services(&self) -> Result<Vec<String>, anyhow::Error> {
802 let mut services = vec![];
803 let namespace_prefix = format!("{}-", self.config.namespace);
804 let mut entries = fs::read_dir(&self.config.metadata_dir).await?;
805 while let Some(entry) = entries.next_entry().await? {
806 let filename = entry
807 .file_name()
808 .into_string()
809 .map_err(|_| anyhow!("unable to convert filename to string"))?;
810 if let Some(id) = filename.strip_prefix(&namespace_prefix) {
811 services.push(id.to_string());
812 }
813 }
814 Ok(services)
815 }
816
817 fn supervise_service_process(
818 &self,
819 ServiceProcessConfig {
820 id,
821 run_dir,
822 i,
823 image,
824 args,
825 command_wrapper,
826 ports,
827 memory_limit,
828 cpu_limit,
829 launch_spec,
830 }: ServiceProcessConfig,
831 ) -> impl Future<Output = ()> + use<> {
832 let suppress_output = self.config.suppress_output;
833 let propagate_crashes = self.config.propagate_crashes;
834 let image = self.config.image_dir.join(image);
835 let pid_file = run_dir.join(format!("{i}.pid"));
836 let full_id = self.config.full_id(&id);
837
838 let state_updater = ProcessStateUpdater {
839 namespace: self.config.namespace.clone(),
840 id,
841 i,
842 services: Arc::clone(&self.services),
843 service_event_tx: self.service_event_tx.clone(),
844 };
845
846 async move {
847 #[allow(clippy::collection_is_never_read)]
849 let mut proxy_handles = vec![];
850 for port in ports {
851 if let Some(tcp_listener) = port.tcp_proxy_listener {
852 info!(
853 "{full_id}-{i}: {} tcp proxy listening on {}",
854 port.name, tcp_listener.local_addr,
855 );
856 let uds_path = port.listen_addr;
857 let handle = mz_ore::task::spawn(
858 || format!("{full_id}-{i}-proxy-{}", port.name),
859 tcp_proxy(TcpProxyConfig {
860 name: format!("{full_id}-{i}-{}", port.name),
861 tcp_listener,
862 uds_path: uds_path.clone(),
863 }),
864 );
865 proxy_handles.push(handle.abort_on_drop());
866 }
867 }
868
869 supervise_existing_process(&state_updater, &pid_file).await;
870
871 loop {
872 let mut cmd = launch_spec.refine_command(
873 &image,
874 &args,
875 &command_wrapper,
876 memory_limit.as_ref(),
877 cpu_limit.as_ref(),
878 );
879 info!(
880 "launching {full_id}-{i} via {} {}...",
881 cmd.as_std().get_program().to_string_lossy(),
882 cmd.as_std()
883 .get_args()
884 .map(|arg| arg.to_string_lossy())
885 .join(" ")
886 );
887 if suppress_output {
888 cmd.stdout(Stdio::null());
889 cmd.stderr(Stdio::null());
890 }
891 match spawn_process(&state_updater, cmd, &pid_file, !command_wrapper.is_empty())
892 .await
893 {
894 Ok(status) => {
895 assert!(
896 !(propagate_crashes && did_process_crash(status)),
897 "{full_id}-{i} crashed; aborting because propagate_crashes is enabled"
898 );
899 error!("{full_id}-{i} exited: {:?}; relaunching in 5s", status);
900 }
901 Err(e) => {
902 error!("{full_id}-{i} failed to spawn: {}; relaunching in 5s", e);
903 }
904 };
905 state_updater.update_state(ProcessStatus::NotReady);
906 time::sleep(Duration::from_secs(5)).await;
907 }
908 }
909 }
910
911 async fn maybe_write_prometheus_service_discovery_file(&self) {
912 #[derive(Serialize)]
913 struct StaticConfig {
914 labels: BTreeMap<String, String>,
915 targets: Vec<String>,
916 }
917
918 let Some(tcp_proxy) = &self.config.tcp_proxy else {
919 return;
920 };
921 let Some(dir) = &tcp_proxy.prometheus_service_discovery_dir else {
922 return;
923 };
924
925 let mut static_configs = vec![];
926 {
927 let services = self.services.lock().expect("lock poisoned");
928 for (id, states) in &*services {
929 for (i, state) in states.iter().enumerate() {
930 for (name, addr) in &state.tcp_proxy_addrs {
931 let mut labels = btreemap! {
932 "mz_orchestrator_namespace".into() => self.config.namespace.clone(),
933 "mz_orchestrator_service_id".into() => id.clone(),
934 "mz_orchestrator_port".into() => name.clone(),
935 "mz_orchestrator_ordinal".into() => i.to_string(),
936 };
937 for (k, v) in &state.labels {
938 let k = format!("mz_orchestrator_{}", k.replace('-', "_"));
939 labels.insert(k, v.clone());
940 }
941 static_configs.push(StaticConfig {
942 labels,
943 targets: vec![addr.to_string()],
944 })
945 }
946 }
947 }
948 }
949
950 let path = dir.join(Path::new(&self.config.namespace).with_extension("json"));
951 let contents = serde_json::to_vec_pretty(&static_configs).expect("valid json");
952 if let Err(e) = fs::write(&path, &contents).await {
953 warn!(
954 "{}: failed to write prometheus service discovery file: {}",
955 self.config.namespace,
956 e.display_with_causes()
957 );
958 }
959 }
960}
961
962struct ServiceProcessConfig {
963 id: String,
964 run_dir: PathBuf,
965 i: usize,
966 image: String,
967 args: Vec<String>,
968 command_wrapper: Vec<String>,
969 ports: Vec<ServiceProcessPort>,
970 memory_limit: Option<MemoryLimit>,
971 cpu_limit: Option<CpuLimit>,
972 launch_spec: LaunchSpec,
973}
974
975struct ServiceProcessPort {
976 name: String,
977 listen_addr: String,
978 tcp_proxy_listener: Option<AddressedTcpListener>,
979}
980
981async fn supervise_existing_process(state_updater: &ProcessStateUpdater, pid_file: &Path) {
983 let name = format!(
984 "{}-{}-{}",
985 state_updater.namespace, state_updater.id, state_updater.i
986 );
987
988 let mut system = System::new();
989 let Some(process) = find_process_from_pid_file(&mut system, pid_file).await else {
990 return;
991 };
992 let pid = process.pid();
993 let start_time = process.start_time();
994
995 info!(%pid, "discovered existing process for {name}");
996 state_updater.update_state(ProcessStatus::Ready { pid });
997
998 let need_kill = AtomicBool::new(true);
1000 defer! {
1001 state_updater.update_state(ProcessStatus::NotReady);
1002 if need_kill.load(Ordering::SeqCst) {
1003 info!(%pid, "terminating existing process for {name}");
1004 process.kill();
1005 }
1006 }
1007
1008 let mut system = System::new();
1011 loop {
1012 let refreshed = system.refresh_processes_specifics(
1013 ProcessesToUpdate::Some(&[pid]),
1014 true,
1015 ProcessRefreshKind::nothing(),
1016 );
1017 if refreshed == 0 {
1018 break;
1019 }
1020 match system.process(pid) {
1021 Some(p) if p.start_time() == start_time => {}
1022 _ => break,
1023 }
1024 time::sleep(Duration::from_secs(5)).await;
1025 }
1026
1027 warn!(%pid, "process for {name} has crashed; will reboot");
1030 need_kill.store(false, Ordering::SeqCst)
1031}
1032
1033fn interpolate_command(
1034 command_part: &str,
1035 full_id: &str,
1036 ports: &BTreeMap<String, String>,
1037) -> String {
1038 let mut command_part = command_part.replace("%N", full_id);
1039 for (endpoint, port) in ports {
1040 command_part = command_part.replace(&format!("%P:{endpoint}"), port);
1041 }
1042 command_part
1043}
1044
1045async fn spawn_process(
1046 state_updater: &ProcessStateUpdater,
1047 mut cmd: Command,
1048 pid_file: &Path,
1049 send_sigterm: bool,
1050) -> Result<ExitStatus, anyhow::Error> {
1051 struct KillOnDropChild(Child, bool);
1052
1053 impl Drop for KillOnDropChild {
1054 fn drop(&mut self) {
1055 if let (Some(pid), true) = (self.0.id().and_then(|id| i32::try_from(id).ok()), self.1) {
1056 let _ = nix::sys::signal::kill(
1057 nix::unistd::Pid::from_raw(pid),
1058 nix::sys::signal::Signal::SIGTERM,
1059 );
1060 tokio::task::block_in_place(|| std::thread::sleep(Duration::from_millis(500)));
1062 }
1063 let _ = self.0.start_kill();
1064 }
1065 }
1066
1067 let mut child = KillOnDropChild(cmd.spawn()?, send_sigterm);
1068
1069 let pid = Pid::from_u32(child.0.id().unwrap());
1078 write_pid_file(pid_file, pid).await?;
1079 state_updater.update_state(ProcessStatus::Ready { pid });
1080 Ok(child.0.wait().await?)
1081}
1082
1083fn did_process_crash(status: ExitStatus) -> bool {
1084 status.signal().is_some_and(|s| {
1088 matches!(
1089 Signal::try_from(s),
1090 Ok(Signal::SIGABRT
1091 | Signal::SIGBUS
1092 | Signal::SIGSEGV
1093 | Signal::SIGTRAP
1094 | Signal::SIGILL)
1095 )
1096 })
1097}
1098
1099async fn write_pid_file(pid_file: &Path, pid: Pid) -> Result<(), anyhow::Error> {
1100 let mut system = System::new();
1101 system.refresh_processes_specifics(
1102 ProcessesToUpdate::Some(&[pid]),
1103 true,
1104 ProcessRefreshKind::nothing(),
1105 );
1106 let start_time = system.process(pid).map_or(0, |p| p.start_time());
1107 fs::write(pid_file, format!("{pid}\n{start_time}\n")).await?;
1108 Ok(())
1109}
1110
1111async fn find_process_from_pid_file<'a>(
1112 system: &'a mut System,
1113 pid_file: &Path,
1114) -> Option<&'a Process> {
1115 let Ok(contents) = fs::read_to_string(pid_file).await else {
1116 return None;
1117 };
1118 let lines = contents.trim().split('\n').collect::<Vec<_>>();
1119 let [pid, start_time] = lines.as_slice() else {
1120 return None;
1121 };
1122 let Ok(pid) = Pid::from_str(pid) else {
1123 return None;
1124 };
1125 let Ok(start_time) = u64::from_str(start_time) else {
1126 return None;
1127 };
1128 system.refresh_processes_specifics(
1129 ProcessesToUpdate::Some(&[pid]),
1130 true,
1131 ProcessRefreshKind::nothing(),
1132 );
1133 let process = system.process(pid)?;
1134 if process.start_time() != start_time {
1137 return None;
1138 }
1139 Some(process)
1140}
1141
1142struct TcpProxyConfig {
1143 name: String,
1144 tcp_listener: AddressedTcpListener,
1145 uds_path: String,
1146}
1147
1148async fn tcp_proxy(
1149 TcpProxyConfig {
1150 name,
1151 tcp_listener,
1152 uds_path,
1153 }: TcpProxyConfig,
1154) {
1155 let mut conns = FuturesUnordered::new();
1156 loop {
1157 select! {
1158 res = tcp_listener.listener.accept() => {
1159 debug!("{name}: accepting tcp proxy connection");
1160 let uds_path = uds_path.clone();
1161 conns.push(Box::pin(async move {
1162 let (mut tcp_conn, _) = res.context("accepting tcp connection")?;
1163 let mut uds_conn = UnixStream::connect(uds_path)
1164 .await
1165 .context("making uds connection")?;
1166 io::copy_bidirectional(&mut tcp_conn, &mut uds_conn)
1167 .await
1168 .context("proxying")
1169 }));
1170 }
1171 Some(result) = conns.next() => if let Err(e) = result {
1172 warn!("{name}: tcp proxy connection failed: {}", e.display_with_causes());
1173 }
1174 }
1175 }
1176}
1177
1178struct ProcessStateUpdater {
1179 namespace: String,
1180 id: String,
1181 i: usize,
1182 services: Arc<Mutex<BTreeMap<String, Vec<ProcessState>>>>,
1183 service_event_tx: broadcast::Sender<ServiceEvent>,
1184}
1185
1186impl ProcessStateUpdater {
1187 fn update_state(&self, status: ProcessStatus) {
1188 let mut services = self.services.lock().expect("lock poisoned");
1189 let Some(process_states) = services.get_mut(&self.id) else {
1190 return;
1191 };
1192 let Some(process_state) = process_states.get_mut(self.i) else {
1193 return;
1194 };
1195 let status_time = Utc::now();
1196 if matches!(status, ProcessStatus::NotReady) {
1202 process_state.restart_count += 1;
1203 }
1204 process_state.status = status;
1205 process_state.status_time = status_time;
1206 let _ = self.service_event_tx.send(ServiceEvent {
1207 service_id: self.id.to_string(),
1208 process_id: u64::cast_from(self.i),
1209 status: status.into(),
1210 restart_count: process_state.restart_count,
1211 time: status_time,
1212 });
1213 }
1214}
1215
1216#[derive(Debug)]
1217struct ProcessState {
1218 _handle: AbortOnDropHandle<()>,
1219 status: ProcessStatus,
1220 status_time: DateTime<Utc>,
1221 restart_count: u64,
1224 labels: BTreeMap<String, String>,
1225 tcp_proxy_addrs: BTreeMap<String, SocketAddr>,
1226}
1227
1228impl ProcessState {
1229 fn pid(&self) -> Option<Pid> {
1230 match &self.status {
1231 ProcessStatus::NotReady => None,
1232 ProcessStatus::Ready { pid } => Some(*pid),
1233 }
1234 }
1235}
1236
1237#[derive(Debug, Clone, Copy)]
1238enum ProcessStatus {
1239 NotReady,
1240 Ready { pid: Pid },
1241}
1242
1243impl From<ProcessStatus> for ServiceStatus {
1244 fn from(status: ProcessStatus) -> ServiceStatus {
1245 match status {
1246 ProcessStatus::NotReady => ServiceStatus::Offline(None),
1247 ProcessStatus::Ready { .. } => ServiceStatus::Online,
1248 }
1249 }
1250}
1251
1252fn socket_path(run_dir: &Path, port: &str, process: u16) -> String {
1253 let desired = run_dir
1254 .join(format!("{port}-{process}"))
1255 .to_string_lossy()
1256 .into_owned();
1257 if UnixSocketAddr::from_pathname(&desired).is_err() {
1258 env::temp_dir()
1261 .join(hex::encode(Sha1::digest(desired)))
1262 .display()
1263 .to_string()
1264 } else {
1265 desired
1266 }
1267}
1268
1269struct AddressedTcpListener {
1270 listener: TcpListener,
1271 local_addr: SocketAddr,
1272}
1273
1274#[derive(Debug)]
1275struct ProcessService {
1276 run_dir: PathBuf,
1277 scale: NonZero<u16>,
1278}
1279
1280impl Service for ProcessService {
1281 fn addresses(&self, port: &str) -> Vec<String> {
1282 (0..self.scale.get())
1283 .map(|i| socket_path(&self.run_dir, port, i))
1284 .collect()
1285 }
1286}