Skip to main content

mz_orchestrator_process/
lib.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use 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/// Configures a [`ProcessOrchestrator`].
61#[derive(Debug, Clone)]
62pub struct ProcessOrchestratorConfig {
63    /// The directory in which the orchestrator should look for executable
64    /// images.
65    pub image_dir: PathBuf,
66    /// Whether to supress output from spawned subprocesses.
67    pub suppress_output: bool,
68    /// The ID of the environment under orchestration.
69    pub environment_id: String,
70    /// The directory in which to store secrets.
71    pub secrets_dir: PathBuf,
72    /// A command to wrap the child command invocation
73    pub command_wrapper: Vec<String>,
74    /// Whether to crash this process if a child process crashes.
75    pub propagate_crashes: bool,
76    /// TCP proxy configuration.
77    ///
78    /// When enabled, for each named port of each created service, the process
79    /// orchestrator will bind a TCP listener that proxies incoming connections
80    /// to the underlying Unix domain socket. Each bound TCP address will be
81    /// emitted as a tracing event.
82    ///
83    /// The primary use is live debugging the running child services via tools
84    /// that do not support Unix domain sockets (e.g., Prometheus, web
85    /// browsers).
86    pub tcp_proxy: Option<ProcessOrchestratorTcpProxyConfig>,
87    /// A scratch directory that orchestrated processes can use for ephemeral storage.
88    pub scratch_directory: PathBuf,
89}
90
91/// Configures the TCP proxy for a [`ProcessOrchestrator`].
92///
93/// See [`ProcessOrchestratorConfig::tcp_proxy`].
94#[derive(Debug, Clone)]
95pub struct ProcessOrchestratorTcpProxyConfig {
96    /// The IP address on which to bind TCP listeners.
97    pub listen_addr: IpAddr,
98    /// A directory in which to write Prometheus scrape targets, for use with
99    /// Prometheus's file-based service discovery.
100    ///
101    /// Each [`NamespacedOrchestrator`] will maintain a single JSON file into
102    /// the directory named `NAMESPACE.json` containing the scrape targets for
103    /// all extant services. The scrape targets will use the TCP proxy address,
104    /// as Prometheus does not support scraping over Unix domain sockets.
105    ///
106    /// See also: <https://prometheus.io/docs/guides/file-sd/>
107    pub prometheus_service_discovery_dir: Option<PathBuf>,
108}
109
110/// An orchestrator backed by processes on the local machine.
111///
112/// **This orchestrator is for development only.** Due to limitations in the
113/// Unix process API, it does not exactly conform to the documented semantics
114/// of `Orchestrator`.
115#[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    /// Directly execute the provided binary
132    Direct,
133    /// Use Systemd to start the binary
134    Systemd,
135}
136
137impl LaunchSpec {
138    fn determine_implementation() -> Result<Self, anyhow::Error> {
139        // According to https://www.freedesktop.org/software/systemd/man/latest/sd_booted.html
140        // checking for `/run/systemd/system/` is the canonical way to determine if the system
141        // was booted up with systemd.
142        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                    // TODO: We could set `-p MemorySwapMax=0` here to disable regular swap.
174                }
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    /// Creates a new process orchestrator from the provided configuration.
192    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/// Configuration for a [`NamespacedProcessOrchestrator`].
283#[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        // Enable disk if the size does not disable it.
339        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
427/// Commands sent from a [`NamespacedProcessOrchestrator`] to its
428/// [`OrchestratorWorker`].
429///
430/// Commands for which the caller expects a result include a `result_tx` on which the
431/// [`OrchestratorWorker`] will deliver the result.
432enum 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
452/// Describes the desired state of a process.
453struct EnsureServiceConfig {
454    /// An opaque identifier for the executable or container image to run.
455    ///
456    /// Often names a container on Docker Hub or a path on the local machine.
457    pub image: String,
458    /// A function that generates the arguments for each process of the service
459    /// given the assigned listen addresses for each named port.
460    pub args: Box<dyn Fn(ServiceAssignments) -> Vec<String> + Send + Sync>,
461    /// Ports to expose.
462    pub ports: Vec<ServicePort>,
463    /// An optional limit on the memory that the service can use.
464    pub memory_limit: Option<MemoryLimit>,
465    /// An optional limit on the CPU that the service can use.
466    pub cpu_limit: Option<CpuLimit>,
467    /// The number of copies of this service to run.
468    pub scale: NonZero<u16>,
469    /// Arbitrary key–value pairs to attach to the service in the orchestrator
470    /// backend.
471    ///
472    /// The orchestrator backend may apply a prefix to the key if appropriate.
473    pub labels: BTreeMap<String, String>,
474    /// Whether scratch disk space should be allocated for the service.
475    pub disk: bool,
476}
477
478/// A task executing blocking work for a [`NamespacedProcessOrchestrator`] in the background.
479///
480/// This type exists to enable making [`NamespacedProcessOrchestrator::ensure_service`] and
481/// [`NamespacedProcessOrchestrator::drop_service`] non-blocking, allowing invocation of these
482/// methods in latency-sensitive contexts.
483///
484/// Note that, apart from `ensure_service` and `drop_service`, this worker also handles blocking
485/// orchestrator calls that query service state (such as `list_services`). These need to be
486/// sequenced through the worker loop to ensure they linearize as expected. For example, we want to
487/// ensure that a `list_services` result contains exactly those services that were previously
488/// created with `ensure_service` and not yet dropped with `drop_service`.
489struct 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                            // Justification for `unwrap`:
555                            //
556                            // `u64::try_cast_from(f: f64)`
557                            // will always succeed if 0 <= f < 2^64.
558                            // Since the max value of `process.cpu_usage()` is
559                            // 100.0 * num_of_cores, this will be true whenever there
560                            // are less than 2^64 / 10^9 logical cores, or about
561                            // 18 billion.
562                            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                // Process orchestrator does not support the remaining fields right now.
576                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        // The service might already exist. If it has the same config as requested (currently we
616        // check only the scale), we have nothing to do. Otherwise we need to drop and recreate it.
617        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        // Create sockets for all processes in the service.
628        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            // Create the state for new processes.
644            let mut process_states = vec![];
645            for i in 0..usize::cast_from(scale) {
646                let listen_addrs = &peer_addrs[i];
647
648                // Fill out placeholders in the command wrapper for this process.
649                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                // Allocate listeners for each TCP proxy, if requested.
657                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                // Launch supervisor process.
698                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            // Update the in-memory process state. We do this after we've created
725            // all process states to avoid partially updating our in-memory state.
726            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        // Drop the supervisor for the service, if it exists. If this service
740        // was under supervision, this will kill all processes associated with
741        // it.
742        let supervised = {
743            let mut supervisors = self.services.lock().expect("lock poisoned");
744            supervisors.remove(id).is_some()
745        };
746
747        // If the service was orphaned by a prior incarnation of the
748        // orchestrator, it won't have been under supervision and therefore will
749        // still be running. So kill any process that we have state for in the
750        // run directory.
751        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                        // Dropping the supervisor may already have killed a
758                        // supervised process. An orphan that cannot be found
759                        // either exited or runs in a PID namespace this
760                        // orchestrator cannot see, e.g., another container
761                        // sharing the data directory. Its run directory is
762                        // deleted below, so warn while there is a trace.
763                        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        // Clean up the on-disk state of the service.
780        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            // Holds AbortOnDropHandles to keep proxy tasks alive.
848            #[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
981/// Supervises an existing process, if it exists.
982async 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    // Kill the process if the future is dropped.
999    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    // Periodically check if the process has terminated. Verify start_time
1009    // on each iteration to detect PID reuse.
1010    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    // The process has crashed. Exit the function without attempting to
1028    // kill it.
1029    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                // Give the process a bit of time to react to the signal
1061                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    // Immediately write out a file containing the PID of the child process and
1070    // its start time. We'll use this state to rediscover our children if we
1071    // crash and restart. There's a very small window where we can crash after
1072    // having spawned the child but before writing this file, in which case we
1073    // might orphan the process. We accept this risk, though. It's hard to do
1074    // anything more robust given the Unix APIs available to us, and the
1075    // solution here is good enough given that the process orchestrator is only
1076    // used in development/testing.
1077    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    // Likely not exhaustive. Feel free to add additional tests for other
1085    // indications of a crashed child process, as those conditions are
1086    // discovered.
1087    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    // Checking the start time protects against killing an unrelated process due
1135    // to PID reuse.
1136    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        // Count each transition to NotReady as a restart. The process
1197        // orchestrator always relaunches a process that exits, so a death is a
1198        // restart. This is monotonic for a given `ProcessState` and changes on
1199        // every restart, which is what the 0dt caught-up check needs. It only
1200        // resets to zero if the whole service is dropped and recreated.
1201        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    /// Number of times this process has died and been relaunched. Monotonic for
1222    /// the lifetime of this `ProcessState`. See [`ProcessStateUpdater::update_state`].
1223    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        // Unix socket addresses have a very low maximum length of around 100
1259        // bytes on most platforms.
1260        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}