diff --git a/README.md b/README.md index 67e4d18..04bb9d7 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,10 @@ beamctl start \ -- /usr/local/bin/my-app --listen "0.0.0.0:8080" ``` +### Autostart + +Services can also be autostarted by placing the executable at `/etc/beam-init/svc//run` + ## Testing > [!NOTE] diff --git a/beam-init/src/main.rs b/beam-init/src/main.rs index 876ea57..1663722 100644 --- a/beam-init/src/main.rs +++ b/beam-init/src/main.rs @@ -24,6 +24,8 @@ mod signal_stream; static DEBUG_LOGS: LazyLock = LazyLock::new(|| env::var("BEAM_INIT_ENABLE_DEBUG_LOGS").as_deref() == Ok("1")); +const AUTOSTART_DIR: &str = "/etc/beam-init/svc"; + enum Event { Command { command: api_impl::Command, @@ -71,6 +73,14 @@ async fn main() { let old_sigmask = signal_stream::init(&[SIGCHLD], tx_event.clone()) .expect("failed to initialize the signal stream"); + let autostart_svcs = match services::get_autostart_events(AUTOSTART_DIR) { + Ok(svcs) => svcs, + Err(e) => { + eprintln!("failed to get autostart services: {e}"); + Vec::new() + } + }; + let fdstore = if cfg!(feature = "unstable-pty") { fdstore::FdStore::bind_socket().expect("failed to bind fdstore socket") } else { @@ -80,6 +90,10 @@ async fn main() { // Listen for API commands api_impl::bind_api_socket(tx_event.clone()).expect("failed to bind api socket"); + tokio::task::spawn(services::autostart_services( + tx_event.clone(), + autostart_svcs, + )); let mut service_manager = ServiceManager::new(old_sigmask, tx_event, fdstore); loop { match rx_event diff --git a/beam-init/src/services.rs b/beam-init/src/services.rs index 5ebaed6..c9ccb11 100644 --- a/beam-init/src/services.rs +++ b/beam-init/src/services.rs @@ -3,6 +3,7 @@ use std::collections::btree_map::Entry; use std::ffi::{CString, NulError, c_int, c_uint}; use std::io::{self, Read, Write}; use std::os::fd::{AsRawFd, OwnedFd}; +use std::path::Path; use std::pin::pin; use std::process::ExitStatus; use std::ptr; @@ -16,7 +17,7 @@ use tokio::sync::mpsc; use tokio::task::AbortHandle; use tokio_stream::StreamExt; -use crate::api_impl::Credentials; +use crate::api_impl::{Command, Credentials}; use crate::fdstore::{FdStore, StoredFd}; use crate::logs::{AsyncRingBuffer, Logs}; use crate::signal_stream::OldSigmask; @@ -24,7 +25,7 @@ use crate::{DEBUG_LOGS, Event}; use beam_init::system::fork::unsafe_fork; use beam_init::system::pty::{Pty, PtyClient}; use beam_init::system::{_exit, cerr, kill_process_group, waitpid}; -use beam_init_api::Probe; +use beam_init_api::{CreateService, Probe}; pub struct ServiceManager { old_sigmask: OldSigmask, @@ -772,3 +773,156 @@ fn spawn_service(old_sigmask: OldSigmask, config: &ServiceConfig, sink: Sink) -> Err(err) => Err(err), } } + +pub(crate) async fn autostart_services(tx: mpsc::Sender, services: Vec) { + for svc in services { + if tx.send(svc).await.is_err() { + return; + } + } +} + +pub(crate) fn get_autostart_events(dir: impl AsRef) -> Result, io::Error> { + let dir = std::fs::read_dir(dir)?; + Ok(dir + .filter_map(|entry| { + let entry = entry.ok()?; + let svc_dir = entry.path(); + let run = svc_dir.join("run"); + + if !run.is_file() { + eprintln!("run not found in {}", svc_dir.display()); + return None; + } + + let name = svc_dir.file_name()?.to_str()?.to_string(); + let run = run.to_str()?.to_string(); + Some((name, run)) + }) + .map(|(name, cmd)| Event::Command { + command: Command::CreateService { + name, + service: CreateService { + cmd, + args: Vec::new(), + liveness: None, + pty: false, + }, + }, + tx: tokio::sync::oneshot::channel().0, + credentials: Credentials::root(), + }) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::io; + use std::path::{Path, PathBuf}; + + struct TestDir(PathBuf); + + impl TestDir { + fn new(name: &str) -> io::Result { + let path = std::env::temp_dir().join(format!("beam-init-test-{name}")); + let _ = fs::remove_dir_all(&path); + + fs::create_dir(&path)?; + Ok(Self(path)) + } + + fn path(&self) -> &Path { + &self.0 + } + + fn create_file(&self, relative_path: &str) -> io::Result { + let path = self.0.join(relative_path); + let parent = path + .parent() + .expect("relative file path should have a parent"); + + fs::create_dir_all(parent)?; + fs::File::create_new(&path)?; + Ok(path) + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn discovers_service_run_files() -> io::Result<()> { + let temp = TestDir::new("discovers-service-run-files")?; + + let regular_run = temp.create_file("regular/run")?; + let symlink_run = temp.path().join("symlink/run"); + + let target = temp.create_file("actual-run")?; + fs::create_dir(temp.path().join("symlink"))?; + std::os::unix::fs::symlink(target, &symlink_run)?; + + temp.create_file("ignored/not-run")?; + + let mut actual: Vec = get_autostart_events(temp.path())? + .into_iter() + .map(CreateServiceEventParts::try_from) + .collect::>()?; + + let mut expected = vec![ + CreateServiceEventParts { + name: "regular".to_owned(), + cmd: regular_run.to_string_lossy().into_owned(), + }, + CreateServiceEventParts { + name: "symlink".to_owned(), + cmd: symlink_run.to_string_lossy().into_owned(), + }, + ]; + + actual.sort(); + expected.sort(); + + assert_eq!(actual, expected); + + Ok(()) + } + + #[derive(Debug, PartialOrd, Ord, PartialEq, Eq)] + struct CreateServiceEventParts { + name: String, + cmd: String, + } + impl TryFrom for CreateServiceEventParts { + type Error = io::Error; + + fn try_from(value: Event) -> Result { + let Event::Command { + command: + Command::CreateService { + name, + service: + CreateService { + cmd, + args: _, + liveness: _, + pty: _, + }, + }, + tx: _, + credentials: _, + } = value + else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "only create service events supported", + )); + }; + Ok(Self { name, cmd }) + } + } +} diff --git a/tests/fixtures/autostart/run b/tests/fixtures/autostart/run new file mode 100755 index 0000000..5cc2dcc --- /dev/null +++ b/tests/fixtures/autostart/run @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +sleep infinity diff --git a/tests/src/basic.rs b/tests/src/basic.rs index 194cdb7..1e4b60f 100644 --- a/tests/src/basic.rs +++ b/tests/src/basic.rs @@ -130,3 +130,13 @@ fn pty_attach_race() { .run("pty_attach_race.py") .wait(); } + +#[test] +fn autostart_service() { + Image::build("test.Dockerfile") + .run_with_mounts( + "autostart_service.py", + &[("fixtures/autostart", "/etc/beam-init/svc/autostart")], + ) + .wait(); +} diff --git a/tests/src/docker.rs b/tests/src/docker.rs index 60664af..5e72eec 100644 --- a/tests/src/docker.rs +++ b/tests/src/docker.rs @@ -49,7 +49,7 @@ impl Image { image } - pub fn run(&self, script: &str) -> Container { + pub fn run_with_mounts(&self, script: &str, mounts: &[(&str, &str)]) -> Container { let script_path = PathBuf::from(MANIFEST_DIR).join("tests").join(script); let script_path = script_path.to_str().unwrap(); @@ -58,6 +58,10 @@ impl Image { cmd.arg("run").arg("-i").arg("--rm"); cmd.arg("-v") .arg(format!("{script_path}:/mnt/script.py:ro")); + for (src, dst) in mounts { + let src = PathBuf::from(MANIFEST_DIR).join(src); + cmd.arg("-v").arg(format!("{}:{dst}:ro", src.display())); + } cmd.arg(&self.tag).arg("python3").arg("/mnt/script.py"); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); @@ -65,6 +69,10 @@ impl Image { child: cmd.spawn().unwrap(), } } + + pub fn run(&self, script: &str) -> Container { + self.run_with_mounts(script, &[]) + } } pub struct Container { diff --git a/tests/tests/autostart_service.py b/tests/tests/autostart_service.py new file mode 100644 index 0000000..768a0a4 --- /dev/null +++ b/tests/tests/autostart_service.py @@ -0,0 +1,6 @@ +import re +import subprocess + +output = subprocess.check_output(["beamctl", "show", "autostart"]) +assert re.fullmatch(rb"autostart \(running PID=\d+\): /etc/beam-init/svc/autostart/run\n", output), output +