Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<service-name>/run`

## Testing

> [!NOTE]
Expand Down
14 changes: 14 additions & 0 deletions beam-init/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ mod signal_stream;
static DEBUG_LOGS: LazyLock<bool> =
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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
158 changes: 156 additions & 2 deletions beam-init/src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,15 +17,15 @@ 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;
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,
Expand Down Expand Up @@ -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<Event>, services: Vec<Event>) {
for svc in services {
if tx.send(svc).await.is_err() {
return;
}
}
}

pub(crate) fn get_autostart_events(dir: impl AsRef<Path>) -> Result<Vec<Event>, 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<Self> {
let path = std::env::temp_dir().join(format!("beam-init-test-{name}"));
let _ = fs::remove_dir_all(&path);

fs::create_dir(&path)?;
Comment thread
rcanderson23 marked this conversation as resolved.
Ok(Self(path))
}

fn path(&self) -> &Path {
&self.0
}

fn create_file(&self, relative_path: &str) -> io::Result<PathBuf> {
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<CreateServiceEventParts> = get_autostart_events(temp.path())?
.into_iter()
.map(CreateServiceEventParts::try_from)
.collect::<Result<_, _>>()?;

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<Event> for CreateServiceEventParts {
type Error = io::Error;

fn try_from(value: Event) -> Result<Self, Self::Error> {
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 })
}
}
}
2 changes: 2 additions & 0 deletions tests/fixtures/autostart/run
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
sleep infinity
10 changes: 10 additions & 0 deletions tests/src/basic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
10 changes: 9 additions & 1 deletion tests/src/docker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -58,13 +58,21 @@ 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());

Container {
child: cmd.spawn().unwrap(),
}
}

pub fn run(&self, script: &str) -> Container {
self.run_with_mounts(script, &[])
}
}

pub struct Container {
Expand Down
6 changes: 6 additions & 0 deletions tests/tests/autostart_service.py
Original file line number Diff line number Diff line change
@@ -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