From 14f4c0273fb05c70052724d2bd87ee0563778872 Mon Sep 17 00:00:00 2001 From: Threated Date: Mon, 7 Sep 2026 15:31:43 +0200 Subject: [PATCH 1/8] native bridgehead --- Cargo.lock | 71 +++ Cargo.toml | 1 + Readme.md | 24 + src/bridgehead.rs | 35 -- src/config.rs | 90 ++-- src/install.rs | 425 ++++++++++++++++++ src/main.rs | 138 +++--- src/services/beam.rs | 16 +- src/services/datashield.rs | 3 +- src/services/mod.rs | 9 +- static/bootstrap.sh | 42 -- templates/bridgehead | 161 ------- tests/install.rs | 267 +++++++++++ .../eucaim-sql/configs@bridgehead.snap | 146 ------ .../eucaim-sql/configs@config.local.toml.snap | 2 + .../snapshots/eucaim/configs@bridgehead.snap | 146 ------ .../eucaim/configs@config.local.toml.snap | 2 + .../example.config/configs@bridgehead.snap | 148 ------ .../configs@config.local.toml.snap | 6 + .../snapshots/minimal/configs@bridgehead.snap | 119 ----- .../minimal/configs@config.local.toml.snap | 2 + .../snapshots/tuning/configs@bridgehead.snap | 146 ------ .../tuning/configs@config.local.toml.snap | 2 + .../volume-dir/configs@bridgehead.snap | 146 ------ .../volume-dir/configs@config.local.toml.snap | 2 + tests/test_configs.rs | 1 - 26 files changed, 947 insertions(+), 1203 deletions(-) delete mode 100644 src/bridgehead.rs create mode 100644 src/install.rs delete mode 100755 static/bootstrap.sh delete mode 100644 templates/bridgehead create mode 100644 tests/install.rs delete mode 100644 tests/snapshots/eucaim-sql/configs@bridgehead.snap delete mode 100644 tests/snapshots/eucaim/configs@bridgehead.snap delete mode 100644 tests/snapshots/example.config/configs@bridgehead.snap delete mode 100644 tests/snapshots/minimal/configs@bridgehead.snap delete mode 100644 tests/snapshots/tuning/configs@bridgehead.snap delete mode 100644 tests/snapshots/volume-dir/configs@bridgehead.snap delete mode 100644 tests/test_configs.rs diff --git a/Cargo.lock b/Cargo.lock index 3dad334..f995d7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -376,6 +376,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "duct" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61b9e2a29ff01e8bb526a571ad06c10ed72aae80d5999ed204f7971f99f19974" +dependencies = [ + "libc", + "os_pipe", + "shared_child", + "shared_thread", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -785,6 +797,16 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "pem" version = "3.0.5" @@ -929,6 +951,7 @@ dependencies = [ "askama", "bcrypt", "clap", + "duct", "insta", "libc", "rand", @@ -1036,12 +1059,60 @@ dependencies = [ "serde_core", ] +[[package]] +name = "shared_child" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "607549934f6cc26b89cfecfdc46fa90f1e5d1536a68349b0c3a4f9d1c0d37959" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.61.2", +] + +[[package]] +name = "shared_thread" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de1c6cdf07f3a4b1900680728ac1a12f72aa6424f37138f9253116461576e00f" + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "sigchld" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24f2b37f04360cd465089b87a9c3869c08220a2f3458463f0adf8badf5e77f2c" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "similar" version = "2.7.0" diff --git a/Cargo.toml b/Cargo.toml index 9be3229..4f9a8f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ rcgen = "0.14.8" serde_json = "1" libc = "0.2" solvent = "0.8.3" +duct = "1.1.2" [dev-dependencies] insta = { version = "1.47", features = ["filters", "glob", "toml"] } diff --git a/Readme.md b/Readme.md index 5268149..0e22f2a 100644 --- a/Readme.md +++ b/Readme.md @@ -4,6 +4,30 @@ A tool for generating docker compose files for the `Samply.Bridgehead` based on ## Installation +The native Rust CLI can install an existing configuration directly: + +```bash +sudo /path/to/rusthead --config /path/to/config.toml install +``` + +This requires Docker (including its `docker` group), Git, and sudo. It creates or reuses +the `bridgehead` account, configures a shared Git repository, and enables the systemd +service and daily update timer when Docker runs under systemd. Otherwise it runs an +update immediately. Re-running `install` preserves existing Beam private keys and +does not duplicate Git trust entries. Systemd uses the current Rust executable's +absolute path, so keep the binary at that location. To enroll pending networks, run +the same command with `enroll` instead of `install`. + +`config.local.toml` persists `beam_networks` required by the current configuration +and `enrolled_beam_networks` for which the enrollment command has succeeded. Both +`install` and `enroll` process only pending networks, saving each success separately +so interrupted enrollment can be retried. CSR approval remains a separate step. +Disabled networks retain their enrollment history. After changing `site_id`, clear +`enrolled_beam_networks` manually. Removing the private key resets that history. +Existing installations without enrollment +history treat all configured networks as pending, even when a private key exists. +To re-enroll a network explicitly, remove it from `enrolled_beam_networks` first. + The rustehead can either be installed by following the installation wizard run with: ```bash diff --git a/src/bridgehead.rs b/src/bridgehead.rs deleted file mode 100644 index e1cefff..0000000 --- a/src/bridgehead.rs +++ /dev/null @@ -1,35 +0,0 @@ -use std::{ - collections::BTreeSet, - fs::{self, Permissions}, -}; - -use askama::Template; - -use crate::{config::Config, services::BEAM_NETWORKS, utils::filters}; - -#[derive(Debug, Template)] -#[template(path = "bridgehead")] -pub struct Bridgehead { - beam_networks: BTreeSet, - conf: &'static Config, -} - -impl Bridgehead { - pub fn new(conf: &'static Config) -> Self { - Self { - beam_networks: BEAM_NETWORKS.with_borrow(|nets| nets.clone()), - conf, - } - } - - pub fn write(&self) -> anyhow::Result<()> { - let path = self.conf.path.join("bridgehead"); - fs::write(&path, self.render()?)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&path, Permissions::from_mode(0o755))?; - } - Ok(()) - } -} diff --git a/src/config.rs b/src/config.rs index 4dd95b1..fd1dcdf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,12 @@ -use std::{cell::RefCell, collections::BTreeMap, fs, ops::Deref, path::PathBuf}; +use std::{ + cell::RefCell, + collections::{BTreeMap, BTreeSet}, + fs, + ops::Deref, + path::PathBuf, +}; +use anyhow::Context; use rand::{RngExt, SeedableRng, rngs::StdRng}; use serde::{Deserialize, Serialize}; use url::{Host, Url}; @@ -56,15 +63,25 @@ impl Config { path.is_absolute(), "Path to config must be absolute unlike {path:?}" ); - let mut conf: Config = toml::from_str(&std::fs::read_to_string(path.join("config.toml"))?)?; - conf.path = path.clone(); - let local_conf = fs::read_to_string(conf.local_conf_path()) - .ok() - .and_then(|data| toml::from_str(&data).ok()) - .unwrap_or_else(|| { - eprintln!("Failed to read local config creating a new one"); - LocalConf::default() - }); + let file = if path.is_dir() { + path.join("config.toml") + } else { + path.clone() + }; + let mut conf: Config = toml::from_str(&std::fs::read_to_string(&file)?)?; + conf.path = file.parent().unwrap().to_path_buf(); + let mut local_conf: LocalConf = match fs::read_to_string(conf.local_conf_path()) { + Ok(data) => toml::from_str(&data).context("Failed to parse config.local.toml")?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => LocalConf::default(), + Err(error) => return Err(error).context("Failed to read config.local.toml"), + }; + if !conf + .path + .join(format!("pki/{}.priv.pem", conf.site_id)) + .try_exists()? + { + local_conf.enrolled_beam_networks.clear(); + } conf.local_conf = RefCell::new(local_conf); Ok(conf) } @@ -80,14 +97,20 @@ impl Config { } pub fn write_local_conf(&self) -> anyhow::Result<()> { - let conf_str = toml::to_string_pretty(self.local_conf.borrow().deref())?; - fs::write(self.local_conf_path(), conf_str)?; + self.save_local_conf()?; fs::write( self.path.join(".env"), self.local_conf.borrow().to_env()?.as_bytes(), )?; Ok(()) } + + /// Persist enrollment progress without rewriting the generated environment. + pub fn save_local_conf(&self) -> anyhow::Result<()> { + let conf_str = toml::to_string_pretty(self.local_conf.borrow().deref())?; + fs::write(self.local_conf_path(), conf_str)?; + Ok(()) + } } #[derive(Debug, Deserialize, Serialize)] @@ -97,6 +120,13 @@ pub struct LocalConf { seed: u32, pub oidc: Option>, pub basic_auth_users: Option>, + /// Networks required by the current service configuration. + #[serde(default)] + pub beam_networks: BTreeSet, + /// Networks for which the local enrollment command completed successfully. + /// Retained when a network is disabled so re-enabling it does not re-enroll it. + #[serde(default)] + pub enrolled_beam_networks: BTreeSet, #[serde(skip)] pub generated_secrets: BTreeMap, } @@ -111,12 +141,21 @@ impl Default for LocalConf { seed: generate_seed(), oidc: None, basic_auth_users: None, + beam_networks: Default::default(), + enrolled_beam_networks: Default::default(), generated_secrets: Default::default(), } } } impl LocalConf { + pub fn pending_beam_networks(&self) -> BTreeSet { + self.beam_networks + .difference(&self.enrolled_beam_networks) + .cloned() + .collect() + } + #[must_use] pub fn generate_secret(&mut self, name: &str) -> String { let name = format!( @@ -154,12 +193,7 @@ impl LocalConf { #[cfg(test)] mod tests { - use std::process::{Command, Stdio}; - - use crate::{ - modules, - services::{BEAM_NETWORKS, ServiceMap}, - }; + use crate::{modules, services::ServiceMap}; use super::*; @@ -179,7 +213,7 @@ mod tests { .iter() .for_each(|&m| services.install_module(m)); services.write_all().unwrap(); - let has_beam_networks = !BEAM_NETWORKS.take().is_empty(); + let has_beam_networks = !conf.local_conf.borrow().beam_networks.is_empty(); let has_services = services.len() > 0; let tmp_dir_path = temp_dir.path().display().to_string(); let filters = [(tmp_dir_path.as_str(), "[TMP_DIR]")]; @@ -222,20 +256,12 @@ mod tests { ) .unwrap(); } - fs::write( - temp_dir.path().join("docker-image.lock.yml"), - "services: {}\n", - ) - .unwrap(); - let out = Command::new("./bridgehead") - .current_dir(temp_dir.path()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .arg("compose") - .arg("config") - .spawn() + let out = crate::compose_command(temp_dir.path(), &["config".into()]) .unwrap() - .wait_with_output() + .stdout_capture() + .stderr_capture() + .unchecked() + .run() .unwrap(); assert!( out.status.success(), diff --git a/src/install.rs b/src/install.rs new file mode 100644 index 0000000..ddca3a3 --- /dev/null +++ b/src/install.rs @@ -0,0 +1,425 @@ +use std::{ + fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process::ExitCode, +}; + +use anyhow::{Context, bail, ensure}; +use duct::cmd; + +use crate::{config::Config, modules, services::ServiceMap}; + +fn require_root() -> anyhow::Result<()> { + ensure!( + unsafe { libc::geteuid() } == 0, + "This command must be run as root." + ); + Ok(()) +} + +fn load_materialized(config: &PathBuf) -> anyhow::Result<&'static Config> { + let conf = Box::leak(Box::new(Config::load(config)?)); + let mut services = ServiceMap::new(conf); + for module in modules::MODULES { + services.install_module(*module); + } + services.materialize(); + Ok(conf) +} + +pub fn install(config: &PathBuf) -> anyhow::Result { + require_root()?; + let conf = load_materialized(config)?; + // Persist the seed and pending networks before update runs in another process. + conf.save_local_conf()?; + let executable = std::env::current_exe().context("Failed to locate rusthead executable")?; + if cmd!("id", "-u", "bridgehead") + .stdout_null() + .stderr_null() + .unchecked() + .run()? + .status + .success() + { + println!("Using existing user bridgehead."); + // Also repair membership if the existing account has a different primary group. + cmd!("usermod", "-a", "-G", "docker", "bridgehead") + .run() + .context("Failed to ensure bridgehead belongs to the docker group")?; + } else { + cmd!("useradd", "-M", "-g", "docker", "-N", "bridgehead") + .run() + .context("Failed to create bridgehead user (the docker group must exist)")?; + } + cmd!("chown", "-R", "-h", "bridgehead:docker", &conf.path) + .run() + .context("Failed to set installation ownership")?; + share_permissions(&conf.path, &private_key(conf))?; + cmd!( + "sudo", + "-u", + "bridgehead", + "git", + "init", + "-b", + "main", + "--shared=group" + ) + .dir(&conf.path) + .run() + .context("Failed to initialize shared Git repository")?; + configure_git(conf)?; + + let systemd = match cmd!("systemctl", "status", "docker") + .stdout_null() + .stderr_null() + .unchecked() + .run() + { + Ok(output) => output.status.success(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => return Err(error).context("Failed to check Docker systemd service"), + }; + if systemd { + install_systemd(Path::new("/etc/systemd/system"), &executable, config)?; + cmd!("systemctl", "daemon-reload").run()?; + cmd!("systemctl", "enable", "bridgehead.service").run()?; + cmd!("systemctl", "enable", "--now", "bridgehead-update.timer").run()?; + } else { + println!( + "Systemd is not active or docker is not running via systemd. Skipping systemd setup." + ); + let status = cmd!( + "sudo", + "-u", + "bridgehead", + &executable, + "--config", + config, + "update" + ) + .dir(&conf.path) + .unchecked() + .run() + .context("Failed to run bridgehead update")? + .status; + match status.code() { + Some(0 | 3) => {} + Some(code) => { + eprintln!("Failed to update bridgehead"); + return Ok(ExitCode::from(u8::try_from(code)?)); + } + None => bail!("Bridgehead update was killed by a signal"), + } + } + // Update may have changed local credentials; do not overwrite them with our earlier copy. + let conf = load_materialized(config)?; + enroll_pending_networks(conf)?; + println!("Installation complete."); + println!( + "Start with 'systemctl start bridgehead' or 'rusthead --config {} compose up'.", + config.display() + ); + Ok(ExitCode::SUCCESS) +} + +fn private_key(conf: &Config) -> PathBuf { + conf.path.join(format!("pki/{}.priv.pem", conf.site_id)) +} + +// Do not follow symlinks out of the installation or make enrollment keys group writable. +fn share_permissions(path: &Path, key: &Path) -> anyhow::Result<()> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() { + return Ok(()); + } + let mode = if path == key { + 0o600 + } else { + metadata.permissions().mode() | 0o2020 + }; + fs::set_permissions(path, fs::Permissions::from_mode(mode)) + .with_context(|| format!("Failed to set permissions on {}", path.display()))?; + if metadata.is_dir() { + for entry in fs::read_dir(path)? { + share_permissions(&entry?.path(), key)?; + } + } + Ok(()) +} + +fn configure_git(conf: &Config) -> anyhow::Result<()> { + // --add in the old installer appended another entry on every invocation. + let trusted = cmd!("git", "config", "--global", "--get-all", "safe.directory") + .stdout_capture() + .unchecked() + .run()?; + ensure!( + matches!(trusted.status.code(), Some(0 | 1)), + "Failed to read Git safe.directory configuration" + ); + if !String::from_utf8(trusted.stdout)? + .lines() + .any(|line| line == conf.path.to_string_lossy()) + { + cmd!( + "git", + "config", + "--global", + "--add", + "safe.directory", + &conf.path + ) + .run()?; + } + for (key, value) in [ + ("user.email", "bridgehead@samply.de"), + ("user.name", "Bridgehead"), + ] { + cmd!("git", "config", "--local", "--replace-all", key, value) + .dir(&conf.path) + .run()?; + } + for key in ["http.proxy", "https.proxy"] { + if let Some(proxy) = &conf.https_proxy_url { + cmd!( + "git", + "config", + "--local", + "--replace-all", + key, + proxy.as_str() + ) + .dir(&conf.path) + .run()?; + } else { + let status = cmd!("git", "config", "--local", "--unset-all", key) + .dir(&conf.path) + .unchecked() + .run()? + .status; + ensure!( + matches!(status.code(), Some(0 | 5)), + "Failed to remove Git {key} configuration" + ); + } + } + Ok(()) +} + +// systemd has its own quoting and specifier expansion, independent of shell quoting. +fn unit_arg(path: &Path) -> anyhow::Result { + let value = path.to_str().context("systemd paths must be UTF-8")?; + ensure!( + !value.chars().any(char::is_control), + "systemd paths must not contain control characters" + ); + Ok(format!( + "\"{}\"", + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('%', "%%") + .replace('$', "$$") + )) +} + +fn install_systemd(directory: &Path, executable: &Path, config: &Path) -> anyhow::Result<()> { + let command = format!("{} --config {}", unit_arg(executable)?, unit_arg(config)?); + let units = [ + ("bridgehead.service", format!("[Unit]\nDescription=Bridgehead Service\nRequires=docker.service\n\n[Service]\nExecStart={command} compose up --abort-on-container-exit\nRestart=always\nUser=bridgehead\nGroup=docker\n\n[Install]\nWantedBy=multi-user.target\n")), + ("bridgehead-update.service", format!("[Unit]\nDescription=Bridgehead Update Service\nRequires=docker.service\n\n[Service]\nExecStart={command} update\nUser=bridgehead\nGroup=docker\nExecStopPost=+/bin/bash -c 'if [ \"$$EXIT_STATUS\" = \"3\" ]; then systemctl restart bridgehead.service; fi'\n")), + ("bridgehead-update.timer", "[Unit]\nDescription=Daily Updates at 6am of Bridgehead\n\n[Timer]\nOnCalendar=*-*-* 06:00:00\nPersistent=true\n\n[Install]\nWantedBy=basic.target\n".into()), + ]; + fs::create_dir_all(directory)?; + for (name, contents) in units { + let path = directory.join(name); + if fs::read(&path).ok().as_deref() != Some(contents.as_bytes()) { + fs::write(&path, contents) + .with_context(|| format!("Failed to write {}", path.display()))?; + } + } + Ok(()) +} + +pub fn enroll(config: &PathBuf) -> anyhow::Result { + require_root()?; + let conf = load_materialized(config)?; + enroll_pending_networks(conf)?; + Ok(ExitCode::SUCCESS) +} + +fn enroll_pending_networks(conf: &Config) -> anyhow::Result<()> { + conf.save_local_conf()?; + let networks = conf.local_conf.borrow().pending_beam_networks(); + if networks.is_empty() { + println!("No Beam networks pending enrollment."); + return Ok(()); + } + let key = private_key(conf); + for broker in &networks { + println!("Enrolling {}.{broker}", conf.site_id); + cmd!( + "docker", + "run", + "--rm", + "-v", + format!("{0}:{0}", conf.path.join("pki").display()), + "docker.verbis.dkfz.de/cache/samply/beam-enroll:latest", + "--output-file", + &key, + "--proxy-id", + format!("{}.{broker}", conf.site_id) + ) + .run() + .context("Beam enrollment failed")?; + fs::set_permissions(&key, fs::Permissions::from_mode(0o600))?; + cmd!("chown", "bridgehead:docker", &key).run()?; + { + let mut local = conf.local_conf.borrow_mut(); + local.enrolled_beam_networks.insert(broker.clone()); + } + // Save each success so a failure on a later network can be retried independently. + conf.save_local_conf()?; + } + if !networks.is_empty() { + println!( + "After getting the CSRs enrolled you may start the bridgehead service with 'systemctl start bridgehead'." + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn network_state_survives_reload_and_tracks_configuration_changes() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("config.toml"); + let configured = "site_id = 'test'\nhostname = 'localhost'\n[ccp]\n"; + fs::write(&config, configured).unwrap(); + // Older local configurations must load without losing their existing seed. + fs::write(temp.path().join("config.local.toml"), "seed = 42\n").unwrap(); + let conf = load_materialized(&config).unwrap(); + let networks = conf.local_conf.borrow().beam_networks.clone(); + assert_eq!(networks.len(), 1); + assert_eq!(conf.local_conf.borrow().pending_beam_networks(), networks); + fs::write(private_key(conf), "key").unwrap(); + { + let mut local = conf.local_conf.borrow_mut(); + local.enrolled_beam_networks = networks.clone(); + } + conf.save_local_conf().unwrap(); + assert!( + load_materialized(&config) + .unwrap() + .local_conf + .borrow() + .pending_beam_networks() + .is_empty() + ); + + fs::write(&config, "site_id = 'test'\nhostname = 'localhost'\n").unwrap(); + let disabled = load_materialized(&config).unwrap(); + assert!(disabled.local_conf.borrow().beam_networks.is_empty()); + assert_eq!( + disabled.local_conf.borrow().enrolled_beam_networks, + networks + ); + disabled.save_local_conf().unwrap(); + fs::write(&config, configured).unwrap(); + assert!( + load_materialized(&config) + .unwrap() + .local_conf + .borrow() + .pending_beam_networks() + .is_empty() + ); + + fs::write(&config, configured.replace("'test'", "'new-site'")).unwrap(); + let renamed = load_materialized(&config).unwrap(); + fs::write(private_key(renamed), "another key").unwrap(); + assert!( + load_materialized(&config) + .unwrap() + .local_conf + .borrow() + .pending_beam_networks() + .is_empty() + ); + fs::write(&config, configured).unwrap(); + fs::remove_file(private_key(conf)).unwrap(); + assert_eq!( + load_materialized(&config) + .unwrap() + .local_conf + .borrow() + .pending_beam_networks(), + networks + ); + } + + #[test] + fn units_are_stable_and_reference_the_rust_binary_and_selected_config() { + let temp = tempfile::tempdir().unwrap(); + let executable = Path::new("/opt/bridge head/rusthead"); + let config = Path::new("/srv/bridge head/custom.toml"); + install_systemd(temp.path(), executable, config).unwrap(); + let service = temp.path().join("bridgehead.service"); + let modified = fs::metadata(&service).unwrap().modified().unwrap(); + install_systemd(temp.path(), executable, config).unwrap(); + assert_eq!( + fs::metadata(&service).unwrap().modified().unwrap(), + modified + ); + assert!(fs::read_to_string(&service).unwrap().contains("ExecStart=\"/opt/bridge head/rusthead\" --config \"/srv/bridge head/custom.toml\" compose up --abort-on-container-exit")); + assert!( + fs::read_to_string(temp.path().join("bridgehead-update.service")) + .unwrap() + .contains("$$EXIT_STATUS") + ); + assert!( + fs::read_to_string(temp.path().join("bridgehead-update.timer")) + .unwrap() + .contains("OnCalendar=*-*-* 06:00:00") + ); + assert_eq!( + unit_arg(Path::new("/srv/50%/$site")).unwrap(), + "\"/srv/50%%/$$site\"" + ); + assert!(unit_arg(Path::new("/srv/line\nbreak")).is_err()); + } + + #[test] + fn sharing_permissions_preserves_keys_and_does_not_follow_symlinks() { + use std::os::unix::fs::symlink; + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("site"); + fs::create_dir(&root).unwrap(); + let key = root.join("key.pem"); + fs::write(&key, "key").unwrap(); + let external = temp.path().join("external"); + fs::write(&external, "external").unwrap(); + fs::set_permissions(&external, fs::Permissions::from_mode(0o600)).unwrap(); + symlink(&external, root.join("link")).unwrap(); + for _ in 0..2 { + share_permissions(&root, &key).unwrap(); + } + assert_eq!( + fs::metadata(&key).unwrap().permissions().mode() & 0o7777, + 0o600 + ); + assert_eq!( + fs::metadata(&external).unwrap().permissions().mode() & 0o7777, + 0o600 + ); + assert_eq!( + fs::metadata(&root).unwrap().permissions().mode() & 0o2020, + 0o2020 + ); + } +} diff --git a/src/main.rs b/src/main.rs index 2f787f9..623eb6d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,92 +1,100 @@ -use std::{path::PathBuf, process::ExitCode}; +use std::{ + path::{Path, PathBuf}, + process::ExitCode, +}; use anyhow::Context; use clap::Parser; use config::Config; +use duct::cmd; use services::ServiceMap; -use crate::{bridgehead::Bridgehead, git::DiffTrackerResult}; - -mod bridgehead; mod config; mod git; +mod install; mod modules; mod services; mod utils; #[derive(Debug, clap::Subcommand)] -enum BootstrapHelper { - Bridgehead { - #[clap(short, long, env = "BRIDGEHEAD_CONFIG_PATH")] - config: PathBuf, +enum Subcommand { + Compose { + #[clap(trailing_var_arg = true)] + compose_args: Vec, }, + Update, + Bootstrap, + Enroll, + Install, } #[derive(Debug, clap::Parser)] -enum Args { - Bootstrap { - #[clap(subcommand)] - helper: Option, - }, - Update { - #[clap(short, long, env = "BRIDGEHEAD_CONFIG_PATH")] - config: PathBuf, - }, +struct Args { + #[clap( + short, + long, + env = "BRIDGEHEAD_CONFIG_PATH", + default_value = "./config.toml" + )] + /// Path to the bridgehead configuration file + config: PathBuf, + + #[clap(subcommand)] + command: Subcommand, } fn main() -> anyhow::Result { - let conf_path = match Args::parse() { - Args::Bootstrap { helper: None } => { - println!("{}", include_str!("../static/bootstrap.sh")); - return Ok(ExitCode::SUCCESS); - } - Args::Bootstrap { - helper: Some(BootstrapHelper::Bridgehead { config }), - } => { - let conf = Config::load(&config) - .with_context(|| format!("Failed to load config from {config:?}"))?; - let conf: &'static Config = Box::leak(Box::new(conf)); - Bridgehead::new(&conf).write()?; - println!( - "Bridgehead bootstrap complete. Run `sudo ./bridgehead install` to install the bridgehead service." - ); - return Ok(ExitCode::SUCCESS); - } - Args::Update { config } => config, - }; - let conf = Config::load(&conf_path) - .with_context(|| format!("Failed to load config from {conf_path:?}"))?; - let conf: &'static mut Config = Box::leak(Box::new(conf)); - let diff_tracker = match git::DiffTracker::start(&conf)? { - DiffTrackerResult::Success(tracker) => Some(tracker), - // git pull updated the repo -> reload the config - DiffTrackerResult::NeedsConfigReload => { - println!("Reloading config..."); - *conf = Config::load(&conf_path).with_context(|| { - format!("Failed to load config from {conf_path:?} after update") - })?; - let DiffTrackerResult::Success(dt) = git::DiffTracker::start(&conf)? else { - anyhow::bail!("We just pulled so we should not need to reload the config again"); - }; - Some(dt) - } - DiffTrackerResult::NotAGitRepo => None, + let args = Args::parse(); + let config = args + .config + .canonicalize() + .context("Failed to resolve config path")?; + let config_dir = if config.is_dir() { + config.as_path() + } else { + config.parent().unwrap() }; + std::env::set_current_dir(config_dir)?; + let cwd = std::env::current_dir()?; + match &args.command { + Subcommand::Compose { compose_args } => compose_command(&cwd, compose_args)? + .run() + .context("Failed to run docker compose")? + .status + .code() + .and_then(|c| Some(ExitCode::from(u8::try_from(c).ok()?))) + .ok_or(anyhow::anyhow!("Killed by signal")), + Subcommand::Update => update(&config), + Subcommand::Bootstrap => todo!("Not implemented"), + Subcommand::Enroll => install::enroll(&config), + Subcommand::Install => install::install(&config), + } +} + +fn compose_command(config_dir: &Path, compose_args: &[String]) -> anyhow::Result { + let mut services = config_dir + .join("services") + .read_dir()? + .map(|entry| entry.map(|entry| entry.path())) + .collect::, _>>()?; + services.sort(); + let mut args = vec!["compose".into(), "-p".into(), "bridgehead".into()]; + for service in services { + args.push(std::ffi::OsString::from("-f")); + args.push(service.into_os_string()); + } + args.extend(compose_args.iter().map(std::ffi::OsString::from)); + Ok(cmd("docker", args).dir(config_dir)) +} + +pub fn update(config: &PathBuf) -> anyhow::Result { + let conf = + Config::load(config).with_context(|| format!("Failed to load config from {config:?}"))?; + let conf: &'static Config = Box::leak(Box::new(conf)); let mut services = ServiceMap::new(conf); modules::MODULES .iter() .for_each(|&m| services.install_module(m)); services.write_all()?; - if let Some(diff_tracker) = diff_tracker { - let needs_restart = diff_tracker.commit()?; - if needs_restart { - println!("Updated the bridgehead. Please restart"); - Ok(ExitCode::from(3)) - } else { - Ok(ExitCode::SUCCESS) - } - } else { - // Most likely a new installation - Ok(ExitCode::SUCCESS) - } + Ok(ExitCode::SUCCESS) } diff --git a/src/services/beam.rs b/src/services/beam.rs index 1e2eb84..078f868 100644 --- a/src/services/beam.rs +++ b/src/services/beam.rs @@ -1,10 +1,5 @@ use std::{ - cell::RefCell, - collections::{BTreeMap, BTreeSet}, - fs, - marker::PhantomData, - path::PathBuf, - str::FromStr, + cell::RefCell, collections::BTreeMap, fs, marker::PhantomData, path::PathBuf, str::FromStr, }; use askama::Template; @@ -63,7 +58,10 @@ impl Service for BeamProxy { type ServiceConfig = &'static Config; fn from_config(conf: Self::ServiceConfig, (fw_proxy,): Deps) -> Self { - BEAM_NETWORKS.with_borrow_mut(|nets| nets.insert(T::broker_id())); + conf.local_conf + .borrow_mut() + .beam_networks + .insert(T::broker_id()); fs::create_dir_all(conf.path.join("pki")).unwrap(); BeamProxy { broker_provider: PhantomData, @@ -80,7 +78,3 @@ impl Service for BeamProxy { format!("{}-beam-proxy", T::network_name()) } } - -thread_local! { - pub static BEAM_NETWORKS: RefCell> = RefCell::default(); -} diff --git a/src/services/datashield.rs b/src/services/datashield.rs index 5eb4ec9..9ff58c0 100644 --- a/src/services/datashield.rs +++ b/src/services/datashield.rs @@ -67,11 +67,12 @@ impl Service for DataShield { } let tm_beam = beam_proxy.add_service("token-manager"); + let oidc = OidcClient::::add_private_redirect_path(conf, "/opal/*"); let mut local_conf = conf.local_conf.borrow_mut(); Self { fw_proxy_url: fw_proxy.get_url(), tm_beam, - oidc: OidcClient::::add_private_redirect_path(conf, "/opal/*"), + oidc, db: pg.connect_info(), exporter_password: None, tm_pw: local_conf.generate_secret::<10, Self>("token-manager"), diff --git a/src/services/mod.rs b/src/services/mod.rs index 545f815..3c45f83 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -8,7 +8,7 @@ use std::{ use anyhow::Context; use askama::Template; -use crate::{Config, bridgehead::Bridgehead, modules::Module}; +use crate::{Config, modules::Module}; pub mod beam_connect; pub mod dnpm_node; @@ -230,9 +230,11 @@ impl std::fmt::Debug for ServiceMap { } impl ServiceMap { - const ROOT_NODE: TypeId = TypeId::of::(); + const ROOT_NODE: TypeId = TypeId::of::<()>(); pub fn new(config: &'static Config) -> Self { + // Rebuild configured networks while retaining enrollment history. + config.local_conf.borrow_mut().beam_networks.clear(); let mut deps = solvent::DepGraph::new(); deps.register_node(Self::ROOT_NODE); Self { @@ -253,7 +255,6 @@ impl ServiceMap { self.materialize(); self.write_composables() .context("Failed to write services")?; - Bridgehead::new(self.config).write()?; self.config.write_local_conf()?; fs::write( self.config.path.join(".gitignore"), @@ -363,7 +364,7 @@ impl ServiceMap { Ok(()) } - fn materialize(&mut self) { + pub(crate) fn materialize(&mut self) { let deps = std::mem::take(&mut self.deps); for dep in deps.dependencies_of(&Self::ROOT_NODE).unwrap() { let dep = dep.expect("No cycle"); diff --git a/static/bootstrap.sh b/static/bootstrap.sh deleted file mode 100755 index c14a08f..0000000 --- a/static/bootstrap.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -set -e - -DEFAULT_CONFIG_DIR="." - -# Check if config.toml exists -if [ ! -f "${DEFAULT_CONFIG_DIR}/config.toml" ]; then - echo "Setting up configuration for bridgehead" - - read -p "Installation directory [$DEFAULT_CONFIG_DIR]: " config_dir - config_dir="${config_dir:-$DEFAULT_CONFIG_DIR}" - config_dir="$(readlink -f $config_dir)" - - read -p "Site ID: " site_id - - default_hostname=$(hostname -f) - read -p "Hostname [$default_hostname]: " hostname - hostname="${hostname:-$default_hostname}" - - # Create config.toml - mkdir -p "$config_dir" - cat > "${config_dir}/config.toml" << EOF -site_id = "$site_id" -hostname = "$hostname" -EOF - - read -p "Proxy [${HTTPS_PROXY:-None}]: " proxy - proxy="${proxy:-$HTTPS_PROXY}" - [ -n "$proxy" ] && echo "https_proxy_url = \"$proxy\"" >> "${config_dir}/config.toml" - [ -n "$IMAGE" ] && echo "image = \"$IMAGE\"" >> "${config_dir}/config.toml" - - echo "Configuration file created at ${config_dir}/config.toml" -else - config_dir="$(readlink -f $DEFAULT_CONFIG_DIR)" - echo "Using already provided configuration from ${config_dir}/config.toml" -fi - -docker run --rm \ - -v $config_dir:$config_dir \ - ${IMAGE:-samply/rusthead:latest} bootstrap bridgehead --config $config_dir > /dev/null -sudo $config_dir/bridgehead install -git config --global --add safe.directory $config_dir diff --git a/templates/bridgehead b/templates/bridgehead deleted file mode 100644 index 735f3d4..0000000 --- a/templates/bridgehead +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env bash -set -e -set -o pipefail - -{%- let config_dir = conf.path|path %} -{%- let priv_key_file = conf.path.join(format!("pki/{}.priv.pem", self.conf.site_id)) %} - -# Ensure the script is running in memory to avoid issues with self modification on update -[ "$LOADED" = 1 ] || LOADED=1 exec bash <(cat "$0") "$@" -cd {{ config_dir }} - - -main() { - case "$1" in - install) - [ "$(id -u)" -ne 0 ] && echo "Install command must be run as root." && exit 1 - useradd -M -g docker -N bridgehead &>/dev/null || echo "Using existing user bridgehead." - chown -R bridgehead:docker . - chmod -R g+sw . - sudo -u bridgehead git init -b main --shared=group - git config --global --add safe.directory {{ config_dir }} - git config --local user.email "bridgehead@samply.de" - git config --local user.name "Bridgehead" - {%- if let Some(proxy_url) = conf.https_proxy_url %} - git config --local http.proxy {{ proxy_url }} - git config --local https.proxy {{ proxy_url }} - {%- endif %} - if ! systemctl status docker &> /dev/null; then - echo "Systemd is not active or docker is not running via systemd. Skipping systemd setup." - set +e - sudo -u bridgehead ./bridgehead update - exit_code=$? - set -e - if [ "$exit_code" != "0" ] && [ "$exit_code" != "3" ]; then - echo "Failed to update bridgehead" - exit $exit_code - fi - else - install_systemd - fi - {%- if beam_networks.is_empty() %} - echo "Installation complete." - echo "You may start the bridgehead service with 'systemctl start bridgehead' or '{{ config_dir }}/bridgehead compose up' now." - {%- else %} - if [ -e {{ priv_key_file.display() }} ]; then - echo "Private key already exists. Skipping enrollment." - echo "If you want to re-enroll or changed the configuration and are now included in a new beam network run 'sudo {{ config_dir }}/bridgehead enroll'." - echo "Installation complete." - else - enroll - fi - {%- endif %} - ;; - logs) - shift - exec journalctl -u bridgehead -u bridgehead-update -a $@ - ;; - compose) - compose_files="$(ls services | awk '{print " -f services/" $0}')" - [ -e ./docker-compose.override.yml ] && compose_files+=" -f docker-compose.override.yml" - {%- if !beam_networks.is_empty() %} - if [ ! -f {{ priv_key_file.display() }} ]; then - echo "Beam private key not found. Please run 'sudo {{ config_dir }}/bridgehead enroll' first." - exit 1 - fi - {%- endif %} - shift - exec docker compose -p bridgehead --env-file .env $compose_files -f docker-image.lock.yml $@ - ;; - {%- if !beam_networks.is_empty() %} - enroll) - [ "$(id -u)" -ne 0 ] && echo "Enroll must be run as root." && exit 1 - enroll - ;; - {%- endif %} - update) - docker image prune -f - docker pull {{ conf.image }} &>/dev/null || echo "Failed to pull latest rusthead image. Using latest local image." - # Mount the docker config if it exists to allow pulling from private registries if needed - docker_config=~/.docker/config.json - docker_config_mount="" - [ -f "$docker_config" ] && docker_config_mount="-v $docker_config:/root/.docker/config.json:ro" - docker run --rm \ - -v {{ config_dir }}:{{ config_dir }} \ - -v /var/run/docker.sock:/var/run/docker.sock \ - $docker_config_mount \ - -e BRIDGEHEAD_CONFIG_PATH={{ config_dir }} \ - -u "$(id -u bridgehead):$(id -g bridgehead)" \ - {{ conf.image }} update - ;; - *) - echo "Unknown bridgehead command '$@'" - exit 1 - ;; - esac -} - -{%- if !beam_networks.is_empty() %} -enroll() { - do_enroll() { - echo "Enrolling {{ conf.site_id }}.$1" - docker run --rm \ - -v {{ config_dir }}/pki:{{ config_dir }}/pki \ - docker.verbis.dkfz.de/cache/samply/beam-enroll:latest \ - --output-file {{ priv_key_file.display() }} \ - --proxy-id {{ conf.site_id }}.$1 - chmod 600 {{ priv_key_file.display() }} - chown bridgehead:docker {{ priv_key_file.display() }} - } - {%- for broker in beam_networks %} - do_enroll {{ broker }} - {%- endfor %} - echo "After getting the csr{{ beam_networks.len()|pluralize }} enrolled you may start the bridgehead service with 'systemctl start bridgehead'." -} -{%- endif %} - -install_systemd() { - cat < /etc/systemd/system/bridgehead.service -[Unit] -Description=Bridgehead Service -Requires=docker.service - -[Service] -ExecStart={{ config_dir }}/bridgehead compose up --abort-on-container-exit -Restart=always -User=bridgehead -Group=docker - -[Install] -WantedBy=multi-user.target -EOF - cat < /etc/systemd/system/bridgehead-update.service -[Unit] -Description=Bridgehead Update Service -Requires=docker.service - -[Service] -ExecStart={{ config_dir }}/bridgehead update -User=bridgehead -Group=docker -ExecStopPost=+/bin/bash -c 'if [ "\$EXIT_STATUS" = "3" ]; then systemctl restart bridgehead.service; fi' -EOF - cat < /etc/systemd/system/bridgehead-update.timer -[Unit] -Description=Daily Updates at 6am of Bridgehead - -[Timer] -OnCalendar=*-*-* 06:00:00 -Persistent=true - -[Install] -WantedBy=basic.target -EOF - systemctl daemon-reload - echo "Enabling autostart of bridgehead.service" - systemctl enable bridgehead.service - echo "Enabling auto-updates for bridgehead.service ..." - systemctl enable --now bridgehead-update.timer -} - -main "$@" diff --git a/tests/install.rs b/tests/install.rs new file mode 100644 index 0000000..69b9b85 --- /dev/null +++ b/tests/install.rs @@ -0,0 +1,267 @@ +use std::{ + fs, + os::unix::fs::PermissionsExt, + path::Path, + process::{Command, Output}, +}; + +fn script(path: &Path, body: &str) { + fs::write(path, format!("#!/bin/sh\nset -eu\n{body}\n")).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); +} + +struct Installation { + temp: tempfile::TempDir, +} + +impl Installation { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + fs::create_dir(root.join("bin")).unwrap(); + fs::create_dir(root.join("site with spaces")).unwrap(); + fs::write( + root.join("site with spaces/custom.toml"), + "site_id = 'test'\nhostname = 'localhost'\n[ccp]\n", + ) + .unwrap(); + script(&root.join("bin/id"), "test -f \"$INSTALL_TEST_ROOT/user\""); + script( + &root.join("bin/useradd"), + "echo useradd >> \"$INSTALL_TEST_ROOT/log\"\ntouch \"$INSTALL_TEST_ROOT/user\"", + ); + script(&root.join("bin/usermod"), ":"); + script(&root.join("bin/chown"), ":"); + script(&root.join("bin/systemctl"), "exit 3"); + script( + &root.join("bin/sudo"), + r#" +shift 2 +if [ "$1" = git ]; then exec "$@"; fi +printf '%s\n' "$@" >> "$INSTALL_TEST_ROOT/log" +exit "${INSTALL_TEST_UPDATE_STATUS:-0}" +"#, + ); + script( + &root.join("bin/docker"), + r#" +echo enroll >> "$INSTALL_TEST_ROOT/log" +while [ "$1" != --output-file ]; do shift; done +key="$2" +shift 2 +while [ "$1" != --proxy-id ]; do shift; done +echo "$2" >> "$INSTALL_TEST_ROOT/proxies" +if [ -f "$INSTALL_TEST_ROOT/fail-network" ] && [ "$2" = "$(cat "$INSTALL_TEST_ROOT/fail-network")" ]; then exit 9; fi +if [ ! -f "$key" ]; then printf 'private key\n' > "$key"; fi +"#, + ); + Self { temp } + } + + fn run(&self, update_status: u8) -> Output { + self.run_command("install", update_status) + } + + fn run_command(&self, command: &str, update_status: u8) -> Output { + Command::new(env!("CARGO_BIN_EXE_rusthead")) + .args(["--config", "site with spaces/custom.toml", command]) + .current_dir(self.temp.path()) + .env( + "PATH", + format!( + "{}:{}", + self.temp.path().join("bin").display(), + std::env::var("PATH").unwrap() + ), + ) + .env("INSTALL_TEST_ROOT", self.temp.path()) + .env("INSTALL_TEST_UPDATE_STATUS", update_status.to_string()) + .env( + "GIT_CONFIG_GLOBAL", + self.temp.path().join("global.gitconfig"), + ) + .env("GIT_CONFIG_NOSYSTEM", "1") + .output() + .unwrap() + } + + fn local_conf(&self) -> toml::Table { + toml::from_str( + &fs::read_to_string(self.temp.path().join("site with spaces/config.local.toml")) + .unwrap(), + ) + .unwrap() + } +} + +#[test] +#[ignore = "requires root; run with unshare --user --map-root-user cargo test --test install -- --ignored"] +fn repeated_install_preserves_repository_and_private_key() { + assert_eq!(unsafe { libc::geteuid() }, 0); + let installation = Installation::new(); + for _ in 0..2 { + let result = installation.run(3); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + } + let root = installation.temp.path(); + let log = fs::read_to_string(root.join("log")).unwrap(); + assert_eq!(log.lines().filter(|line| *line == "useradd").count(), 1); + assert_eq!(log.lines().filter(|line| *line == "enroll").count(), 1); + assert!(log.contains(root.join("site with spaces/custom.toml").to_str().unwrap())); + let key = root.join("site with spaces/pki/test.priv.pem"); + assert_eq!(fs::read_to_string(&key).unwrap(), "private key\n"); + assert_eq!( + fs::metadata(key).unwrap().permissions().mode() & 0o7777, + 0o600 + ); + let global = fs::read_to_string(root.join("global.gitconfig")).unwrap(); + assert_eq!(global.matches("directory =").count(), 1); + let repo = root.join("site with spaces"); + let git_config = fs::read_to_string(repo.join(".git/config")).unwrap(); + assert!(git_config.contains("sharedrepository = 1")); + assert_eq!( + git_config.matches("email = bridgehead@samply.de").count(), + 1 + ); + assert_eq!( + fs::read_to_string(repo.join(".git/HEAD")).unwrap(), + "ref: refs/heads/main\n" + ); +} + +#[test] +#[ignore = "requires root; run with unshare --user --map-root-user cargo test --test install -- --ignored"] +fn failed_update_stops_installation_before_enrollment() { + assert_eq!(unsafe { libc::geteuid() }, 0); + let installation = Installation::new(); + let result = installation.run(7); + assert_eq!( + result.status.code(), + Some(7), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + assert!( + !installation + .temp + .path() + .join("site with spaces/pki/test.priv.pem") + .exists() + ); +} + +#[test] +fn install_requires_root() { + use std::os::unix::process::CommandExt; + let installation = Installation::new(); + // --config must remain accessible while running as an unprivileged account. + fs::set_permissions(installation.temp.path(), fs::Permissions::from_mode(0o755)).unwrap(); + let mut command = Command::new(env!("CARGO_BIN_EXE_rusthead")); + command + .arg("--config") + .arg( + installation + .temp + .path() + .join("site with spaces/custom.toml"), + ) + .arg("install"); + if unsafe { libc::geteuid() } == 0 { + command.uid(65534); + } + let output = command.output().unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("must be run as root")); +} + +#[test] +#[ignore = "requires root; run with unshare --user --map-root-user cargo test --test install -- --ignored"] +fn new_network_is_enrolled_without_repeating_existing_enrollment() { + assert_eq!(unsafe { libc::geteuid() }, 0); + let installation = Installation::new(); + assert!(installation.run(0).status.success()); + let root = installation.temp.path(); + let site = root.join("site with spaces"); + let before = installation.local_conf(); + fs::write( + site.join("custom.toml"), + "site_id = 'test'\nhostname = 'localhost'\n[ccp]\n[bbmri]\n", + ) + .unwrap(); + fs::write(site.join(".env"), "KEEP=unchanged\n").unwrap(); + let result = installation.run_command("enroll", 0); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let local = installation.local_conf(); + assert_eq!(local["beam_networks"].as_array().unwrap().len(), 2); + assert_eq!(local["enrolled_beam_networks"].as_array().unwrap().len(), 2); + assert_eq!(local["seed"], before["seed"]); + assert_eq!( + fs::read_to_string(site.join(".env")).unwrap(), + "KEEP=unchanged\n" + ); + assert_eq!( + fs::read_to_string(root.join("proxies")) + .unwrap() + .lines() + .count(), + 2 + ); + assert!(installation.run_command("enroll", 0).status.success()); + assert_eq!( + fs::read_to_string(root.join("proxies")) + .unwrap() + .lines() + .count(), + 2 + ); +} + +#[test] +#[ignore = "requires root; run with unshare --user --map-root-user cargo test --test install -- --ignored"] +fn partial_enrollment_is_persisted_and_only_failed_networks_are_retried() { + assert_eq!(unsafe { libc::geteuid() }, 0); + let installation = Installation::new(); + let root = installation.temp.path(); + fs::write( + root.join("site with spaces/custom.toml"), + "site_id = 'test'\nhostname = 'localhost'\n[ccp]\n[bbmri]\n", + ) + .unwrap(); + fs::write(root.join("fail-network"), "test.broker.ccp-it.dktk.dkfz.de").unwrap(); + assert!(!installation.run(0).status.success()); + let local = installation.local_conf(); + assert_eq!(local["beam_networks"].as_array().unwrap().len(), 2); + assert_eq!(local["enrolled_beam_networks"].as_array().unwrap().len(), 1); + fs::remove_file(root.join("fail-network")).unwrap(); + assert!(installation.run(0).status.success()); + let proxies = fs::read_to_string(root.join("proxies")).unwrap(); + assert_eq!( + proxies + .lines() + .filter(|line| *line == "test.broker.bbmri.samply.de") + .count(), + 1 + ); + assert_eq!( + proxies + .lines() + .filter(|line| *line == "test.broker.ccp-it.dktk.dkfz.de") + .count(), + 2 + ); + assert_eq!( + installation.local_conf()["enrolled_beam_networks"] + .as_array() + .unwrap() + .len(), + 2 + ); +} diff --git a/tests/snapshots/eucaim-sql/configs@bridgehead.snap b/tests/snapshots/eucaim-sql/configs@bridgehead.snap deleted file mode 100644 index dc09627..0000000 --- a/tests/snapshots/eucaim-sql/configs@bridgehead.snap +++ /dev/null @@ -1,146 +0,0 @@ ---- -source: src/config.rs -expression: file -info: bridgehead -input_file: tests/configs/eucaim-sql.toml ---- -#!/usr/bin/env bash -set -e -set -o pipefail - -# Ensure the script is running in memory to avoid issues with self modification on update -[ "$LOADED" = 1 ] || LOADED=1 exec bash <(cat "$0") "$@" -cd [TMP_DIR] - - -main() { - case "$1" in - install) - [ "$(id -u)" -ne 0 ] && echo "Install command must be run as root." && exit 1 - useradd -M -g docker -N bridgehead &>/dev/null || echo "Using existing user bridgehead." - chown -R bridgehead:docker . - chmod -R g+sw . - sudo -u bridgehead git init -b main --shared=group - git config --global --add safe.directory [TMP_DIR] - git config --local user.email "bridgehead@samply.de" - git config --local user.name "Bridgehead" - if ! systemctl status docker &> /dev/null; then - echo "Systemd is not active or docker is not running via systemd. Skipping systemd setup." - set +e - sudo -u bridgehead ./bridgehead update - exit_code=$? - set -e - if [ "$exit_code" != "0" ] && [ "$exit_code" != "3" ]; then - echo "Failed to update bridgehead" - exit $exit_code - fi - else - install_systemd - fi - if [ -e [TMP_DIR]/pki/dummy.priv.pem ]; then - echo "Private key already exists. Skipping enrollment." - echo "If you want to re-enroll or changed the configuration and are now included in a new beam network run 'sudo [TMP_DIR]/bridgehead enroll'." - echo "Installation complete." - else - enroll - fi - ;; - logs) - shift - exec journalctl -u bridgehead -u bridgehead-update -a $@ - ;; - compose) - compose_files="$(ls services | awk '{print " -f services/" $0}')" - [ -e ./docker-compose.override.yml ] && compose_files+=" -f docker-compose.override.yml" - if [ ! -f [TMP_DIR]/pki/dummy.priv.pem ]; then - echo "Beam private key not found. Please run 'sudo [TMP_DIR]/bridgehead enroll' first." - exit 1 - fi - shift - exec docker compose -p bridgehead --env-file .env $compose_files -f docker-image.lock.yml $@ - ;; - enroll) - [ "$(id -u)" -ne 0 ] && echo "Enroll must be run as root." && exit 1 - enroll - ;; - update) - docker image prune -f - docker pull samply/rusthead:latest &>/dev/null || echo "Failed to pull latest rusthead image. Using latest local image." - # Mount the docker config if it exists to allow pulling from private registries if needed - docker_config=~/.docker/config.json - docker_config_mount="" - [ -f "$docker_config" ] && docker_config_mount="-v $docker_config:/root/.docker/config.json:ro" - docker run --rm \ - -v [TMP_DIR]:[TMP_DIR] \ - -v /var/run/docker.sock:/var/run/docker.sock \ - $docker_config_mount \ - -e BRIDGEHEAD_CONFIG_PATH=[TMP_DIR] \ - -u "$(id -u bridgehead):$(id -g bridgehead)" \ - samply/rusthead:latest update - ;; - *) - echo "Unknown bridgehead command '$@'" - exit 1 - ;; - esac -} -enroll() { - do_enroll() { - echo "Enrolling dummy.$1" - docker run --rm \ - -v [TMP_DIR]/pki:[TMP_DIR]/pki \ - docker.verbis.dkfz.de/cache/samply/beam-enroll:latest \ - --output-file [TMP_DIR]/pki/dummy.priv.pem \ - --proxy-id dummy.$1 - chmod 600 [TMP_DIR]/pki/dummy.priv.pem - chown bridgehead:docker [TMP_DIR]/pki/dummy.priv.pem - } - do_enroll broker.eucaim.cancerimage.eu - echo "After getting the csr enrolled you may start the bridgehead service with 'systemctl start bridgehead'." -} - -install_systemd() { - cat < /etc/systemd/system/bridgehead.service -[Unit] -Description=Bridgehead Service -Requires=docker.service - -[Service] -ExecStart=[TMP_DIR]/bridgehead compose up --abort-on-container-exit -Restart=always -User=bridgehead -Group=docker - -[Install] -WantedBy=multi-user.target -EOF - cat < /etc/systemd/system/bridgehead-update.service -[Unit] -Description=Bridgehead Update Service -Requires=docker.service - -[Service] -ExecStart=[TMP_DIR]/bridgehead update -User=bridgehead -Group=docker -ExecStopPost=+/bin/bash -c 'if [ "\$EXIT_STATUS" = "3" ]; then systemctl restart bridgehead.service; fi' -EOF - cat < /etc/systemd/system/bridgehead-update.timer -[Unit] -Description=Daily Updates at 6am of Bridgehead - -[Timer] -OnCalendar=*-*-* 06:00:00 -Persistent=true - -[Install] -WantedBy=basic.target -EOF - systemctl daemon-reload - echo "Enabling autostart of bridgehead.service" - systemctl enable bridgehead.service - echo "Enabling auto-updates for bridgehead.service ..." - systemctl enable --now bridgehead-update.timer -} - -main "$@" diff --git a/tests/snapshots/eucaim-sql/configs@config.local.toml.snap b/tests/snapshots/eucaim-sql/configs@config.local.toml.snap index 60a6009..8837139 100644 --- a/tests/snapshots/eucaim-sql/configs@config.local.toml.snap +++ b/tests/snapshots/eucaim-sql/configs@config.local.toml.snap @@ -5,3 +5,5 @@ info: config.local.toml input_file: tests/configs/eucaim-sql.toml --- seed = 42 +beam_networks = ['broker.eucaim.cancerimage.eu'] +enrolled_beam_networks = [] diff --git a/tests/snapshots/eucaim/configs@bridgehead.snap b/tests/snapshots/eucaim/configs@bridgehead.snap deleted file mode 100644 index 78f2652..0000000 --- a/tests/snapshots/eucaim/configs@bridgehead.snap +++ /dev/null @@ -1,146 +0,0 @@ ---- -source: src/config.rs -expression: file -info: bridgehead -input_file: tests/configs/eucaim.toml ---- -#!/usr/bin/env bash -set -e -set -o pipefail - -# Ensure the script is running in memory to avoid issues with self modification on update -[ "$LOADED" = 1 ] || LOADED=1 exec bash <(cat "$0") "$@" -cd [TMP_DIR] - - -main() { - case "$1" in - install) - [ "$(id -u)" -ne 0 ] && echo "Install command must be run as root." && exit 1 - useradd -M -g docker -N bridgehead &>/dev/null || echo "Using existing user bridgehead." - chown -R bridgehead:docker . - chmod -R g+sw . - sudo -u bridgehead git init -b main --shared=group - git config --global --add safe.directory [TMP_DIR] - git config --local user.email "bridgehead@samply.de" - git config --local user.name "Bridgehead" - if ! systemctl status docker &> /dev/null; then - echo "Systemd is not active or docker is not running via systemd. Skipping systemd setup." - set +e - sudo -u bridgehead ./bridgehead update - exit_code=$? - set -e - if [ "$exit_code" != "0" ] && [ "$exit_code" != "3" ]; then - echo "Failed to update bridgehead" - exit $exit_code - fi - else - install_systemd - fi - if [ -e [TMP_DIR]/pki/dummy.priv.pem ]; then - echo "Private key already exists. Skipping enrollment." - echo "If you want to re-enroll or changed the configuration and are now included in a new beam network run 'sudo [TMP_DIR]/bridgehead enroll'." - echo "Installation complete." - else - enroll - fi - ;; - logs) - shift - exec journalctl -u bridgehead -u bridgehead-update -a $@ - ;; - compose) - compose_files="$(ls services | awk '{print " -f services/" $0}')" - [ -e ./docker-compose.override.yml ] && compose_files+=" -f docker-compose.override.yml" - if [ ! -f [TMP_DIR]/pki/dummy.priv.pem ]; then - echo "Beam private key not found. Please run 'sudo [TMP_DIR]/bridgehead enroll' first." - exit 1 - fi - shift - exec docker compose -p bridgehead --env-file .env $compose_files -f docker-image.lock.yml $@ - ;; - enroll) - [ "$(id -u)" -ne 0 ] && echo "Enroll must be run as root." && exit 1 - enroll - ;; - update) - docker image prune -f - docker pull samply/rusthead:latest &>/dev/null || echo "Failed to pull latest rusthead image. Using latest local image." - # Mount the docker config if it exists to allow pulling from private registries if needed - docker_config=~/.docker/config.json - docker_config_mount="" - [ -f "$docker_config" ] && docker_config_mount="-v $docker_config:/root/.docker/config.json:ro" - docker run --rm \ - -v [TMP_DIR]:[TMP_DIR] \ - -v /var/run/docker.sock:/var/run/docker.sock \ - $docker_config_mount \ - -e BRIDGEHEAD_CONFIG_PATH=[TMP_DIR] \ - -u "$(id -u bridgehead):$(id -g bridgehead)" \ - samply/rusthead:latest update - ;; - *) - echo "Unknown bridgehead command '$@'" - exit 1 - ;; - esac -} -enroll() { - do_enroll() { - echo "Enrolling dummy.$1" - docker run --rm \ - -v [TMP_DIR]/pki:[TMP_DIR]/pki \ - docker.verbis.dkfz.de/cache/samply/beam-enroll:latest \ - --output-file [TMP_DIR]/pki/dummy.priv.pem \ - --proxy-id dummy.$1 - chmod 600 [TMP_DIR]/pki/dummy.priv.pem - chown bridgehead:docker [TMP_DIR]/pki/dummy.priv.pem - } - do_enroll broker.eucaim.cancerimage.eu - echo "After getting the csr enrolled you may start the bridgehead service with 'systemctl start bridgehead'." -} - -install_systemd() { - cat < /etc/systemd/system/bridgehead.service -[Unit] -Description=Bridgehead Service -Requires=docker.service - -[Service] -ExecStart=[TMP_DIR]/bridgehead compose up --abort-on-container-exit -Restart=always -User=bridgehead -Group=docker - -[Install] -WantedBy=multi-user.target -EOF - cat < /etc/systemd/system/bridgehead-update.service -[Unit] -Description=Bridgehead Update Service -Requires=docker.service - -[Service] -ExecStart=[TMP_DIR]/bridgehead update -User=bridgehead -Group=docker -ExecStopPost=+/bin/bash -c 'if [ "\$EXIT_STATUS" = "3" ]; then systemctl restart bridgehead.service; fi' -EOF - cat < /etc/systemd/system/bridgehead-update.timer -[Unit] -Description=Daily Updates at 6am of Bridgehead - -[Timer] -OnCalendar=*-*-* 06:00:00 -Persistent=true - -[Install] -WantedBy=basic.target -EOF - systemctl daemon-reload - echo "Enabling autostart of bridgehead.service" - systemctl enable bridgehead.service - echo "Enabling auto-updates for bridgehead.service ..." - systemctl enable --now bridgehead-update.timer -} - -main "$@" diff --git a/tests/snapshots/eucaim/configs@config.local.toml.snap b/tests/snapshots/eucaim/configs@config.local.toml.snap index bbb9de6..5b732b2 100644 --- a/tests/snapshots/eucaim/configs@config.local.toml.snap +++ b/tests/snapshots/eucaim/configs@config.local.toml.snap @@ -5,3 +5,5 @@ info: config.local.toml input_file: tests/configs/eucaim.toml --- seed = 42 +beam_networks = ['broker.eucaim.cancerimage.eu'] +enrolled_beam_networks = [] diff --git a/tests/snapshots/example.config/configs@bridgehead.snap b/tests/snapshots/example.config/configs@bridgehead.snap deleted file mode 100644 index d9c0805..0000000 --- a/tests/snapshots/example.config/configs@bridgehead.snap +++ /dev/null @@ -1,148 +0,0 @@ ---- -source: src/config.rs -expression: file -info: bridgehead -input_file: tests/configs/example.config.toml ---- -#!/usr/bin/env bash -set -e -set -o pipefail - -# Ensure the script is running in memory to avoid issues with self modification on update -[ "$LOADED" = 1 ] || LOADED=1 exec bash <(cat "$0") "$@" -cd [TMP_DIR] - - -main() { - case "$1" in - install) - [ "$(id -u)" -ne 0 ] && echo "Install command must be run as root." && exit 1 - useradd -M -g docker -N bridgehead &>/dev/null || echo "Using existing user bridgehead." - chown -R bridgehead:docker . - chmod -R g+sw . - sudo -u bridgehead git init -b main --shared=group - git config --global --add safe.directory [TMP_DIR] - git config --local user.email "bridgehead@samply.de" - git config --local user.name "Bridgehead" - if ! systemctl status docker &> /dev/null; then - echo "Systemd is not active or docker is not running via systemd. Skipping systemd setup." - set +e - sudo -u bridgehead ./bridgehead update - exit_code=$? - set -e - if [ "$exit_code" != "0" ] && [ "$exit_code" != "3" ]; then - echo "Failed to update bridgehead" - exit $exit_code - fi - else - install_systemd - fi - if [ -e [TMP_DIR]/pki/dummy.priv.pem ]; then - echo "Private key already exists. Skipping enrollment." - echo "If you want to re-enroll or changed the configuration and are now included in a new beam network run 'sudo [TMP_DIR]/bridgehead enroll'." - echo "Installation complete." - else - enroll - fi - ;; - logs) - shift - exec journalctl -u bridgehead -u bridgehead-update -a $@ - ;; - compose) - compose_files="$(ls services | awk '{print " -f services/" $0}')" - [ -e ./docker-compose.override.yml ] && compose_files+=" -f docker-compose.override.yml" - if [ ! -f [TMP_DIR]/pki/dummy.priv.pem ]; then - echo "Beam private key not found. Please run 'sudo [TMP_DIR]/bridgehead enroll' first." - exit 1 - fi - shift - exec docker compose -p bridgehead --env-file .env $compose_files -f docker-image.lock.yml $@ - ;; - enroll) - [ "$(id -u)" -ne 0 ] && echo "Enroll must be run as root." && exit 1 - enroll - ;; - update) - docker image prune -f - docker pull samply/rusthead:latest &>/dev/null || echo "Failed to pull latest rusthead image. Using latest local image." - # Mount the docker config if it exists to allow pulling from private registries if needed - docker_config=~/.docker/config.json - docker_config_mount="" - [ -f "$docker_config" ] && docker_config_mount="-v $docker_config:/root/.docker/config.json:ro" - docker run --rm \ - -v [TMP_DIR]:[TMP_DIR] \ - -v /var/run/docker.sock:/var/run/docker.sock \ - $docker_config_mount \ - -e BRIDGEHEAD_CONFIG_PATH=[TMP_DIR] \ - -u "$(id -u bridgehead):$(id -g bridgehead)" \ - samply/rusthead:latest update - ;; - *) - echo "Unknown bridgehead command '$@'" - exit 1 - ;; - esac -} -enroll() { - do_enroll() { - echo "Enrolling dummy.$1" - docker run --rm \ - -v [TMP_DIR]/pki:[TMP_DIR]/pki \ - docker.verbis.dkfz.de/cache/samply/beam-enroll:latest \ - --output-file [TMP_DIR]/pki/dummy.priv.pem \ - --proxy-id dummy.$1 - chmod 600 [TMP_DIR]/pki/dummy.priv.pem - chown bridgehead:docker [TMP_DIR]/pki/dummy.priv.pem - } - do_enroll broker.bbmri.de - do_enroll broker.bbmri.samply.de - do_enroll broker.ccp-it.dktk.dkfz.de - echo "After getting the csrs enrolled you may start the bridgehead service with 'systemctl start bridgehead'." -} - -install_systemd() { - cat < /etc/systemd/system/bridgehead.service -[Unit] -Description=Bridgehead Service -Requires=docker.service - -[Service] -ExecStart=[TMP_DIR]/bridgehead compose up --abort-on-container-exit -Restart=always -User=bridgehead -Group=docker - -[Install] -WantedBy=multi-user.target -EOF - cat < /etc/systemd/system/bridgehead-update.service -[Unit] -Description=Bridgehead Update Service -Requires=docker.service - -[Service] -ExecStart=[TMP_DIR]/bridgehead update -User=bridgehead -Group=docker -ExecStopPost=+/bin/bash -c 'if [ "\$EXIT_STATUS" = "3" ]; then systemctl restart bridgehead.service; fi' -EOF - cat < /etc/systemd/system/bridgehead-update.timer -[Unit] -Description=Daily Updates at 6am of Bridgehead - -[Timer] -OnCalendar=*-*-* 06:00:00 -Persistent=true - -[Install] -WantedBy=basic.target -EOF - systemctl daemon-reload - echo "Enabling autostart of bridgehead.service" - systemctl enable bridgehead.service - echo "Enabling auto-updates for bridgehead.service ..." - systemctl enable --now bridgehead-update.timer -} - -main "$@" diff --git a/tests/snapshots/example.config/configs@config.local.toml.snap b/tests/snapshots/example.config/configs@config.local.toml.snap index 5a7d8e1..1fdf09d 100644 --- a/tests/snapshots/example.config/configs@config.local.toml.snap +++ b/tests/snapshots/example.config/configs@config.local.toml.snap @@ -5,6 +5,12 @@ info: config.local.toml input_file: tests/configs/example.config.toml --- seed = 42 +beam_networks = [ + 'broker.bbmri.de', + 'broker.bbmri.samply.de', + 'broker.ccp-it.dktk.dkfz.de', +] +enrolled_beam_networks = [] [basic_auth_users.bbmri-blaze] hash = '' pw = 'test' diff --git a/tests/snapshots/minimal/configs@bridgehead.snap b/tests/snapshots/minimal/configs@bridgehead.snap deleted file mode 100644 index 6a60218..0000000 --- a/tests/snapshots/minimal/configs@bridgehead.snap +++ /dev/null @@ -1,119 +0,0 @@ ---- -source: src/config.rs -expression: file -info: bridgehead -input_file: tests/configs/minimal.toml ---- -#!/usr/bin/env bash -set -e -set -o pipefail - -# Ensure the script is running in memory to avoid issues with self modification on update -[ "$LOADED" = 1 ] || LOADED=1 exec bash <(cat "$0") "$@" -cd [TMP_DIR] - - -main() { - case "$1" in - install) - [ "$(id -u)" -ne 0 ] && echo "Install command must be run as root." && exit 1 - useradd -M -g docker -N bridgehead &>/dev/null || echo "Using existing user bridgehead." - chown -R bridgehead:docker . - chmod -R g+sw . - sudo -u bridgehead git init -b main --shared=group - git config --global --add safe.directory [TMP_DIR] - git config --local user.email "bridgehead@samply.de" - git config --local user.name "Bridgehead" - if ! systemctl status docker &> /dev/null; then - echo "Systemd is not active or docker is not running via systemd. Skipping systemd setup." - set +e - sudo -u bridgehead ./bridgehead update - exit_code=$? - set -e - if [ "$exit_code" != "0" ] && [ "$exit_code" != "3" ]; then - echo "Failed to update bridgehead" - exit $exit_code - fi - else - install_systemd - fi - echo "Installation complete." - echo "You may start the bridgehead service with 'systemctl start bridgehead' or '[TMP_DIR]/bridgehead compose up' now." - ;; - logs) - shift - exec journalctl -u bridgehead -u bridgehead-update -a $@ - ;; - compose) - compose_files="$(ls services | awk '{print " -f services/" $0}')" - [ -e ./docker-compose.override.yml ] && compose_files+=" -f docker-compose.override.yml" - shift - exec docker compose -p bridgehead --env-file .env $compose_files -f docker-image.lock.yml $@ - ;; - update) - docker image prune -f - docker pull samply/rusthead:latest &>/dev/null || echo "Failed to pull latest rusthead image. Using latest local image." - # Mount the docker config if it exists to allow pulling from private registries if needed - docker_config=~/.docker/config.json - docker_config_mount="" - [ -f "$docker_config" ] && docker_config_mount="-v $docker_config:/root/.docker/config.json:ro" - docker run --rm \ - -v [TMP_DIR]:[TMP_DIR] \ - -v /var/run/docker.sock:/var/run/docker.sock \ - $docker_config_mount \ - -e BRIDGEHEAD_CONFIG_PATH=[TMP_DIR] \ - -u "$(id -u bridgehead):$(id -g bridgehead)" \ - samply/rusthead:latest update - ;; - *) - echo "Unknown bridgehead command '$@'" - exit 1 - ;; - esac -} - -install_systemd() { - cat < /etc/systemd/system/bridgehead.service -[Unit] -Description=Bridgehead Service -Requires=docker.service - -[Service] -ExecStart=[TMP_DIR]/bridgehead compose up --abort-on-container-exit -Restart=always -User=bridgehead -Group=docker - -[Install] -WantedBy=multi-user.target -EOF - cat < /etc/systemd/system/bridgehead-update.service -[Unit] -Description=Bridgehead Update Service -Requires=docker.service - -[Service] -ExecStart=[TMP_DIR]/bridgehead update -User=bridgehead -Group=docker -ExecStopPost=+/bin/bash -c 'if [ "\$EXIT_STATUS" = "3" ]; then systemctl restart bridgehead.service; fi' -EOF - cat < /etc/systemd/system/bridgehead-update.timer -[Unit] -Description=Daily Updates at 6am of Bridgehead - -[Timer] -OnCalendar=*-*-* 06:00:00 -Persistent=true - -[Install] -WantedBy=basic.target -EOF - systemctl daemon-reload - echo "Enabling autostart of bridgehead.service" - systemctl enable bridgehead.service - echo "Enabling auto-updates for bridgehead.service ..." - systemctl enable --now bridgehead-update.timer -} - -main "$@" diff --git a/tests/snapshots/minimal/configs@config.local.toml.snap b/tests/snapshots/minimal/configs@config.local.toml.snap index a9f4b35..a2868b8 100644 --- a/tests/snapshots/minimal/configs@config.local.toml.snap +++ b/tests/snapshots/minimal/configs@config.local.toml.snap @@ -5,3 +5,5 @@ info: config.local.toml input_file: tests/configs/minimal.toml --- seed = 42 +beam_networks = [] +enrolled_beam_networks = [] diff --git a/tests/snapshots/tuning/configs@bridgehead.snap b/tests/snapshots/tuning/configs@bridgehead.snap deleted file mode 100644 index e0c4ccf..0000000 --- a/tests/snapshots/tuning/configs@bridgehead.snap +++ /dev/null @@ -1,146 +0,0 @@ ---- -source: src/config.rs -expression: file -info: bridgehead -input_file: tests/configs/tuning.toml ---- -#!/usr/bin/env bash -set -e -set -o pipefail - -# Ensure the script is running in memory to avoid issues with self modification on update -[ "$LOADED" = 1 ] || LOADED=1 exec bash <(cat "$0") "$@" -cd [TMP_DIR] - - -main() { - case "$1" in - install) - [ "$(id -u)" -ne 0 ] && echo "Install command must be run as root." && exit 1 - useradd -M -g docker -N bridgehead &>/dev/null || echo "Using existing user bridgehead." - chown -R bridgehead:docker . - chmod -R g+sw . - sudo -u bridgehead git init -b main --shared=group - git config --global --add safe.directory [TMP_DIR] - git config --local user.email "bridgehead@samply.de" - git config --local user.name "Bridgehead" - if ! systemctl status docker &> /dev/null; then - echo "Systemd is not active or docker is not running via systemd. Skipping systemd setup." - set +e - sudo -u bridgehead ./bridgehead update - exit_code=$? - set -e - if [ "$exit_code" != "0" ] && [ "$exit_code" != "3" ]; then - echo "Failed to update bridgehead" - exit $exit_code - fi - else - install_systemd - fi - if [ -e [TMP_DIR]/pki/dummy.priv.pem ]; then - echo "Private key already exists. Skipping enrollment." - echo "If you want to re-enroll or changed the configuration and are now included in a new beam network run 'sudo [TMP_DIR]/bridgehead enroll'." - echo "Installation complete." - else - enroll - fi - ;; - logs) - shift - exec journalctl -u bridgehead -u bridgehead-update -a $@ - ;; - compose) - compose_files="$(ls services | awk '{print " -f services/" $0}')" - [ -e ./docker-compose.override.yml ] && compose_files+=" -f docker-compose.override.yml" - if [ ! -f [TMP_DIR]/pki/dummy.priv.pem ]; then - echo "Beam private key not found. Please run 'sudo [TMP_DIR]/bridgehead enroll' first." - exit 1 - fi - shift - exec docker compose -p bridgehead --env-file .env $compose_files -f docker-image.lock.yml $@ - ;; - enroll) - [ "$(id -u)" -ne 0 ] && echo "Enroll must be run as root." && exit 1 - enroll - ;; - update) - docker image prune -f - docker pull samply/rusthead:latest &>/dev/null || echo "Failed to pull latest rusthead image. Using latest local image." - # Mount the docker config if it exists to allow pulling from private registries if needed - docker_config=~/.docker/config.json - docker_config_mount="" - [ -f "$docker_config" ] && docker_config_mount="-v $docker_config:/root/.docker/config.json:ro" - docker run --rm \ - -v [TMP_DIR]:[TMP_DIR] \ - -v /var/run/docker.sock:/var/run/docker.sock \ - $docker_config_mount \ - -e BRIDGEHEAD_CONFIG_PATH=[TMP_DIR] \ - -u "$(id -u bridgehead):$(id -g bridgehead)" \ - samply/rusthead:latest update - ;; - *) - echo "Unknown bridgehead command '$@'" - exit 1 - ;; - esac -} -enroll() { - do_enroll() { - echo "Enrolling dummy.$1" - docker run --rm \ - -v [TMP_DIR]/pki:[TMP_DIR]/pki \ - docker.verbis.dkfz.de/cache/samply/beam-enroll:latest \ - --output-file [TMP_DIR]/pki/dummy.priv.pem \ - --proxy-id dummy.$1 - chmod 600 [TMP_DIR]/pki/dummy.priv.pem - chown bridgehead:docker [TMP_DIR]/pki/dummy.priv.pem - } - do_enroll broker.ccp-it.dktk.dkfz.de - echo "After getting the csr enrolled you may start the bridgehead service with 'systemctl start bridgehead'." -} - -install_systemd() { - cat < /etc/systemd/system/bridgehead.service -[Unit] -Description=Bridgehead Service -Requires=docker.service - -[Service] -ExecStart=[TMP_DIR]/bridgehead compose up --abort-on-container-exit -Restart=always -User=bridgehead -Group=docker - -[Install] -WantedBy=multi-user.target -EOF - cat < /etc/systemd/system/bridgehead-update.service -[Unit] -Description=Bridgehead Update Service -Requires=docker.service - -[Service] -ExecStart=[TMP_DIR]/bridgehead update -User=bridgehead -Group=docker -ExecStopPost=+/bin/bash -c 'if [ "\$EXIT_STATUS" = "3" ]; then systemctl restart bridgehead.service; fi' -EOF - cat < /etc/systemd/system/bridgehead-update.timer -[Unit] -Description=Daily Updates at 6am of Bridgehead - -[Timer] -OnCalendar=*-*-* 06:00:00 -Persistent=true - -[Install] -WantedBy=basic.target -EOF - systemctl daemon-reload - echo "Enabling autostart of bridgehead.service" - systemctl enable bridgehead.service - echo "Enabling auto-updates for bridgehead.service ..." - systemctl enable --now bridgehead-update.timer -} - -main "$@" diff --git a/tests/snapshots/tuning/configs@config.local.toml.snap b/tests/snapshots/tuning/configs@config.local.toml.snap index dc74a56..19eda60 100644 --- a/tests/snapshots/tuning/configs@config.local.toml.snap +++ b/tests/snapshots/tuning/configs@config.local.toml.snap @@ -5,6 +5,8 @@ info: config.local.toml input_file: tests/configs/tuning.toml --- seed = 42 +beam_networks = ['broker.ccp-it.dktk.dkfz.de'] +enrolled_beam_networks = [] [basic_auth_users.ccp-blaze] hash = '' pw = 'test' diff --git a/tests/snapshots/volume-dir/configs@bridgehead.snap b/tests/snapshots/volume-dir/configs@bridgehead.snap deleted file mode 100644 index f4ac03e..0000000 --- a/tests/snapshots/volume-dir/configs@bridgehead.snap +++ /dev/null @@ -1,146 +0,0 @@ ---- -source: src/config.rs -expression: file -info: bridgehead -input_file: tests/configs/volume-dir.toml ---- -#!/usr/bin/env bash -set -e -set -o pipefail - -# Ensure the script is running in memory to avoid issues with self modification on update -[ "$LOADED" = 1 ] || LOADED=1 exec bash <(cat "$0") "$@" -cd [TMP_DIR] - - -main() { - case "$1" in - install) - [ "$(id -u)" -ne 0 ] && echo "Install command must be run as root." && exit 1 - useradd -M -g docker -N bridgehead &>/dev/null || echo "Using existing user bridgehead." - chown -R bridgehead:docker . - chmod -R g+sw . - sudo -u bridgehead git init -b main --shared=group - git config --global --add safe.directory [TMP_DIR] - git config --local user.email "bridgehead@samply.de" - git config --local user.name "Bridgehead" - if ! systemctl status docker &> /dev/null; then - echo "Systemd is not active or docker is not running via systemd. Skipping systemd setup." - set +e - sudo -u bridgehead ./bridgehead update - exit_code=$? - set -e - if [ "$exit_code" != "0" ] && [ "$exit_code" != "3" ]; then - echo "Failed to update bridgehead" - exit $exit_code - fi - else - install_systemd - fi - if [ -e [TMP_DIR]/pki/dummy.priv.pem ]; then - echo "Private key already exists. Skipping enrollment." - echo "If you want to re-enroll or changed the configuration and are now included in a new beam network run 'sudo [TMP_DIR]/bridgehead enroll'." - echo "Installation complete." - else - enroll - fi - ;; - logs) - shift - exec journalctl -u bridgehead -u bridgehead-update -a $@ - ;; - compose) - compose_files="$(ls services | awk '{print " -f services/" $0}')" - [ -e ./docker-compose.override.yml ] && compose_files+=" -f docker-compose.override.yml" - if [ ! -f [TMP_DIR]/pki/dummy.priv.pem ]; then - echo "Beam private key not found. Please run 'sudo [TMP_DIR]/bridgehead enroll' first." - exit 1 - fi - shift - exec docker compose -p bridgehead --env-file .env $compose_files -f docker-image.lock.yml $@ - ;; - enroll) - [ "$(id -u)" -ne 0 ] && echo "Enroll must be run as root." && exit 1 - enroll - ;; - update) - docker image prune -f - docker pull samply/rusthead:latest &>/dev/null || echo "Failed to pull latest rusthead image. Using latest local image." - # Mount the docker config if it exists to allow pulling from private registries if needed - docker_config=~/.docker/config.json - docker_config_mount="" - [ -f "$docker_config" ] && docker_config_mount="-v $docker_config:/root/.docker/config.json:ro" - docker run --rm \ - -v [TMP_DIR]:[TMP_DIR] \ - -v /var/run/docker.sock:/var/run/docker.sock \ - $docker_config_mount \ - -e BRIDGEHEAD_CONFIG_PATH=[TMP_DIR] \ - -u "$(id -u bridgehead):$(id -g bridgehead)" \ - samply/rusthead:latest update - ;; - *) - echo "Unknown bridgehead command '$@'" - exit 1 - ;; - esac -} -enroll() { - do_enroll() { - echo "Enrolling dummy.$1" - docker run --rm \ - -v [TMP_DIR]/pki:[TMP_DIR]/pki \ - docker.verbis.dkfz.de/cache/samply/beam-enroll:latest \ - --output-file [TMP_DIR]/pki/dummy.priv.pem \ - --proxy-id dummy.$1 - chmod 600 [TMP_DIR]/pki/dummy.priv.pem - chown bridgehead:docker [TMP_DIR]/pki/dummy.priv.pem - } - do_enroll broker.ccp-it.dktk.dkfz.de - echo "After getting the csr enrolled you may start the bridgehead service with 'systemctl start bridgehead'." -} - -install_systemd() { - cat < /etc/systemd/system/bridgehead.service -[Unit] -Description=Bridgehead Service -Requires=docker.service - -[Service] -ExecStart=[TMP_DIR]/bridgehead compose up --abort-on-container-exit -Restart=always -User=bridgehead -Group=docker - -[Install] -WantedBy=multi-user.target -EOF - cat < /etc/systemd/system/bridgehead-update.service -[Unit] -Description=Bridgehead Update Service -Requires=docker.service - -[Service] -ExecStart=[TMP_DIR]/bridgehead update -User=bridgehead -Group=docker -ExecStopPost=+/bin/bash -c 'if [ "\$EXIT_STATUS" = "3" ]; then systemctl restart bridgehead.service; fi' -EOF - cat < /etc/systemd/system/bridgehead-update.timer -[Unit] -Description=Daily Updates at 6am of Bridgehead - -[Timer] -OnCalendar=*-*-* 06:00:00 -Persistent=true - -[Install] -WantedBy=basic.target -EOF - systemctl daemon-reload - echo "Enabling autostart of bridgehead.service" - systemctl enable bridgehead.service - echo "Enabling auto-updates for bridgehead.service ..." - systemctl enable --now bridgehead-update.timer -} - -main "$@" diff --git a/tests/snapshots/volume-dir/configs@config.local.toml.snap b/tests/snapshots/volume-dir/configs@config.local.toml.snap index ffff80a..5c2b968 100644 --- a/tests/snapshots/volume-dir/configs@config.local.toml.snap +++ b/tests/snapshots/volume-dir/configs@config.local.toml.snap @@ -5,6 +5,8 @@ info: config.local.toml input_file: tests/configs/volume-dir.toml --- seed = 42 +beam_networks = ['broker.ccp-it.dktk.dkfz.de'] +enrolled_beam_networks = [] [basic_auth_users.ccp-blaze] hash = '' pw = 'test' diff --git a/tests/test_configs.rs b/tests/test_configs.rs deleted file mode 100644 index 8b13789..0000000 --- a/tests/test_configs.rs +++ /dev/null @@ -1 +0,0 @@ - From 293abf9015bb8cecc4fbf6a03d2eee1705b9d6de Mon Sep 17 00:00:00 2001 From: Threated Date: Tue, 8 Sep 2026 13:21:48 +0200 Subject: [PATCH 2/8] new update command --- Cargo.lock | 70 ++- Cargo.toml | 1 + docs/updates.md | 80 +++ src/config.rs | 4 +- src/git.rs | 450 ++++++++--------- src/install.rs | 84 +++- src/main.rs | 69 ++- src/services/mod.rs | 122 ++--- src/services/traefik.rs | 8 +- src/update.rs | 215 ++++++++ src/update_state.rs | 243 +++++++++ .../eucaim-sql/configs@.gitignore.snap | 1 + .../snapshots/eucaim/configs@.gitignore.snap | 1 + .../example.config/configs@.gitignore.snap | 1 + .../snapshots/minimal/configs@.gitignore.snap | 1 + .../snapshots/tuning/configs@.gitignore.snap | 1 + .../volume-dir/configs@.gitignore.snap | 2 + tests/update.rs | 474 ++++++++++++++++++ 18 files changed, 1480 insertions(+), 347 deletions(-) create mode 100644 docs/updates.md create mode 100644 src/update.rs create mode 100644 src/update_state.rs create mode 100644 tests/update.rs diff --git a/Cargo.lock b/Cargo.lock index f995d7d..78a7d04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -199,6 +199,15 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "blowfish" version = "0.10.0" @@ -247,7 +256,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "rand_core", ] @@ -257,7 +266,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea" dependencies = [ - "crypto-common", + "crypto-common 0.2.1", "inout", ] @@ -318,6 +327,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -327,6 +345,16 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.1" @@ -365,6 +393,16 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common 0.1.7", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -431,6 +469,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -958,6 +1006,7 @@ dependencies = [ "rcgen", "serde", "serde_json", + "sha2", "solvent", "tempfile", "toml", @@ -1059,6 +1108,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shared_child" version = "1.1.2" @@ -1374,6 +1434,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "vte" version = "0.14.1" diff --git a/Cargo.toml b/Cargo.toml index 4f9a8f5..306944f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ url = { version = "2", features = ["serde"] } clap = { version = "4.6", features = ["derive", "env"] } rcgen = "0.14.8" serde_json = "1" +sha2 = "0.10" libc = "0.2" solvent = "0.8.3" duct = "1.1.2" diff --git a/docs/updates.md b/docs/updates.md new file mode 100644 index 0000000..7833091 --- /dev/null +++ b/docs/updates.md @@ -0,0 +1,80 @@ +# Native installation updates + +Run commands against the installation directory or its selected configuration file: + +```sh +rusthead --config /srv/bridgehead/config.toml update commit +rusthead --config /srv/bridgehead/config.toml update sync +``` + +`update commit` accepts your edited inputs, regenerates service definitions and +image pins, pulls the pinned images, and commits all nonignored installation +changes together. This includes staged, unstaged, and unrelated nonignored files; +use a dedicated installation repository. Customize configuration or +`docker-compose.override.yml`, rather than generated service files or `.env`. +Manual generated-file edits are rejected and preserved. + +`update sync` requires a clean repository and unchanged local inputs. It is the +command used by the daily timer. Pending edits to ignored configuration, +certificates, keys, or overrides require an explicit `update commit` too. The +first update initializes a shared local Git repository and accepts the existing +inputs. An existing repository must be rooted at the installation directory; +worktrees are supported. A configured `volume_dir` inside the installation is +excluded from Git and input fingerprints. Use a dedicated subdirectory (such as +`./data`) or an external directory, not the installation root or an ancestor. +Previously tracked runtime data must be untracked before updating. + +## Remote synchronization + +Set `git_sync = true` at the top level of the configuration to enable network Git +operations. The default is `false`, even when a remote exists. Configure the +current branch's upstream with Git before enabling synchronization. + +With synchronization enabled, `update sync` fetches the upstream branch and +fast-forwards before generation. A locally ahead branch can proceed. Both update +commands push successful local commits to the upstream; `update commit` never +pulls. The setting loaded at command start controls that invocation, even if a +pull changes it. + +Divergent histories require an administrator to reconcile them using Git. +Rusthead never stashes, rebases, merges divergent branches, resets, or force-pushes. +A failed push preserves the local update and commit; retry with `update sync` +when the remote is reachable and the histories permit synchronization. + +## Local state and recovery + +Ignored `.rusthead/` metadata stores fingerprints and an update lock. It contains +no copies of secret contents and is accessible only to the installation owner +and group. Runtime change detection includes generated files, pinned image +versions, environment values, overrides, and certificates; a config-only commit +need not request a restart. No-op or ignored-only changes create no empty commits. + +Generation renders all service templates before replacing service files, but the +whole update is not a filesystem transaction: certificate creation, output +writes, or a fast-forward can remain after a later failure. No successful update +commit is made when generation or image pulling fails. After an image failure, +`update commit` can retry outputs recorded by the failed run, provided nobody has +edited those outputs. Earlier write failures require inspecting partial outputs. + +A migrated installation without a fingerprint baseline establishes one on its +first successful update and conservatively requests a restart. Untracked legacy +`.env` edits cannot be identified before this baseline exists. + +Compose commands warn about pending inputs but remain usable, including diagnostics +and shutdown with invalid source configuration. Generation, image pulling, and +Compose commands all use `docker compose`; pulling and launching include the +image lockfile after the override file. + +## Automation status + +| Exit status | Meaning | +| --- | --- | +| 0 | Successful update; runtime artifacts unchanged | +| 3 | Successful update; restart needed | +| 4 | Local update succeeded and needs restart, but push failed | +| 1 | Other update failure, or push failure without runtime changes | + +The generated systemd update service accepts status 3 as success and requests a +restart for 3 or 4. Status 4 remains a service failure so the push problem stays +visible. Standalone enrollment changes are accepted with `update commit`; +installation accepts its enrollment changes before enabling the timer. diff --git a/src/config.rs b/src/config.rs index fd1dcdf..ad7789b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -29,7 +29,9 @@ pub struct Config { pub image: String, /// Defaults to docker named volumes pub volume_dir: Option, - pub git_sync: Option, + /// Explicitly enable upstream synchronization during updates. + #[serde(default)] + pub git_sync: bool, pub https_proxy_url: Option, pub ccp: Option, pub bbmri: Option, diff --git a/src/git.rs b/src/git.rs index f44c594..cd1ebad 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,265 +1,253 @@ +//! Git history and transport. Generation and runtime change detection live elsewhere. +use anyhow::{Context, ensure}; use std::{ - collections::HashMap, - fs, - hash::{DefaultHasher, Hasher}, - process::Command, + ffi::OsString, + path::{Path, PathBuf}, + process::Output, }; -use anyhow::Context; - -use crate::config::Config; - -fn is_git_repo(conf: &Config) -> bool { - fs::metadata(conf.path.join(".git")).map_or(false, |meta| meta.is_dir()) -} - -type LocalDiffHashes = HashMap; - -pub struct DiffTracker<'a> { - conf: &'a Config, - before_hashes: LocalDiffHashes, - stashed_changes: Option, -} - -pub enum DiffTrackerResult<'a> { - Success(DiffTracker<'a>), - NeedsConfigReload, - NotAGitRepo, +pub struct Repository { + pub root: PathBuf, } -impl<'a> DiffTracker<'a> { - pub fn start(conf: &'a Config) -> anyhow::Result> { - // Required for git to create the files in the shared repository with group write permissions - unsafe { libc::umask(0o0002) }; - if !is_git_repo(conf) { - println!("Directory is not a git repository yet skipping diff tracking"); - return Ok(DiffTrackerResult::NotAGitRepo); - } - let tmp_self = Self { - conf, - before_hashes: LocalDiffHashes::default(), - stashed_changes: None, +impl Repository { + pub fn open(root: &Path) -> anyhow::Result> { + let repo = Self { + root: root.to_owned(), }; - let git_diff = tmp_self.get_modified()?; - let stashed_changes = if !git_diff.is_empty() { - if tmp_self.is_initial_commit()? { - println!("No initial commit yet not stashing changes"); - None - } else { - tmp_self.stash_all()?; - Some(git_diff) - } - } else { - None - }; - if conf.git_sync.unwrap_or_else(|| tmp_self.has_remote()) { - let repo_hash_before = tmp_self.head_hash()?.stdout; - println!("Pulling changes from remote"); - tmp_self.pull()?; - let repo_hash_after = tmp_self.head_hash()?.stdout; - if repo_hash_before != repo_hash_after { - return Ok(DiffTrackerResult::NeedsConfigReload); - } - } - Ok(DiffTrackerResult::Success(Self { - stashed_changes, - before_hashes: tmp_self - .hash_untracked_files() - .context("Failed to start tracking local files")?, - ..tmp_self - })) - } - - fn git_command(&self) -> Command { - let mut cmd = Command::new("git"); - cmd.current_dir(&self.conf.path); - cmd - } - - fn get_modified(&self) -> anyhow::Result { - let status = self - .git_command() - .arg("status") - .arg("--porcelain") - .output()?; - if !status.status.success() { - anyhow::bail!( - "Failed to get status: {}", - String::from_utf8_lossy(&status.stderr) + // Do not silently join an enclosing repository, including through worktrees. + let out = repo.output(&["rev-parse", "--show-toplevel"])?; + if !out.status.success() { + ensure!( + !root.join(".git").exists(), + "Cannot read installation Git repository: {}", + String::from_utf8_lossy(&out.stderr) ); + // A failed discovery (e.g. dubious ownership) must not trigger reinitialization. + ensure!( + String::from_utf8_lossy(&out.stderr).contains("not a git repository"), + "Git discovery failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + return Ok(None); } - let files = String::from_utf8_lossy(&status.stdout); - Ok(files.into_owned()) + let top = PathBuf::from(String::from_utf8(out.stdout)?.trim_end_matches('\n')); + ensure!( + top.canonicalize()? == root.canonicalize()?, + "The installation directory must be the Git repository root" + ); + Ok(Some(repo)) } - fn hash_untracked_files(&self) -> anyhow::Result { - let status = self - .git_command() - .args(["ls-files", "--others", "--exclude-standard", "--ignored"]) - .output()?; - if !status.status.success() { - anyhow::bail!( - "Failed to get untracked files: {}", - String::from_utf8_lossy(&status.stderr) - ); - } - let output = String::from_utf8_lossy(&status.stdout); - let mut hash_map = LocalDiffHashes::default(); - for file_path in output.lines() { - let mut hasher = DefaultHasher::new(); - let path = self.conf.path.join(file_path); - let file = fs::read(&path) - .with_context(|| format!("Failed to read file: `{}`", path.display()))?; - hasher.write(&file); - hash_map.insert(file_path.to_string(), hasher.finish()); + pub fn initialize(root: &Path) -> anyhow::Result { + let repo = match Self::open(root)? { + Some(repo) => repo, + None => { + let repo = Self { + root: root.to_owned(), + }; + repo.run(&["init", "-b", "main", "--shared=group"])?; + repo + } + }; + for (key, fallback) in [ + ("user.name", "Bridgehead"), + ("user.email", "bridgehead@samply.de"), + ] { + if !repo.output(&["config", "--get", key])?.status.success() { + repo.run(&["config", "--local", key, fallback])?; + } } - Ok(hash_map) + Ok(repo) } - fn is_initial_commit(&self) -> anyhow::Result { - Ok(!self.head_hash()?.status.success()) + pub fn output(&self, args: &[&str]) -> anyhow::Result { + duct::cmd("git", args) + .env("LC_ALL", "C") + .dir(&self.root) + .stdout_capture() + .stderr_capture() + .unchecked() + .run() + .context("Failed to execute git") } - - fn head_hash(&self) -> anyhow::Result { + pub fn run(&self, args: &[&str]) -> anyhow::Result> { + let out = self.output(args)?; + ensure!( + out.status.success(), + "git {} failed: {}{}", + args.join(" "), + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + Ok(out.stdout) + } + pub fn has_head(&self) -> anyhow::Result { Ok(self - .git_command() - .arg("rev-parse") - .arg("--verify") - .arg("HEAD") - .output()?) + .output(&["rev-parse", "--verify", "HEAD"])? + .status + .success()) } - - fn stash_all(&self) -> anyhow::Result<()> { - println!("Stashing untracked changes:\n{}", self.get_modified()?); - let status = self - .git_command() - .args(["stash", "push", "-m", "auto-stash", "--include-untracked"]) - .output()?; - if !status.status.success() { - anyhow::bail!( - "Failed to stash changes: {}", - String::from_utf8_lossy(&status.stderr) - ); + pub fn head(&self) -> anyhow::Result> { + if self.has_head()? { + self.run(&["rev-parse", "HEAD"]) + } else { + Ok(Vec::new()) } - Ok(()) } - - fn git_add_all(&self) -> anyhow::Result<()> { - let status = self.git_command().arg("add").arg(".").output()?; - if !status.status.success() { - anyhow::bail!( - "Failed to add changes: {}", - String::from_utf8_lossy(&status.stderr) + pub fn ensure_idle(&self) -> anyhow::Result<()> { + for marker in [ + "MERGE_HEAD", + "CHERRY_PICK_HEAD", + "REVERT_HEAD", + "rebase-merge", + "rebase-apply", + "sequencer", + ] { + let path = self.run(&["rev-parse", "--git-path", marker])?; + let path = String::from_utf8(path)?; + ensure!( + !self.root.join(path.trim_end()).exists(), + "Finish or abort the current Git operation before updating ({marker})" ); } + ensure!( + self.run(&["ls-files", "-u"])?.is_empty(), + "Resolve Git conflicts before updating" + ); + ensure!( + self.run(&["ls-files", "--", ".rusthead"])?.is_empty(), + "Internal .rusthead state must not be tracked by Git" + ); + ensure!( + self.run(&[ + "ls-files", + "--", + ".env", + "config.local.toml", + "pki", + "trusted-ca-certs", + "traefik-tls" + ])? + .is_empty(), + "Local credentials and certificates must be untracked before updating" + ); Ok(()) } - - /// Commit all changes to git. Return true if there were any changes to local or git tracked files. - pub fn commit(self) -> anyhow::Result { - let git_diff = self.get_modified()?; - let after_hashes = self.hash_untracked_files()?; - let local_diff = compute_local_file_diff(&self.before_hashes, &after_hashes); - let local_diff_str = local_diff - .iter() - .map(|(file, changed)| format!("{changed} {file}")) - .collect::>() - .join("\n"); - let mut cmd = self.git_command(); - cmd.arg("commit").arg("-m"); - match (git_diff.is_empty(), local_diff.is_empty()) { - (true, true) => { - cmd.arg("Nothing changed"); - cmd.arg("--allow-empty"); - } - (true, false) => { - cmd.arg(format!( - "Only local files changed\n\nlocal:\n{local_diff_str}" - )); - cmd.arg("--allow-empty"); - } - (false, true) => { - self.git_add_all()?; - cmd.arg(format!("Git files changed\n\ngit:\n{git_diff}")); - } - (false, false) => { - self.git_add_all()?; - cmd.arg(format!( - "Local files and git changed\n\ngit:\n{git_diff}\nlocal:\n{local_diff_str}" - )); - } - } - if let Some(ref stashed_changes) = self.stashed_changes { - cmd.arg("-m") - .arg(format!("stashed changes:\n{stashed_changes}")); - } - let status = cmd.output()?; - if !status.status.success() { - anyhow::bail!( - "Failed to commit changes: {}", - String::from_utf8_lossy(&status.stdout) - ); - } - if self.conf.git_sync.unwrap_or_else(|| self.has_remote()) { - println!("Pushing changes to remote"); - self.push()?; - } - Ok(!(git_diff.is_empty() && local_diff.is_empty())) + pub fn dirty(&self) -> anyhow::Result { + Ok(!self + .run(&[ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--", + ".", + ":(exclude).rusthead", + ])? + .is_empty()) } - - fn has_remote(&self) -> bool { - self.git_command() - .arg("remote") - .output() - .is_ok_and(|output| output.status.success() && !output.stdout.is_empty()) + pub fn generated_dirty(&self) -> anyhow::Result { + Ok(!self + .run(&[ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--", + "services", + "docker-image.lock.yml", + ])? + .is_empty()) } - - fn pull(&self) -> anyhow::Result<()> { - let output = self.git_command().arg("pull").arg("--rebase").output()?; - if !output.status.success() { - anyhow::bail!( - "Failed to pull changes: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - Ok(()) + pub fn input_paths(&self) -> anyhow::Result> { + use std::os::unix::ffi::OsStringExt; + let files = self.run(&[ + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + ])?; + Ok(files + .split(|b| *b == 0) + .filter(|p| !p.is_empty()) + .map(|p| PathBuf::from(OsString::from_vec(p.to_vec()))) + .filter(|p| { + !p.starts_with("services") + && !p.starts_with(".rusthead") + && p != Path::new("docker-image.lock.yml") + && p != Path::new(".env") + }) + .collect()) } - - fn push(&self) -> anyhow::Result<()> { - let output = self.git_command().arg("push").output()?; - if !output.status.success() { + pub fn upstream(&self) -> anyhow::Result<(String, String)> { + let branch = self + .run(&["symbolic-ref", "--quiet", "--short", "HEAD"]) + .context("git_sync requires a branch with an upstream")?; + let branch = String::from_utf8(branch)?.trim().to_owned(); + let remote = String::from_utf8( + self.run(&["config", "--get", &format!("branch.{branch}.remote")]) + .context("git_sync requires a configured upstream")?, + )? + .trim() + .to_owned(); + let target = String::from_utf8( + self.run(&["config", "--get", &format!("branch.{branch}.merge")]) + .context("git_sync requires a configured upstream")?, + )? + .trim() + .to_owned(); + ensure!( + !remote.is_empty() && !remote.starts_with('-') && target.starts_with("refs/heads/"), + "Unsupported Git upstream configuration" + ); + Ok((remote, target)) + } + pub fn sync(&self, upstream: &(String, String)) -> anyhow::Result<()> { + self.run(&["fetch", &upstream.0, &upstream.1])?; + if !self.has_head()? { anyhow::bail!( - "Failed to push changes: {}", - String::from_utf8_lossy(&output.stderr) + "Create the initial local commit with update commit before pulling an upstream" ); } + if self + .output(&["merge-base", "--is-ancestor", "FETCH_HEAD", "HEAD"])? + .status + .success() + { + return Ok(()); // equal or locally ahead: publish after generation + } + ensure!( + self.output(&["merge-base", "--is-ancestor", "HEAD", "FETCH_HEAD"])? + .status + .success(), + "Local and upstream histories diverged; reconcile them manually and retry" + ); + self.run(&["merge", "--ff-only", "FETCH_HEAD"])?; Ok(()) } -} - -fn compute_local_file_diff<'a>( - before_hashes: &'a LocalDiffHashes, - after_hashes: &'a LocalDiffHashes, -) -> HashMap<&'a str, char> { - let mut diff = HashMap::new(); - - for (file, &before_hash) in before_hashes { - if let Some(&after_hash) = after_hashes.get(file) { - if before_hash == after_hash { - continue; - } else { - diff.insert(file.as_str(), 'M'); - } - } else { - diff.insert(file.as_str(), 'D'); + pub fn commit(&self) -> anyhow::Result<()> { + self.run(&["add", "-A", "--", "."])?; + let diff = self.output(&["diff", "--cached", "--quiet"])?; + match diff.status.code() { + Some(0) if self.has_head()? => return Ok(()), + Some(0 | 1) => {} + _ => anyhow::bail!( + "Failed to inspect staged changes: {}", + String::from_utf8_lossy(&diff.stderr) + ), } + self.run(&[ + "commit", + "--allow-empty", + "-m", + "Update bridgehead configuration", + ])?; + Ok(()) } - - for file in after_hashes.keys() { - if !before_hashes.contains_key(file) { - diff.insert(file.as_str(), 'A'); - } + pub fn push(&self, upstream: &(String, String)) -> anyhow::Result<()> { + self.run(&["push", &upstream.0, &format!("HEAD:{}", upstream.1)])?; + Ok(()) } - - diff } diff --git a/src/install.rs b/src/install.rs index ddca3a3..d0b2822 100644 --- a/src/install.rs +++ b/src/install.rs @@ -85,37 +85,29 @@ pub fn install(config: &PathBuf) -> anyhow::Result { install_systemd(Path::new("/etc/systemd/system"), &executable, config)?; cmd!("systemctl", "daemon-reload").run()?; cmd!("systemctl", "enable", "bridgehead.service").run()?; - cmd!("systemctl", "enable", "--now", "bridgehead-update.timer").run()?; } else { println!( "Systemd is not active or docker is not running via systemd. Skipping systemd setup." ); - let status = cmd!( - "sudo", - "-u", - "bridgehead", - &executable, - "--config", - config, - "update" - ) - .dir(&conf.path) - .unchecked() - .run() - .context("Failed to run bridgehead update")? - .status; - match status.code() { - Some(0 | 3) => {} - Some(code) => { - eprintln!("Failed to update bridgehead"); - return Ok(ExitCode::from(u8::try_from(code)?)); - } - None => bail!("Bridgehead update was killed by a signal"), - } + } + let status = run_update(&executable, config, &conf.path)?; + if !matches!(status, 0 | 3) { + return Ok(ExitCode::from(status)); } // Update may have changed local credentials; do not overwrite them with our earlier copy. let conf = load_materialized(config)?; + let needs_enrollment = !conf.local_conf.borrow().pending_beam_networks().is_empty(); enroll_pending_networks(conf)?; + if needs_enrollment { + // Accept enrollment's local inputs before the clean-only timer starts. + let status = run_update(&executable, config, &conf.path)?; + if !matches!(status, 0 | 3) { + return Ok(ExitCode::from(status)); + } + } + if systemd { + cmd!("systemctl", "enable", "--now", "bridgehead-update.timer").run()?; + } println!("Installation complete."); println!( "Start with 'systemctl start bridgehead' or 'rusthead --config {} compose up'.", @@ -124,6 +116,33 @@ pub fn install(config: &PathBuf) -> anyhow::Result { Ok(ExitCode::SUCCESS) } +fn run_update(executable: &Path, config: &Path, directory: &Path) -> anyhow::Result { + let status = cmd!( + "sudo", + "-u", + "bridgehead", + executable, + "--config", + config, + "update", + "commit" + ) + .dir(directory) + .unchecked() + .run() + .context("Failed to run bridgehead update")? + .status; + match status.code() { + Some(code) => { + if !matches!(code, 0 | 3) { + eprintln!("Failed to update bridgehead"); + } + Ok(u8::try_from(code)?) + } + None => bail!("Bridgehead update was killed by a signal"), + } +} + fn private_key(conf: &Config) -> PathBuf { conf.path.join(format!("pki/{}.priv.pem", conf.site_id)) } @@ -177,9 +196,18 @@ fn configure_git(conf: &Config) -> anyhow::Result<()> { ("user.email", "bridgehead@samply.de"), ("user.name", "Bridgehead"), ] { - cmd!("git", "config", "--local", "--replace-all", key, value) + if !cmd!("git", "config", "--get", key) .dir(&conf.path) - .run()?; + .stdout_null() + .unchecked() + .run()? + .status + .success() + { + cmd!("git", "config", "--local", key, value) + .dir(&conf.path) + .run()?; + } } for key in ["http.proxy", "https.proxy"] { if let Some(proxy) = &conf.https_proxy_url { @@ -229,7 +257,7 @@ fn install_systemd(directory: &Path, executable: &Path, config: &Path) -> anyhow let command = format!("{} --config {}", unit_arg(executable)?, unit_arg(config)?); let units = [ ("bridgehead.service", format!("[Unit]\nDescription=Bridgehead Service\nRequires=docker.service\n\n[Service]\nExecStart={command} compose up --abort-on-container-exit\nRestart=always\nUser=bridgehead\nGroup=docker\n\n[Install]\nWantedBy=multi-user.target\n")), - ("bridgehead-update.service", format!("[Unit]\nDescription=Bridgehead Update Service\nRequires=docker.service\n\n[Service]\nExecStart={command} update\nUser=bridgehead\nGroup=docker\nExecStopPost=+/bin/bash -c 'if [ \"$$EXIT_STATUS\" = \"3\" ]; then systemctl restart bridgehead.service; fi'\n")), + ("bridgehead-update.service", format!("[Unit]\nDescription=Bridgehead Update Service\nRequires=docker.service\n\n[Service]\nExecStart={command} update sync\nSuccessExitStatus=3\nUser=bridgehead\nGroup=docker\nExecStopPost=+/bin/bash -c 'if [ \"$$EXIT_STATUS\" = \"3\" ] || [ \"$$EXIT_STATUS\" = \"4\" ]; then systemctl restart bridgehead.service; fi'\n")), ("bridgehead-update.timer", "[Unit]\nDescription=Daily Updates at 6am of Bridgehead\n\n[Timer]\nOnCalendar=*-*-* 06:00:00\nPersistent=true\n\n[Install]\nWantedBy=basic.target\n".into()), ]; fs::create_dir_all(directory)?; @@ -382,6 +410,10 @@ mod tests { .unwrap() .contains("$$EXIT_STATUS") ); + let update_unit = + fs::read_to_string(temp.path().join("bridgehead-update.service")).unwrap(); + assert!(update_unit.contains(" update sync\nSuccessExitStatus=3\n")); + assert!(update_unit.contains("= \"4\"")); assert!( fs::read_to_string(temp.path().join("bridgehead-update.timer")) .unwrap() diff --git a/src/main.rs b/src/main.rs index 623eb6d..9c20818 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,13 +7,14 @@ use anyhow::Context; use clap::Parser; use config::Config; use duct::cmd; -use services::ServiceMap; mod config; mod git; mod install; mod modules; mod services; +mod update; +mod update_state; mod utils; #[derive(Debug, clap::Subcommand)] @@ -22,7 +23,11 @@ enum Subcommand { #[clap(trailing_var_arg = true)] compose_args: Vec, }, - Update, + /// Generate and record an installation update. + Update { + #[clap(subcommand)] + mode: update::Mode, + }, Bootstrap, Enroll, Install, @@ -57,14 +62,18 @@ fn main() -> anyhow::Result { std::env::set_current_dir(config_dir)?; let cwd = std::env::current_dir()?; match &args.command { - Subcommand::Compose { compose_args } => compose_command(&cwd, compose_args)? - .run() - .context("Failed to run docker compose")? - .status - .code() - .and_then(|c| Some(ExitCode::from(u8::try_from(c).ok()?))) - .ok_or(anyhow::anyhow!("Killed by signal")), - Subcommand::Update => update(&config), + Subcommand::Compose { compose_args } => { + update::warn_pending(&cwd, &config); + compose_command(&cwd, compose_args)? + .unchecked() + .run() + .context("Failed to run docker compose")? + .status + .code() + .and_then(|c| Some(ExitCode::from(u8::try_from(c).ok()?))) + .ok_or(anyhow::anyhow!("Killed by signal")) + } + Subcommand::Update { mode } => update::run(&config, *mode), Subcommand::Bootstrap => todo!("Not implemented"), Subcommand::Enroll => install::enroll(&config), Subcommand::Install => install::install(&config), @@ -72,29 +81,43 @@ fn main() -> anyhow::Result { } fn compose_command(config_dir: &Path, compose_args: &[String]) -> anyhow::Result { + compose_command_with_lock(config_dir, compose_args, true) +} + +fn compose_command_with_lock( + config_dir: &Path, + compose_args: &[String], + locked: bool, +) -> anyhow::Result { let mut services = config_dir .join("services") .read_dir()? .map(|entry| entry.map(|entry| entry.path())) .collect::, _>>()?; + services.retain(|p| { + p.is_file() && matches!(p.extension().and_then(|s| s.to_str()), Some("yml" | "yaml")) + }); services.sort(); - let mut args = vec!["compose".into(), "-p".into(), "bridgehead".into()]; + for name in ["docker-compose.override.yml", "docker-image.lock.yml"] { + if (locked || name != "docker-image.lock.yml") && config_dir.join(name).is_file() { + services.push(config_dir.join(name)); + } + } + let mut args = vec![ + "compose".into(), + "-p".into(), + "bridgehead".into(), + "--project-directory".into(), + config_dir.as_os_str().to_owned(), + ]; for service in services { args.push(std::ffi::OsString::from("-f")); args.push(service.into_os_string()); } + args.extend([ + std::ffi::OsString::from("--env-file"), + config_dir.join(".env").into_os_string(), + ]); args.extend(compose_args.iter().map(std::ffi::OsString::from)); Ok(cmd("docker", args).dir(config_dir)) } - -pub fn update(config: &PathBuf) -> anyhow::Result { - let conf = - Config::load(config).with_context(|| format!("Failed to load config from {config:?}"))?; - let conf: &'static Config = Box::leak(Box::new(conf)); - let mut services = ServiceMap::new(conf); - modules::MODULES - .iter() - .for_each(|&m| services.install_module(m)); - services.write_all()?; - Ok(ExitCode::SUCCESS) -} diff --git a/src/services/mod.rs b/src/services/mod.rs index 3c45f83..d8431a7 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -251,64 +251,81 @@ impl ServiceMap { self.map.len() } + #[cfg(test)] pub fn write_all(&mut self) -> anyhow::Result<()> { + self.write_all_checked(|| Ok(())) + } + + pub fn write_all_checked( + &mut self, + check_inputs: impl FnOnce() -> anyhow::Result<()>, + ) -> anyhow::Result<()> { self.materialize(); - self.write_composables() - .context("Failed to write services")?; + // Render everything before replacing the previous service definitions. + let rendered = self + .map + .values() + .map(|service| Ok((service.service_name(), service.render(self.config)?))) + .collect::>>()?; + check_inputs()?; + let services_dir = self.config.path.join("services"); + fs::create_dir_all(&services_dir)?; + let expected: std::collections::HashSet<_> = rendered + .iter() + .map(|(name, _)| format!("{name}.yml")) + .collect(); + for (name, contents) in rendered { + fs::write(services_dir.join(format!("{name}.yml")), contents)?; + } + for entry in fs::read_dir(&services_dir)? { + let entry = entry?; + if !expected.contains(&entry.file_name().to_string_lossy().into_owned()) { + anyhow::ensure!( + !entry.file_type()?.is_dir(), + "Unexpected directory in generated services: {}", + entry.path().display() + ); + fs::remove_file(entry.path())?; + } + } self.config.write_local_conf()?; - fs::write( - self.config.path.join(".gitignore"), - include_str!("../../static/.gitignore"), - )?; - #[cfg(not(test))] - self.generate_lockfile_and_pull() - .context("Failed to generate lockfile and pull images")?; + crate::update_state::ensure_ignore(self.config)?; Ok(()) } - #[cfg(not(test))] - fn generate_lockfile_and_pull(&self) -> anyhow::Result<()> { + pub(crate) fn generate_lockfile_and_pull( + &self, + checkpoint: impl FnOnce() -> anyhow::Result<()>, + ) -> anyhow::Result<()> { if self.map.is_empty() { + match fs::remove_file(self.config.path.join("docker-image.lock.yml")) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + checkpoint()?; return Ok(()); } - use std::process::Command; - let mut cmd = Command::new("docker-compose"); - let mut pull_cmd = Command::new("docker-compose"); - for service in self.map.values() { - let path = self - .config - .path - .join("services") - .join(format!("{}.yml", service.service_name())); - cmd.arg("-f").arg(&path); - pull_cmd.arg("-f").arg(&path); - } - if fs::exists(self.config.path.join("docker-compose.override.yml"))? { - cmd.arg("-f").arg("docker-compose.override.yml"); - pull_cmd.arg("-f").arg("docker-compose.override.yml"); - } - cmd.args(["--env-file", ".env", "config", "--lock-image-digests"]) - .current_dir(&self.config.path); - pull_cmd - .args(["--env-file", ".env", "pull", "--quiet"]) - .current_dir(&self.config.path); - let output = cmd.output()?; - if !output.status.success() { - anyhow::bail!( - "Failed to generate lockfile: {}", - String::from_utf8_lossy(&output.stderr) - ); - } + let output = crate::compose_command_with_lock( + &self.config.path, + &["config".into(), "--lock-image-digests".into()], + false, + )? + .stdout_capture() + .stderr_capture() + .run() + .context("Failed to resolve image digests")?; fs::write( self.config.path.join("docker-image.lock.yml"), output.stdout, )?; - if !pull_cmd.status()?.success() { - anyhow::bail!( - "Failed to pull images: {}", - String::from_utf8_lossy(&output.stderr) - ); - } + checkpoint()?; + crate::compose_command(&self.config.path, &["config".into(), "--quiet".into()])? + .run() + .context("Failed to validate pinned Compose configuration")?; + crate::compose_command(&self.config.path, &["pull".into(), "--quiet".into()])? + .run() + .context("Failed to pull pinned images")?; Ok(()) } @@ -349,21 +366,6 @@ impl ServiceMap { m.install(self, &self.config); } - fn write_composables(&self) -> anyhow::Result<()> { - let services_dir = self.config.path.join("services"); - _ = fs::remove_dir_all(&services_dir); - fs::create_dir_all(&services_dir)?; - for service in self.map.values() { - let service_name = service.service_name(); - eprintln!("Generating service {service_name}"); - fs::write( - services_dir.join(format!("{}.yml", service.service_name())), - service.render(self.config)?, - )?; - } - Ok(()) - } - pub(crate) fn materialize(&mut self) { let deps = std::mem::take(&mut self.deps); for dep in deps.dependencies_of(&Self::ROOT_NODE).unwrap() { diff --git a/src/services/traefik.rs b/src/services/traefik.rs index 88f1dbd..1afecf8 100644 --- a/src/services/traefik.rs +++ b/src/services/traefik.rs @@ -11,13 +11,13 @@ use super::Service; #[derive(Debug, Deserialize)] pub struct TraefikConfig { - tls: Option, + pub(crate) tls: Option, } #[derive(Debug, Deserialize, Clone)] -struct TlsConfig { - cert_file: PathBuf, - key_file: PathBuf, +pub(crate) struct TlsConfig { + pub(crate) cert_file: PathBuf, + pub(crate) key_file: PathBuf, } #[derive(Debug, Template)] diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 0000000..746bbf2 --- /dev/null +++ b/src/update.rs @@ -0,0 +1,215 @@ +use crate::{ + Config, + git::Repository, + modules, + services::ServiceMap, + update_state::{self as state, Fingerprints, OUTPUTS, State, UpdateLock}, +}; +use anyhow::{Context, ensure}; +use std::{ + path::{Path, PathBuf}, + process::ExitCode, +}; + +#[derive(Debug, Clone, Copy, clap::Subcommand)] +pub enum Mode { + /// Require clean inputs, pull upstream changes if enabled, and update generated files. + Sync, + /// Accept local input edits and commit them together with updated generated files. + Commit, +} + +fn inputs(repo: &Repository, conf: &Config, config: &Path) -> anyhow::Result { + let mut paths = repo.input_paths()?; + if let Some(volume) = state::volume_path(conf)? { + paths.retain(|path| !repo.root.join(path).starts_with(&volume)); + if let Ok(relative) = volume.strip_prefix(&repo.root) { + ensure!( + repo.run(&[ + "ls-files", + "--", + relative + .to_str() + .context("Volume directory must be UTF-8")? + ])? + .is_empty(), + "Runtime data is tracked in Git; untrack the volume directory before updating" + ); + } + } + paths.push(config.to_owned()); + paths.extend(state::local_paths(conf)); + state::fingerprint(&repo.root, paths) +} +fn snapshot(repo: &Repository, conf: &Config, config: &Path) -> anyhow::Result { + let local_inputs = state::fingerprint(&repo.root, state::local_paths(conf))?; + let outputs = state::fingerprint(&repo.root, OUTPUTS.iter().map(PathBuf::from))?; + let mut runtime = outputs.clone(); + // config.local is a generation input. Its derived .env is the runtime artifact. + runtime.extend( + local_inputs + .iter() + .filter(|(key, _)| key.as_str() != "config.local.toml") + .map(|(k, v)| (k.clone(), v.clone())), + ); + Ok(State { + inputs: inputs(repo, conf, config)?, + local_inputs, + outputs, + runtime, + }) +} + +pub fn run(config: &PathBuf, mode: Mode) -> anyhow::Result { + let config = if config.is_dir() { + config.join("config.toml") + } else { + config.clone() + }; + let root = config + .parent() + .context("Configuration has no parent directory")?; + // Match the shared repository and installation permissions, including new generated files. + unsafe { libc::umask(0o0002) }; + // Check repository boundaries before creating installation metadata. + Repository::open(root)?; + let _lock = UpdateLock::acquire(root)?; + let repo = Repository::initialize(root)?; + repo.ensure_idle()?; + let initial = !repo.has_head()?; + let baseline = State::load(root)?; + let conf = Config::load(&config)?; + let network = if conf.git_sync { + Some(repo.upstream()?) + } else { + None + }; + let before = snapshot(&repo, &conf, &config)?; + if !initial { + match mode { + Mode::Sync => { + ensure!( + !repo.dirty()?, + "Repository has pending changes; run update commit to accept them before update sync" + ); + if let Some(ref baseline) = baseline { + ensure!( + before.local_inputs == baseline.local_inputs + && before.outputs.get(".env") == baseline.outputs.get(".env"), + "Local inputs or generated files changed; run update commit to accept input edits" + ); + } + } + Mode::Commit => { + ensure!( + !repo.generated_dirty()? || state::pending_matches(root, &before.outputs)?, + "Generated files were edited; move customization into config or docker-compose.override.yml and restore generated files before updating" + ); + if let Some(ref baseline) = baseline { + // Git handles tracked outputs; .env is ignored and must be checked separately. + ensure!( + before.outputs.get(".env") == baseline.outputs.get(".env") + || state::pending_matches(root, &before.outputs)?, + "Generated .env was edited; move changes into config.local.toml and restore .env before updating" + ); + } + } + } + } + if matches!(mode, Mode::Sync) + && let Some(ref upstream) = network + { + repo.sync(upstream)?; + repo.ensure_idle()?; + } + // Pulling may have changed both the configuration and its local path settings. + let conf = Box::leak(Box::new(Config::load(&config)?)); + state::ensure_ignore(conf)?; + let start_inputs = inputs(&repo, conf, &config)?; + let head = repo.head()?; + let index = repo.run(&["ls-files", "--stage", "-z"])?; + let mut services = ServiceMap::new(conf); + for module in modules::MODULES { + services.install_module(*module); + } + services.write_all_checked(|| { + let mut now = inputs(&repo, conf, &config)?; + // Constructors may create missing certificates. Existing inputs must remain unchanged. + now.retain(|key, _| start_inputs.contains_key(key) || !["pki/", "trusted-ca-certs/", "traefik-tls/"].iter().any(|prefix| key.starts_with(prefix))); + ensure!(now == start_inputs, "Inputs changed during generation; retry after finishing your edits"); + Ok(()) + }).context("Generation failed; no update commit was created. Inspect any partial generated files before retrying")?; + let expected = inputs(&repo, conf, &config)?; + let mut expected_outputs = state::record_pending(root)?; + services.generate_lockfile_and_pull(|| { + let now = state::fingerprint(root, OUTPUTS.iter().map(PathBuf::from))?; + let without_lock = |fp: &Fingerprints| fp.iter().filter(|(k, _)| k.as_str() != "docker-image.lock.yml").map(|(k,v)| (k.clone(),v.clone())).collect::(); + ensure!(without_lock(&now) == without_lock(&expected_outputs), "Generated files changed while resolving images; no commit was created"); + expected_outputs = state::record_pending(root)?; + Ok(()) + }).context("Image update failed; no update commit was created. Run update commit to retry; generated files must remain unedited")?; + ensure!( + expected == inputs(&repo, conf, &config)? + && head == repo.head()? + && index == repo.run(&["ls-files", "--stage", "-z"])?, + "Inputs or Git state changed during update; no commit was created. Inspect generated files before retrying" + ); + let after = snapshot(&repo, conf, &config)?; + ensure!( + after.outputs == expected_outputs, + "Generated files changed during image validation or pull; no commit was created" + ); + let changed = baseline.as_ref().is_none_or(|s| s.runtime != after.runtime); + repo.commit()?; + after.save(root)?; + std::fs::remove_file(root.join(".rusthead/pending.json"))?; + let push_failed = if let Some(ref upstream) = network { + match repo.push(upstream) { + Ok(()) => false, + Err(error) => { + eprintln!("Local update committed, but synchronization failed: {error:#}"); + true + } + } + } else { + false + }; + if changed { + println!("Updated runtime configuration. Please restart the bridgehead."); + } + Ok(ExitCode::from(match (changed, push_failed) { + (false, false) => 0, + (true, false) => 3, + (false, true) => 1, + (true, true) => 4, + })) +} + +pub fn warn_pending(root: &Path, config: &Path) { + let check = || -> anyhow::Result { + let Some(repo) = Repository::open(root)? else { + return Ok(true); + }; + if repo.dirty()? { + return Ok(true); + } + let Some(baseline) = State::load(root)? else { + return Ok(true); + }; + let config = if config.is_dir() { + config.join("config.toml") + } else { + config.to_owned() + }; + let conf = Config::load(&config)?; + let now = snapshot(&repo, &conf, &config)?; + Ok(now.inputs != baseline.inputs || now.outputs != baseline.outputs) + }; + match check() { + Ok(false) => {} + Ok(true) => eprintln!( + "Warning: configuration has pending changes or no successful update baseline; run update commit." + ), + Err(e) => eprintln!("Warning: cannot check pending configuration changes: {e:#}"), + } +} diff --git a/src/update_state.rs b/src/update_state.rs new file mode 100644 index 0000000..a403d36 --- /dev/null +++ b/src/update_state.rs @@ -0,0 +1,243 @@ +//! Private, persistent fingerprints; never a Git cleanliness proxy. +use anyhow::{Context, ensure}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File, OpenOptions}, + os::{ + fd::AsRawFd, + unix::fs::{OpenOptionsExt, PermissionsExt}, + }, + path::{Path, PathBuf}, +}; + +pub type Fingerprints = BTreeMap; +pub const LOCAL_INPUTS: &[&str] = &[ + "config.local.toml", + "docker-compose.override.yml", + "pki", + "trusted-ca-certs", + "traefik-tls", +]; +pub const OUTPUTS: &[&str] = &["services", "docker-image.lock.yml", ".env"]; + +#[derive(Default, Serialize, Deserialize)] +pub struct State { + pub inputs: Fingerprints, + pub local_inputs: Fingerprints, + pub outputs: Fingerprints, + pub runtime: Fingerprints, +} + +pub struct UpdateLock { + _file: File, +} +impl UpdateLock { + pub fn acquire(root: &Path) -> anyhow::Result { + let dir = root.join(".rusthead"); + if dir.exists() { + ensure!( + !fs::symlink_metadata(&dir)?.file_type().is_symlink(), + ".rusthead must not be a symlink" + ); + } + fs::create_dir_all(&dir)?; + fs::set_permissions(&dir, fs::Permissions::from_mode(0o2770))?; + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o660) + .custom_flags(libc::O_NOFOLLOW) + .open(dir.join("update.lock"))?; + ensure!( + unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0, + "Another update is running (or the update lock could not be acquired)" + ); + Ok(Self { _file: file }) + } +} + +impl State { + pub fn load(root: &Path) -> anyhow::Result> { + match fs::read(root.join(".rusthead/state.json")) { + Ok(bytes) => Ok(Some( + serde_json::from_slice(&bytes).context("Cannot read update baseline")?, + )), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } + } + pub fn save(&self, root: &Path) -> anyhow::Result<()> { + let tmp = root.join(".rusthead/state.json.tmp"); + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o660) + .custom_flags(libc::O_NOFOLLOW) + .open(&tmp)?; + use std::io::Write; + file.write_all(&serde_json::to_vec_pretty(self)?)?; + file.sync_all()?; + fs::rename(tmp, root.join(".rusthead/state.json"))?; + Ok(()) + } +} + +pub fn fingerprint( + root: &Path, + paths: impl IntoIterator, +) -> anyhow::Result { + let mut result = Fingerprints::new(); + for path in paths { + visit(root, &path, &mut result, &mut BTreeSet::new())?; + } + Ok(result) +} +fn visit( + root: &Path, + path: &Path, + result: &mut Fingerprints, + ancestors: &mut BTreeSet, +) -> anyhow::Result<()> { + let full = root.join(path); + let meta = match fs::symlink_metadata(&full) { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e).with_context(|| format!("Cannot inspect {}", full.display())), + }; + let key = path + .to_str() + .context("Update fingerprint paths must be UTF-8")? + .to_owned(); + if meta.file_type().is_symlink() { + let target = fs::read_link(&full)?; + result.insert( + format!("{key}/@symlink"), + target.to_string_lossy().into_owned(), + ); + } + let meta = fs::metadata(&full).with_context(|| format!("Cannot follow {}", full.display()))?; + if meta.is_dir() { + let canonical = full.canonicalize()?; + ensure!( + ancestors.insert(canonical.clone()), + "Symlink cycle at {}", + full.display() + ); + for entry in fs::read_dir(full)? { + visit(root, &path.join(entry?.file_name()), result, ancestors)?; + } + ancestors.remove(&canonical); + } else { + ensure!( + meta.is_file(), + "Cannot fingerprint special file {}", + full.display() + ); + result.insert(key, format!("{:x}", Sha256::digest(fs::read(&full)?))); + } + Ok(()) +} + +pub fn local_paths(conf: &crate::Config) -> Vec { + let mut paths: Vec<_> = LOCAL_INPUTS.iter().map(PathBuf::from).collect(); + if let Some(tls) = conf.traefik.as_ref().and_then(|t| t.tls.as_ref()) { + paths.extend([tls.cert_file.clone(), tls.key_file.clone()]); + } + paths +} + +pub fn ensure_ignore(conf: &crate::Config) -> anyhow::Result<()> { + let root = &conf.path; + let path = root.join(".gitignore"); + let mut contents = match fs::read_to_string(&path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => return Err(e.into()), + }; + // Keep custom entries; put mandatory rules last so earlier negations cannot expose secrets. + let mut rules = format!( + "{}\n/.rusthead/\n", + include_str!("../static/.gitignore").trim_end() + ); + if let Some(volume) = volume_path(conf)? + && let Ok(relative) = volume.strip_prefix(root) + { + let relative = relative + .to_str() + .context("Volume directory must be UTF-8")?; + ensure!( + !relative.chars().any(char::is_control), + "Volume directory cannot contain control characters" + ); + let escaped: String = relative + .chars() + .flat_map(|ch| { + if matches!(ch, '\\' | '*' | '?' | '[' | ']' | ' ' | '!' | '#') { + vec!['\\', ch] + } else { + vec![ch] + } + }) + .collect(); + rules.push_str(&format!("/{escaped}/\n")); + } + if !contents.ends_with(&rules) { + if !contents.is_empty() && !contents.ends_with('\n') { + contents.push('\n'); + } + contents.push_str(&rules); + fs::write(path, contents)?; + } + Ok(()) +} + +// A failed image pull can leave valid generated files on disk. Permit an explicit +// commit retry only while those files still match what this process generated. +pub fn record_pending(root: &Path) -> anyhow::Result { + let outputs = fingerprint(root, OUTPUTS.iter().map(PathBuf::from))?; + let path = root.join(".rusthead/pending.json"); + use std::io::Write; + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o660) + .custom_flags(libc::O_NOFOLLOW) + .open(path)?; + file.write_all(&serde_json::to_vec(&outputs)?)?; + Ok(outputs) +} +pub fn pending_matches(root: &Path, outputs: &Fingerprints) -> anyhow::Result { + match fs::read(root.join(".rusthead/pending.json")) { + Ok(bytes) => Ok(serde_json::from_slice::(&bytes)? == *outputs), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(e.into()), + } +} + +/// Resolve the configured data directory without requiring it to exist yet. +pub fn volume_path(conf: &crate::Config) -> anyhow::Result> { + let Some(volume) = &conf.volume_dir else { + return Ok(None); + }; + let mut path = PathBuf::new(); + for component in conf.path.join(volume).components() { + match component { + std::path::Component::ParentDir => { + path.pop(); + } + std::path::Component::CurDir => {} + component => path.push(component.as_os_str()), + } + } + ensure!( + !conf.path.starts_with(&path), + "volume_dir must be outside the installation or a dedicated subdirectory, not the installation directory or an ancestor" + ); + Ok(Some(path)) +} diff --git a/tests/snapshots/eucaim-sql/configs@.gitignore.snap b/tests/snapshots/eucaim-sql/configs@.gitignore.snap index 3a0d222..c501d7d 100644 --- a/tests/snapshots/eucaim-sql/configs@.gitignore.snap +++ b/tests/snapshots/eucaim-sql/configs@.gitignore.snap @@ -11,3 +11,4 @@ config.local.toml /trusted-ca-certs /traefik-tls docker-compose.override.yml +/.rusthead/ diff --git a/tests/snapshots/eucaim/configs@.gitignore.snap b/tests/snapshots/eucaim/configs@.gitignore.snap index d2f8d3d..f481a5a 100644 --- a/tests/snapshots/eucaim/configs@.gitignore.snap +++ b/tests/snapshots/eucaim/configs@.gitignore.snap @@ -11,3 +11,4 @@ config.local.toml /trusted-ca-certs /traefik-tls docker-compose.override.yml +/.rusthead/ diff --git a/tests/snapshots/example.config/configs@.gitignore.snap b/tests/snapshots/example.config/configs@.gitignore.snap index 6c6bc37..437ea91 100644 --- a/tests/snapshots/example.config/configs@.gitignore.snap +++ b/tests/snapshots/example.config/configs@.gitignore.snap @@ -11,3 +11,4 @@ config.local.toml /trusted-ca-certs /traefik-tls docker-compose.override.yml +/.rusthead/ diff --git a/tests/snapshots/minimal/configs@.gitignore.snap b/tests/snapshots/minimal/configs@.gitignore.snap index 71354f4..03b7dd0 100644 --- a/tests/snapshots/minimal/configs@.gitignore.snap +++ b/tests/snapshots/minimal/configs@.gitignore.snap @@ -11,3 +11,4 @@ config.local.toml /trusted-ca-certs /traefik-tls docker-compose.override.yml +/.rusthead/ diff --git a/tests/snapshots/tuning/configs@.gitignore.snap b/tests/snapshots/tuning/configs@.gitignore.snap index a16fa5b..b2dcadd 100644 --- a/tests/snapshots/tuning/configs@.gitignore.snap +++ b/tests/snapshots/tuning/configs@.gitignore.snap @@ -11,3 +11,4 @@ config.local.toml /trusted-ca-certs /traefik-tls docker-compose.override.yml +/.rusthead/ diff --git a/tests/snapshots/volume-dir/configs@.gitignore.snap b/tests/snapshots/volume-dir/configs@.gitignore.snap index bc84007..7e85126 100644 --- a/tests/snapshots/volume-dir/configs@.gitignore.snap +++ b/tests/snapshots/volume-dir/configs@.gitignore.snap @@ -11,3 +11,5 @@ config.local.toml /trusted-ca-certs /traefik-tls docker-compose.override.yml +/.rusthead/ +/data/ diff --git a/tests/update.rs b/tests/update.rs new file mode 100644 index 0000000..55da169 --- /dev/null +++ b/tests/update.rs @@ -0,0 +1,474 @@ +use std::{ + fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process::Output, +}; + +struct Site { + _temp: tempfile::TempDir, + root: PathBuf, + bin: PathBuf, +} +impl Site { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("site with spaces"); + let bin = temp.path().join("bin"); + fs::create_dir(&root).unwrap(); + fs::create_dir(&bin).unwrap(); + fs::write( + root.join("custom.toml"), + "site_id = 'test'\nhostname = 'localhost'\n[ccp]\n", + ) + .unwrap(); + fs::write(bin.join("docker"), r#"#!/bin/sh +set -eu +printf '%s\n' "$*" >> "$TEST_SITE/docker.log" +case " $* " in + *' --lock-image-digests '*) + test ! -f "$TEST_SITE/fail-resolve" + printf 'services:\n ccp-blaze:\n image: test@sha256:%s\n' "$(cat "$TEST_SITE/digest" 2>/dev/null || echo one)" + ;; + *' pull '*) + test ! -f "$TEST_SITE/fail-pull" + if test -f "$TEST_SITE/edit-during-pull"; then echo '# concurrent edit' >> "$TEST_SITE/site with spaces/custom.toml"; fi + ;; +esac +"#).unwrap(); + fs::set_permissions(bin.join("docker"), fs::Permissions::from_mode(0o755)).unwrap(); + Self { + _temp: temp, + root, + bin, + } + } + fn command(&self, args: &[&str]) -> duct::Expression { + duct::cmd(env!("CARGO_BIN_EXE_rusthead"), args) + .dir(&self.root) + .env( + "PATH", + format!("{}:{}", self.bin.display(), std::env::var("PATH").unwrap()), + ) + .env("TEST_SITE", self._temp.path()) + .env("GIT_CONFIG_GLOBAL", self._temp.path().join("gitconfig")) + .env("GIT_CONFIG_NOSYSTEM", "1") + .stdout_capture() + .stderr_capture() + .unchecked() + } + fn run(&self, mode: &str) -> Output { + self.command(&["--config", "custom.toml", "update", mode]) + .run() + .unwrap() + } + fn expect(&self, mode: &str, status: i32) -> Output { + let out = self.run(mode); + assert_eq!( + out.status.code(), + Some(status), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + out + } + fn git(&self, args: &[&str]) -> String { + git(&self.root, args) + } + fn head(&self) -> String { + self.git(&["rev-parse", "HEAD"]) + } + fn append(&self, path: &str, text: &str) { + let path = self.root.join(path); + let mut contents = fs::read_to_string(&path).unwrap(); + contents.push_str(text); + fs::write(path, contents).unwrap(); + } + fn remote(&self) -> PathBuf { + let remote = self._temp.path().join("remote.git"); + git( + self._temp.path(), + &["init", "--bare", remote.to_str().unwrap()], + ); + self.git(&["remote", "add", "origin", remote.to_str().unwrap()]); + self.git(&["push", "-u", "origin", "main"]); + let config = fs::read_to_string(self.root.join("custom.toml")).unwrap(); + fs::write( + self.root.join("custom.toml"), + format!("git_sync = true\n{config}"), + ) + .unwrap(); + self.expect("commit", 0); + remote + } +} +fn git(root: &Path, args: &[&str]) -> String { + let out = duct::cmd("git", args) + .dir(root) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@example.invalid") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@example.invalid") + .stdout_capture() + .stderr_capture() + .run() + .unwrap(); + String::from_utf8(out.stdout).unwrap() +} + +#[test] +fn initial_update_noop_and_manual_edits() { + let site = Site::new(); + site.expect("sync", 3); + let head = site.head(); + assert!(site.git(&["status", "--porcelain"]).is_empty()); + site.expect("sync", 0); + assert_eq!(head, site.head()); + assert!(!site.git(&["ls-files"]).contains(".env")); + site.append("custom.toml", "# edited\n"); + site.git(&["add", "custom.toml"]); + site.append("custom.toml", "# also unstaged\n"); + fs::write(site.root.join("operator notes.txt"), "notes").unwrap(); + site.expect("sync", 1); + site.expect("commit", 0); + assert_ne!(head, site.head()); + assert!( + site.git(&["show", "HEAD:custom.toml"]) + .contains("also unstaged") + ); + assert!(site.git(&["ls-files"]).contains("operator notes.txt")); + assert!(site.git(&["status", "--porcelain"]).is_empty()); +} + +#[test] +fn ignored_inputs_outputs_and_compose_warnings() { + let site = Site::new(); + site.expect("commit", 3); + let head = site.head(); + site.append("config.local.toml", "# edit\n"); + site.expect("sync", 1); + site.expect("commit", 0); + assert_eq!(head, site.head()); + fs::write( + site.root.join("docker-compose.override.yml"), + "services: {}\n", + ) + .unwrap(); + site.expect("sync", 1); + site.expect("commit", 3); + site.append("services/ccp-blaze.yml", "# manual edit\n"); + let out = site.expect("commit", 1); + assert!(String::from_utf8_lossy(&out.stderr).contains("Generated files were edited")); + assert!( + fs::read_to_string(site.root.join("services/ccp-blaze.yml")) + .unwrap() + .contains("manual edit") + ); + let compose = site + .command(&["--config", "custom.toml", "compose", "logs"]) + .run() + .unwrap(); + assert!(compose.status.success()); + assert!(String::from_utf8_lossy(&compose.stderr).contains("Warning:")); + site.git(&["restore", "services"]); + site.append(".env", "MANUAL=value\n"); + site.expect("commit", 1); + fs::write(site.root.join("custom.toml"), "invalid syntax").unwrap(); + assert!( + site.command(&["--config", "custom.toml", "compose", "down"]) + .run() + .unwrap() + .status + .success() + ); +} + +#[test] +fn image_updates_are_pinned_and_failures_do_not_commit() { + let site = Site::new(); + site.expect("commit", 3); + fs::write( + site.root.join("docker-compose.override.yml"), + "services: {}\n", + ) + .unwrap(); + site.expect("commit", 3); + fs::write(site._temp.path().join("digest"), "two").unwrap(); + site.expect("sync", 3); + let log = fs::read_to_string(site._temp.path().join("docker.log")).unwrap(); + let resolution = log + .lines() + .rev() + .find(|line| line.contains("--lock-image-digests")) + .unwrap(); + assert!(resolution.contains("docker-compose.override.yml")); + assert!(!resolution.contains("docker-image.lock.yml")); + let pull = log + .lines() + .rev() + .find(|line| line.contains("pull")) + .unwrap(); + assert!(pull.contains("docker-image.lock.yml")); + assert!(pull.contains("--project-directory")); + assert!( + pull.find("docker-compose.override.yml").unwrap() + < pull.find("docker-image.lock.yml").unwrap() + ); + assert!( + site.git(&["show", "HEAD:docker-image.lock.yml"]) + .contains("two") + ); + let head = site.head(); + fs::write(site._temp.path().join("fail-pull"), "").unwrap(); + site.append("custom.toml", "# pending\n"); + site.expect("commit", 1); + assert_eq!(head, site.head()); + assert!( + fs::read_to_string(site.root.join("custom.toml")) + .unwrap() + .contains("pending") + ); +} + +#[test] +fn sync_fast_forwards_pushes_and_stops_on_divergence() { + let site = Site::new(); + site.expect("commit", 3); + let remote = site.remote(); + let admin = site._temp.path().join("admin"); + git( + site._temp.path(), + &[ + "clone", + "--branch", + "main", + remote.to_str().unwrap(), + admin.to_str().unwrap(), + ], + ); + let config = fs::read_to_string(admin.join("custom.toml")) + .unwrap() + .replace("localhost", "new.example"); + fs::write( + admin.join("custom.toml"), + format!("{config}\n[ccp.blaze]\nheap_size = '8g'\n"), + ) + .unwrap(); + git(&admin, &["add", "."]); + git(&admin, &["commit", "-m", "remote config"]); + git(&admin, &["push"]); + site.expect("sync", 3); + assert!( + fs::read_to_string(site.root.join("custom.toml")) + .unwrap() + .contains("new.example") + ); + assert_eq!(site.head(), git(&remote, &["rev-parse", "refs/heads/main"])); + site.append("custom.toml", "# local\n"); + site.git(&["add", "."]); + site.git(&["commit", "-m", "local"]); + git(&admin, &["pull", "--ff-only"]); + fs::write(admin.join("notes"), "remote").unwrap(); + git(&admin, &["add", "."]); + git(&admin, &["commit", "-m", "remote"]); + git(&admin, &["push"]); + let before = site.head(); + let out = site.expect("sync", 1); + assert!(String::from_utf8_lossy(&out.stderr).contains("diverged")); + assert_eq!(before, site.head()); +} + +#[test] +fn failed_push_keeps_commit_and_next_sync_retries() { + let site = Site::new(); + site.expect("commit", 3); + let remote = site.remote(); + let hook = remote.join("hooks/pre-receive"); + fs::write(&hook, "#!/bin/sh\nexit 1\n").unwrap(); + fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).unwrap(); + fs::write(site._temp.path().join("digest"), "two").unwrap(); + let before = site.head(); + site.expect("sync", 4); + assert_ne!(before, site.head()); + assert!(site.git(&["status", "--porcelain"]).is_empty()); + site.expect("sync", 1); + fs::remove_file(hook).unwrap(); + let head = site.head(); + site.expect("sync", 0); + assert_eq!(head, site.head()); + assert_eq!(head, git(&remote, &["rev-parse", "refs/heads/main"])); +} + +#[test] +fn explicit_sync_opt_in_and_missing_upstream() { + let site = Site::new(); + site.expect("commit", 3); + site.git(&["remote", "add", "origin", "/nonexistent/remote"]); + site.expect("sync", 0); + let text = fs::read_to_string(site.root.join("custom.toml")).unwrap(); + fs::write( + site.root.join("custom.toml"), + format!("git_sync = true\n{text}"), + ) + .unwrap(); + assert!(String::from_utf8_lossy(&site.expect("commit", 1).stderr).contains("upstream")); +} + +#[test] +fn concurrent_input_edits_and_update_lock() { + use std::os::fd::AsRawFd; + let site = Site::new(); + site.expect("commit", 3); + let file = fs::OpenOptions::new() + .write(true) + .open(site.root.join(".rusthead/update.lock")) + .unwrap(); + assert_eq!( + unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }, + 0 + ); + assert!(String::from_utf8_lossy(&site.expect("sync", 1).stderr).contains("Another update")); + drop(file); + let head = site.head(); + fs::write(site._temp.path().join("edit-during-pull"), "").unwrap(); + site.expect("sync", 1); + assert_eq!(head, site.head()); + assert!( + fs::read_to_string(site.root.join("custom.toml")) + .unwrap() + .contains("concurrent edit") + ); +} + +#[test] +fn worktrees_are_supported_and_parent_repositories_rejected() { + let site = Site::new(); + site.expect("commit", 3); + let worktree = site._temp.path().join("worktree"); + site.git(&["worktree", "add", "-b", "other", worktree.to_str().unwrap()]); + let out = site + .command(&[ + "--config", + worktree.join("custom.toml").to_str().unwrap(), + "update", + "commit", + ]) + .run() + .unwrap(); + assert_eq!( + out.status.code(), + Some(3), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let nested = site.root.join("nested"); + fs::create_dir(&nested).unwrap(); + fs::write( + nested.join("custom.toml"), + "site_id = 'x'\nhostname = 'localhost'\n", + ) + .unwrap(); + let out = site + .command(&["--config", "nested/custom.toml", "update", "commit"]) + .run() + .unwrap(); + assert!(!out.status.success()); + assert!(!nested.join(".rusthead").exists()); +} + +#[test] +fn failed_generation_can_be_retried_without_accepting_manual_output_edits() { + let site = Site::new(); + site.expect("commit", 3); + let head = site.head(); + site.append("custom.toml", "\n[ccp.blaze]\nheap_size = '8g'\n"); + fs::write(site._temp.path().join("fail-pull"), "").unwrap(); + site.expect("commit", 1); + assert_eq!(site.head(), head); + let generated = fs::read(site.root.join("services/ccp-blaze.yml")).unwrap(); + site.append( + "services/ccp-blaze.yml", + "# manual change after failed update\n", + ); + site.expect("commit", 1); + fs::write(site.root.join("services/ccp-blaze.yml"), generated).unwrap(); + fs::remove_file(site._temp.path().join("fail-pull")).unwrap(); + site.expect("commit", 3); + assert!(!site.root.join(".rusthead/pending.json").exists()); + site.expect("sync", 0); +} + +#[test] +fn ignored_runtime_changes_restart_without_empty_commits() { + let site = Site::new(); + site.expect("commit", 3); + let head = site.head(); + fs::write(site.root.join("pki/runtime.pem"), "certificate").unwrap(); + site.expect("sync", 1); + site.expect("commit", 3); + assert_eq!(head, site.head()); + assert!(!site.git(&["ls-files"]).contains("runtime.pem")); + site.expect("sync", 0); + fs::remove_file(site.root.join("pki/runtime.pem")).unwrap(); + site.expect("sync", 1); + site.expect("commit", 3); + assert_eq!(head, site.head()); +} + +#[test] +fn custom_ignores_are_preserved_and_unfinished_git_operations_are_rejected() { + let site = Site::new(); + fs::write(site.root.join(".gitignore"), "/operator-data/\n").unwrap(); + site.expect("commit", 3); + let contents = fs::read_to_string(site.root.join(".gitignore")).unwrap(); + assert!(contents.starts_with("/operator-data/\n")); + site.expect("sync", 0); + assert_eq!( + contents, + fs::read_to_string(site.root.join(".gitignore")).unwrap() + ); + fs::write(site.root.join(".git/MERGE_HEAD"), site.head()).unwrap(); + assert!(String::from_utf8_lossy(&site.expect("sync", 1).stderr).contains("Git operation")); + assert!(String::from_utf8_lossy(&site.expect("commit", 1).stderr).contains("Git operation")); +} + +#[test] +fn bare_update_requires_a_mode_and_empty_installations_are_supported() { + let site = Site::new(); + let help = site.command(&["update"]).run().unwrap(); + assert_eq!(help.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&help.stderr).contains("sync")); + assert!(!site.root.join(".git").exists()); + fs::write( + site.root.join("custom.toml"), + "site_id = 'test'\nhostname = 'localhost'\n", + ) + .unwrap(); + site.expect("sync", 3); + site.expect("sync", 0); + assert!(!site._temp.path().join("docker.log").exists()); +} + +#[test] +fn runtime_volume_data_is_neither_hashed_nor_committed() { + let site = Site::new(); + let config = fs::read_to_string(site.root.join("custom.toml")).unwrap(); + fs::write( + site.root.join("custom.toml"), + format!("volume_dir = './data'\n{config}"), + ) + .unwrap(); + fs::create_dir(site.root.join("data")).unwrap(); + // A FIFO would fail fingerprinting (or hang a naive reader). + let fifo = + std::ffi::CString::new(site.root.join("data/runtime.fifo").to_str().unwrap()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0); + site.expect("commit", 3); + assert!(!site.git(&["ls-files"]).contains("data/")); + fs::write(site.root.join("data/database"), "runtime changes").unwrap(); + site.expect("sync", 0); + assert!(site.git(&["status", "--porcelain"]).is_empty()); +} From 789a958db8628db46b92d863bf5039e6840b84c8 Mon Sep 17 00:00:00 2001 From: Threated Date: Tue, 8 Sep 2026 13:46:34 +0200 Subject: [PATCH 3/8] track enrollment status in .rusthead --- docs/updates.md | 17 ++- src/config.rs | 113 ++++++++++++++---- src/enrollment.rs | 53 ++++++++ src/install.rs | 56 ++++----- src/main.rs | 1 + src/services/beam.rs | 5 +- src/services/mod.rs | 2 +- src/update_state.rs | 52 +++++--- tests/install.rs | 31 +++-- .../eucaim-sql/configs@config.local.toml.snap | 2 - .../eucaim/configs@config.local.toml.snap | 2 - .../configs@config.local.toml.snap | 6 - .../minimal/configs@config.local.toml.snap | 2 - .../tuning/configs@config.local.toml.snap | 2 - .../volume-dir/configs@config.local.toml.snap | 2 - tests/update.rs | 24 ++++ 16 files changed, 270 insertions(+), 100 deletions(-) create mode 100644 src/enrollment.rs diff --git a/docs/updates.md b/docs/updates.md index 7833091..d39a132 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -43,12 +43,23 @@ when the remote is reachable and the histories permit synchronization. ## Local state and recovery -Ignored `.rusthead/` metadata stores fingerprints and an update lock. It contains +Ignored `.rusthead/` metadata stores update fingerprints, an update lock, and a +separate `enrollment.json` containing completed enrollment records. It contains no copies of secret contents and is accessible only to the installation owner and group. Runtime change detection includes generated files, pinned image versions, environment values, overrides, and certificates; a config-only commit need not request a restart. No-op or ignored-only changes create no empty commits. +`enrollment.json` is durable operational state: resetting `state.json` must not +remove it. Required Beam networks are derived from the configured services and +are not persisted. The seed, credentials, and user configuration remain in +`config.local.toml`. + +Enrollment saves each completed network independently. Receipt changes alone do +not dirty update inputs; changes to actual keys or certificates still do. A missing +site private key invalidates its enrollment records, retaining the existing +reenrollment behavior. + Generation renders all service templates before replacing service files, but the whole update is not a filesystem transaction: certificate creation, output writes, or a fast-forward can remain after a later failure. No successful update @@ -76,5 +87,5 @@ image lockfile after the override file. The generated systemd update service accepts status 3 as success and requests a restart for 3 or 4. Status 4 remains a service failure so the push problem stays -visible. Standalone enrollment changes are accepted with `update commit`; -installation accepts its enrollment changes before enabling the timer. +visible. Standalone enrollment key or certificate changes are accepted with +`update commit`; installation accepts these changes before enabling the timer. diff --git a/src/config.rs b/src/config.rs index ad7789b..c5fc668 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use url::{Host, Url}; use crate::{ + enrollment::Enrollment, modules::{BbmriConfig, CcpConfig, DnpmConfig, EucaimConfig}, services::{BasicAuthUser, Service, TraefikConfig}, }; @@ -44,6 +45,11 @@ pub struct Config { #[serde(skip)] pub local_conf: RefCell, + /// Computed while materializing configured services; never persisted. + #[serde(skip)] + pub beam_networks: RefCell>, + #[serde(skip)] + pub enrollment: RefCell, } fn default_image() -> String { @@ -72,19 +78,21 @@ impl Config { }; let mut conf: Config = toml::from_str(&std::fs::read_to_string(&file)?)?; conf.path = file.parent().unwrap().to_path_buf(); - let mut local_conf: LocalConf = match fs::read_to_string(conf.local_conf_path()) { + let local_conf: LocalConf = match fs::read_to_string(conf.local_conf_path()) { Ok(data) => toml::from_str(&data).context("Failed to parse config.local.toml")?, Err(error) if error.kind() == std::io::ErrorKind::NotFound => LocalConf::default(), Err(error) => return Err(error).context("Failed to read config.local.toml"), }; + let mut enrollment = Enrollment::load(&conf.path)?; if !conf .path .join(format!("pki/{}.priv.pem", conf.site_id)) .try_exists()? { - local_conf.enrolled_beam_networks.clear(); + enrollment.enrolled_beam_networks.clear(); } conf.local_conf = RefCell::new(local_conf); + conf.enrollment = RefCell::new(enrollment); Ok(conf) } @@ -107,10 +115,29 @@ impl Config { Ok(()) } - /// Persist enrollment progress without rewriting the generated environment. + pub fn pending_beam_networks(&self) -> BTreeSet { + self.beam_networks + .borrow() + .difference(&self.enrollment.borrow().enrolled_beam_networks) + .cloned() + .collect() + } + + /// Persist local configuration without rewriting the generated environment. pub fn save_local_conf(&self) -> anyhow::Result<()> { let conf_str = toml::to_string_pretty(self.local_conf.borrow().deref())?; - fs::write(self.local_conf_path(), conf_str)?; + // Enrollment alone must not rewrite local configuration (including comments). + let unchanged = match fs::read_to_string(self.local_conf_path()) { + Ok(existing) => { + toml::from_str::(&existing).ok() + == Some(toml::from_str::(&conf_str)?) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(e) => return Err(e.into()), + }; + if !unchanged { + fs::write(self.local_conf_path(), conf_str)?; + } Ok(()) } } @@ -122,13 +149,6 @@ pub struct LocalConf { seed: u32, pub oidc: Option>, pub basic_auth_users: Option>, - /// Networks required by the current service configuration. - #[serde(default)] - pub beam_networks: BTreeSet, - /// Networks for which the local enrollment command completed successfully. - /// Retained when a network is disabled so re-enabling it does not re-enroll it. - #[serde(default)] - pub enrolled_beam_networks: BTreeSet, #[serde(skip)] pub generated_secrets: BTreeMap, } @@ -143,21 +163,12 @@ impl Default for LocalConf { seed: generate_seed(), oidc: None, basic_auth_users: None, - beam_networks: Default::default(), - enrolled_beam_networks: Default::default(), generated_secrets: Default::default(), } } } impl LocalConf { - pub fn pending_beam_networks(&self) -> BTreeSet { - self.beam_networks - .difference(&self.enrolled_beam_networks) - .cloned() - .collect() - } - #[must_use] pub fn generate_secret(&mut self, name: &str) -> String { let name = format!( @@ -199,6 +210,66 @@ mod tests { use super::*; + #[test] + fn enrollment_state_preserves_configuration_and_survives_baseline_reset() { + let temp = tempfile::tempdir().unwrap(); + fs::write( + temp.path().join("config.toml"), + "site_id = 'test'\nhostname = 'localhost'\n", + ) + .unwrap(); + fs::create_dir(temp.path().join("pki")).unwrap(); + fs::write(temp.path().join("pki/test.priv.pem"), "existing key").unwrap(); + let local = "# preserve operator comments\nseed = 42\n[oidc]\nclient = 'credential'\n[basic_auth_users.admin]\nhash = 'existing hash'\npw = 'existing password'\n"; + fs::write(temp.path().join("config.local.toml"), local).unwrap(); + let conf = Config::load(&temp.path().to_owned()).unwrap(); + assert!(conf.beam_networks.borrow().is_empty()); + assert!(!Enrollment::path(temp.path()).exists()); // Loading alone is read-only. + conf.enrollment + .borrow_mut() + .enrolled_beam_networks + .insert("completed.example".to_owned()); + conf.enrollment.borrow().save(temp.path()).unwrap(); + conf.save_local_conf().unwrap(); + assert_eq!(fs::read_to_string(conf.local_conf_path()).unwrap(), local); + let receipt = fs::read(Enrollment::path(temp.path())).unwrap(); + fs::write(temp.path().join(".rusthead/state.json"), "{}").unwrap(); + fs::remove_file(temp.path().join(".rusthead/state.json")).unwrap(); + let reloaded = Config::load(&temp.path().to_owned()).unwrap(); + assert!( + reloaded + .enrollment + .borrow() + .enrolled_beam_networks + .contains("completed.example") + ); + reloaded.save_local_conf().unwrap(); + assert_eq!(receipt, fs::read(Enrollment::path(temp.path())).unwrap()); + fs::remove_file(temp.path().join("pki/test.priv.pem")).unwrap(); + let missing = Config::load(&temp.path().to_owned()).unwrap(); + assert!( + missing + .enrollment + .borrow() + .enrolled_beam_networks + .is_empty() + ); + missing.enrollment.borrow().save(temp.path()).unwrap(); + missing.save_local_conf().unwrap(); + assert_eq!(fs::read_to_string(conf.local_conf_path()).unwrap(), local); + fs::write(temp.path().join("pki/test.priv.pem"), "replacement key").unwrap(); + assert!( + Config::load(&temp.path().to_owned()) + .unwrap() + .enrollment + .borrow() + .enrolled_beam_networks + .is_empty() + ); + fs::write(Enrollment::path(temp.path()), "invalid json").unwrap(); + assert!(Config::load(&temp.path().to_owned()).is_err()); + } + #[test] fn test_configs() { let mut s = insta::Settings::clone_current(); @@ -215,7 +286,7 @@ mod tests { .iter() .for_each(|&m| services.install_module(m)); services.write_all().unwrap(); - let has_beam_networks = !conf.local_conf.borrow().beam_networks.is_empty(); + let has_beam_networks = !conf.beam_networks.borrow().is_empty(); let has_services = services.len() > 0; let tmp_dir_path = temp_dir.path().display().to_string(); let filters = [(tmp_dir_path.as_str(), "[TMP_DIR]")]; diff --git a/src/enrollment.rs b/src/enrollment.rs new file mode 100644 index 0000000..c87a644 --- /dev/null +++ b/src/enrollment.rs @@ -0,0 +1,53 @@ +//! Durable receipts for completed enrollment, independent of update fingerprints. +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeSet, + fs::{self, OpenOptions}, + io::Write, + os::unix::fs::{OpenOptionsExt, PermissionsExt}, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Enrollment { + pub enrolled_beam_networks: BTreeSet, +} + +impl Enrollment { + pub fn path(root: &Path) -> PathBuf { + root.join(".rusthead/enrollment.json") + } + + pub fn load(root: &Path) -> anyhow::Result { + match fs::read(Self::path(root)) { + Ok(bytes) => serde_json::from_slice(&bytes).context("Failed to parse enrollment.json"), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(e) => Err(e).context("Failed to read enrollment.json"), + } + } + + pub fn save(&self, root: &Path) -> anyhow::Result<()> { + let dir = crate::update_state::prepare_directory(root)?; + let bytes = serde_json::to_vec_pretty(self)?; + let path = Self::path(root); + if fs::read(&path).ok().as_deref() == Some(&bytes) { + return Ok(()); + } + let tmp = dir.join("enrollment.json.tmp"); + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o660) + .custom_flags(libc::O_NOFOLLOW) + .open(&tmp)?; + file.set_permissions(fs::Permissions::from_mode(0o660))?; + file.write_all(&bytes)?; + file.sync_all()?; + fs::rename(tmp, path).context("Failed to save enrollment.json")?; + fs::File::open(dir)?.sync_all()?; + Ok(()) + } +} diff --git a/src/install.rs b/src/install.rs index d0b2822..0935fd1 100644 --- a/src/install.rs +++ b/src/install.rs @@ -8,7 +8,7 @@ use std::{ use anyhow::{Context, bail, ensure}; use duct::cmd; -use crate::{config::Config, modules, services::ServiceMap}; +use crate::{config::Config, modules, services::ServiceMap, update_state::UpdateLock}; fn require_root() -> anyhow::Result<()> { ensure!( @@ -95,9 +95,13 @@ pub fn install(config: &PathBuf) -> anyhow::Result { return Ok(ExitCode::from(status)); } // Update may have changed local credentials; do not overwrite them with our earlier copy. - let conf = load_materialized(config)?; - let needs_enrollment = !conf.local_conf.borrow().pending_beam_networks().is_empty(); - enroll_pending_networks(conf)?; + let needs_enrollment = { + let _lock = UpdateLock::acquire(&conf.path)?; + let conf = load_materialized(config)?; + let pending = !conf.pending_beam_networks().is_empty(); + enroll_pending_networks(conf)?; + pending + }; if needs_enrollment { // Accept enrollment's local inputs before the clean-only timer starts. let status = run_update(&executable, config, &conf.path)?; @@ -273,6 +277,12 @@ fn install_systemd(directory: &Path, executable: &Path, config: &Path) -> anyhow pub fn enroll(config: &PathBuf) -> anyhow::Result { require_root()?; + let root = if config.is_dir() { + config.as_path() + } else { + config.parent().context("Configuration has no parent")? + }; + let _lock = UpdateLock::acquire(root)?; let conf = load_materialized(config)?; enroll_pending_networks(conf)?; Ok(ExitCode::SUCCESS) @@ -280,7 +290,9 @@ pub fn enroll(config: &PathBuf) -> anyhow::Result { fn enroll_pending_networks(conf: &Config) -> anyhow::Result<()> { conf.save_local_conf()?; - let networks = conf.local_conf.borrow().pending_beam_networks(); + // Persist invalidation of old receipts when the site's private key is missing. + conf.enrollment.borrow().save(&conf.path)?; + let networks = conf.pending_beam_networks(); if networks.is_empty() { println!("No Beam networks pending enrollment."); return Ok(()); @@ -305,11 +317,11 @@ fn enroll_pending_networks(conf: &Config) -> anyhow::Result<()> { fs::set_permissions(&key, fs::Permissions::from_mode(0o600))?; cmd!("chown", "bridgehead:docker", &key).run()?; { - let mut local = conf.local_conf.borrow_mut(); - local.enrolled_beam_networks.insert(broker.clone()); + let mut enrollment = conf.enrollment.borrow_mut(); + enrollment.enrolled_beam_networks.insert(broker.clone()); } - // Save each success so a failure on a later network can be retried independently. - conf.save_local_conf()?; + // Save each success independently without modifying configuration or the update baseline. + conf.enrollment.borrow().save(&conf.path)?; } if !networks.is_empty() { println!( @@ -332,29 +344,27 @@ mod tests { // Older local configurations must load without losing their existing seed. fs::write(temp.path().join("config.local.toml"), "seed = 42\n").unwrap(); let conf = load_materialized(&config).unwrap(); - let networks = conf.local_conf.borrow().beam_networks.clone(); + let networks = conf.beam_networks.borrow().clone(); assert_eq!(networks.len(), 1); - assert_eq!(conf.local_conf.borrow().pending_beam_networks(), networks); + assert_eq!(conf.pending_beam_networks(), networks); fs::write(private_key(conf), "key").unwrap(); { - let mut local = conf.local_conf.borrow_mut(); - local.enrolled_beam_networks = networks.clone(); + let mut enrollment = conf.enrollment.borrow_mut(); + enrollment.enrolled_beam_networks = networks.clone(); } - conf.save_local_conf().unwrap(); + conf.enrollment.borrow().save(&conf.path).unwrap(); assert!( load_materialized(&config) .unwrap() - .local_conf - .borrow() .pending_beam_networks() .is_empty() ); fs::write(&config, "site_id = 'test'\nhostname = 'localhost'\n").unwrap(); let disabled = load_materialized(&config).unwrap(); - assert!(disabled.local_conf.borrow().beam_networks.is_empty()); + assert!(disabled.beam_networks.borrow().is_empty()); assert_eq!( - disabled.local_conf.borrow().enrolled_beam_networks, + disabled.enrollment.borrow().enrolled_beam_networks, networks ); disabled.save_local_conf().unwrap(); @@ -362,8 +372,6 @@ mod tests { assert!( load_materialized(&config) .unwrap() - .local_conf - .borrow() .pending_beam_networks() .is_empty() ); @@ -374,19 +382,13 @@ mod tests { assert!( load_materialized(&config) .unwrap() - .local_conf - .borrow() .pending_beam_networks() .is_empty() ); fs::write(&config, configured).unwrap(); fs::remove_file(private_key(conf)).unwrap(); assert_eq!( - load_materialized(&config) - .unwrap() - .local_conf - .borrow() - .pending_beam_networks(), + load_materialized(&config).unwrap().pending_beam_networks(), networks ); } diff --git a/src/main.rs b/src/main.rs index 9c20818..d64d12d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ use config::Config; use duct::cmd; mod config; +mod enrollment; mod git; mod install; mod modules; diff --git a/src/services/beam.rs b/src/services/beam.rs index 078f868..4418a99 100644 --- a/src/services/beam.rs +++ b/src/services/beam.rs @@ -58,10 +58,7 @@ impl Service for BeamProxy { type ServiceConfig = &'static Config; fn from_config(conf: Self::ServiceConfig, (fw_proxy,): Deps) -> Self { - conf.local_conf - .borrow_mut() - .beam_networks - .insert(T::broker_id()); + conf.beam_networks.borrow_mut().insert(T::broker_id()); fs::create_dir_all(conf.path.join("pki")).unwrap(); BeamProxy { broker_provider: PhantomData, diff --git a/src/services/mod.rs b/src/services/mod.rs index d8431a7..9b1360e 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -234,7 +234,7 @@ impl ServiceMap { pub fn new(config: &'static Config) -> Self { // Rebuild configured networks while retaining enrollment history. - config.local_conf.borrow_mut().beam_networks.clear(); + config.beam_networks.borrow_mut().clear(); let mut deps = solvent::DepGraph::new(); deps.register_node(Self::ROOT_NODE); Self { diff --git a/src/update_state.rs b/src/update_state.rs index a403d36..6fd7d2c 100644 --- a/src/update_state.rs +++ b/src/update_state.rs @@ -35,23 +35,24 @@ pub struct UpdateLock { } impl UpdateLock { pub fn acquire(root: &Path) -> anyhow::Result { - let dir = root.join(".rusthead"); - if dir.exists() { - ensure!( - !fs::symlink_metadata(&dir)?.file_type().is_symlink(), - ".rusthead must not be a symlink" - ); - } - fs::create_dir_all(&dir)?; - fs::set_permissions(&dir, fs::Permissions::from_mode(0o2770))?; - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .mode(0o660) - .custom_flags(libc::O_NOFOLLOW) - .open(dir.join("update.lock"))?; + let dir = prepare_directory(root)?; + let open_lock = |create| { + OpenOptions::new() + .read(true) + .write(true) + .create_new(create) + .mode(0o660) + .custom_flags(libc::O_NOFOLLOW) + .open(dir.join("update.lock")) + }; + let file = match open_lock(true) { + Ok(file) => { + file.set_permissions(fs::Permissions::from_mode(0o660))?; + file + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => open_lock(false)?, + Err(e) => return Err(e.into()), + }; ensure!( unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0, "Another update is running (or the update lock could not be acquired)" @@ -60,6 +61,23 @@ impl UpdateLock { } } +/// Shared private metadata directory; enrollment receipts outlive update baselines. +pub(crate) fn prepare_directory(root: &Path) -> anyhow::Result { + let dir = root.join(".rusthead"); + match fs::create_dir(&dir) { + Ok(()) => fs::set_permissions(&dir, fs::Permissions::from_mode(0o2770))?, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + ensure!( + fs::symlink_metadata(&dir)?.is_dir(), + ".rusthead must be a directory, not a symlink" + ); + // The other installation user may own this shared directory. + } + Err(e) => return Err(e.into()), + } + Ok(dir) +} + impl State { pub fn load(root: &Path) -> anyhow::Result> { match fs::read(root.join(".rusthead/state.json")) { diff --git a/tests/install.rs b/tests/install.rs index 69b9b85..4524796 100644 --- a/tests/install.rs +++ b/tests/install.rs @@ -85,6 +85,19 @@ if [ ! -f "$key" ]; then printf 'private key\n' > "$key"; fi .unwrap() } + fn enrolled_networks(&self) -> Vec { + let value: serde_json::Value = serde_json::from_slice( + &fs::read( + self.temp + .path() + .join("site with spaces/.rusthead/enrollment.json"), + ) + .unwrap(), + ) + .unwrap(); + value["enrolled_beam_networks"].as_array().unwrap().clone() + } + fn local_conf(&self) -> toml::Table { toml::from_str( &fs::read_to_string(self.temp.path().join("site with spaces/config.local.toml")) @@ -200,8 +213,9 @@ fn new_network_is_enrolled_without_repeating_existing_enrollment() { String::from_utf8_lossy(&result.stderr) ); let local = installation.local_conf(); - assert_eq!(local["beam_networks"].as_array().unwrap().len(), 2); - assert_eq!(local["enrolled_beam_networks"].as_array().unwrap().len(), 2); + assert!(!local.contains_key("beam_networks")); + assert!(!local.contains_key("enrolled_beam_networks")); + assert_eq!(installation.enrolled_networks().len(), 2); assert_eq!(local["seed"], before["seed"]); assert_eq!( fs::read_to_string(site.join(".env")).unwrap(), @@ -238,8 +252,9 @@ fn partial_enrollment_is_persisted_and_only_failed_networks_are_retried() { fs::write(root.join("fail-network"), "test.broker.ccp-it.dktk.dkfz.de").unwrap(); assert!(!installation.run(0).status.success()); let local = installation.local_conf(); - assert_eq!(local["beam_networks"].as_array().unwrap().len(), 2); - assert_eq!(local["enrolled_beam_networks"].as_array().unwrap().len(), 1); + assert!(!local.contains_key("beam_networks")); + assert!(!local.contains_key("enrolled_beam_networks")); + assert_eq!(installation.enrolled_networks().len(), 1); fs::remove_file(root.join("fail-network")).unwrap(); assert!(installation.run(0).status.success()); let proxies = fs::read_to_string(root.join("proxies")).unwrap(); @@ -257,11 +272,5 @@ fn partial_enrollment_is_persisted_and_only_failed_networks_are_retried() { .count(), 2 ); - assert_eq!( - installation.local_conf()["enrolled_beam_networks"] - .as_array() - .unwrap() - .len(), - 2 - ); + assert_eq!(installation.enrolled_networks().len(), 2); } diff --git a/tests/snapshots/eucaim-sql/configs@config.local.toml.snap b/tests/snapshots/eucaim-sql/configs@config.local.toml.snap index 8837139..60a6009 100644 --- a/tests/snapshots/eucaim-sql/configs@config.local.toml.snap +++ b/tests/snapshots/eucaim-sql/configs@config.local.toml.snap @@ -5,5 +5,3 @@ info: config.local.toml input_file: tests/configs/eucaim-sql.toml --- seed = 42 -beam_networks = ['broker.eucaim.cancerimage.eu'] -enrolled_beam_networks = [] diff --git a/tests/snapshots/eucaim/configs@config.local.toml.snap b/tests/snapshots/eucaim/configs@config.local.toml.snap index 5b732b2..bbb9de6 100644 --- a/tests/snapshots/eucaim/configs@config.local.toml.snap +++ b/tests/snapshots/eucaim/configs@config.local.toml.snap @@ -5,5 +5,3 @@ info: config.local.toml input_file: tests/configs/eucaim.toml --- seed = 42 -beam_networks = ['broker.eucaim.cancerimage.eu'] -enrolled_beam_networks = [] diff --git a/tests/snapshots/example.config/configs@config.local.toml.snap b/tests/snapshots/example.config/configs@config.local.toml.snap index 1fdf09d..5a7d8e1 100644 --- a/tests/snapshots/example.config/configs@config.local.toml.snap +++ b/tests/snapshots/example.config/configs@config.local.toml.snap @@ -5,12 +5,6 @@ info: config.local.toml input_file: tests/configs/example.config.toml --- seed = 42 -beam_networks = [ - 'broker.bbmri.de', - 'broker.bbmri.samply.de', - 'broker.ccp-it.dktk.dkfz.de', -] -enrolled_beam_networks = [] [basic_auth_users.bbmri-blaze] hash = '' pw = 'test' diff --git a/tests/snapshots/minimal/configs@config.local.toml.snap b/tests/snapshots/minimal/configs@config.local.toml.snap index a2868b8..a9f4b35 100644 --- a/tests/snapshots/minimal/configs@config.local.toml.snap +++ b/tests/snapshots/minimal/configs@config.local.toml.snap @@ -5,5 +5,3 @@ info: config.local.toml input_file: tests/configs/minimal.toml --- seed = 42 -beam_networks = [] -enrolled_beam_networks = [] diff --git a/tests/snapshots/tuning/configs@config.local.toml.snap b/tests/snapshots/tuning/configs@config.local.toml.snap index 19eda60..dc74a56 100644 --- a/tests/snapshots/tuning/configs@config.local.toml.snap +++ b/tests/snapshots/tuning/configs@config.local.toml.snap @@ -5,8 +5,6 @@ info: config.local.toml input_file: tests/configs/tuning.toml --- seed = 42 -beam_networks = ['broker.ccp-it.dktk.dkfz.de'] -enrolled_beam_networks = [] [basic_auth_users.ccp-blaze] hash = '' pw = 'test' diff --git a/tests/snapshots/volume-dir/configs@config.local.toml.snap b/tests/snapshots/volume-dir/configs@config.local.toml.snap index 5c2b968..ffff80a 100644 --- a/tests/snapshots/volume-dir/configs@config.local.toml.snap +++ b/tests/snapshots/volume-dir/configs@config.local.toml.snap @@ -5,8 +5,6 @@ info: config.local.toml input_file: tests/configs/volume-dir.toml --- seed = 42 -beam_networks = ['broker.ccp-it.dktk.dkfz.de'] -enrolled_beam_networks = [] [basic_auth_users.ccp-blaze] hash = '' pw = 'test' diff --git a/tests/update.rs b/tests/update.rs index 55da169..b17f82c 100644 --- a/tests/update.rs +++ b/tests/update.rs @@ -472,3 +472,27 @@ fn runtime_volume_data_is_neither_hashed_nor_committed() { site.expect("sync", 0); assert!(site.git(&["status", "--porcelain"]).is_empty()); } + +#[test] +fn enrollment_receipts_do_not_dirty_inputs_and_survive_update_baseline_reset() { + let site = Site::new(); + fs::create_dir(site.root.join("pki")).unwrap(); + fs::write(site.root.join("pki/test.priv.pem"), "existing key").unwrap(); + site.expect("commit", 3); + let local = fs::read(site.root.join("config.local.toml")).unwrap(); + let receipt = br#"{"enrolled_beam_networks":["broker.ccp-it.dktk.dkfz.de"]}"#; + fs::write(site.root.join(".rusthead/enrollment.json"), receipt).unwrap(); + site.expect("sync", 0); + assert_eq!( + fs::read(site.root.join("config.local.toml")).unwrap(), + local + ); + let saved = fs::read(site.root.join(".rusthead/enrollment.json")).unwrap(); + fs::remove_file(site.root.join(".rusthead/state.json")).unwrap(); + site.expect("sync", 3); + assert_eq!( + fs::read(site.root.join(".rusthead/enrollment.json")).unwrap(), + saved + ); + assert!(!site.git(&["ls-files"]).contains("enrollment.json")); +} From 734035f93a3a0a3e0e062ae8c180108d67594386 Mon Sep 17 00:00:00 2001 From: Threated Date: Tue, 8 Sep 2026 14:31:22 +0200 Subject: [PATCH 4/8] Self replace + CI --- .dockerignore | 4 + .github/workflows/rust.yml | 61 +++++++++--- Cargo.lock | 12 +++ Cargo.toml | 9 +- Dockerfile | 55 +---------- docs/updates.md | 58 ++++++++++++ src/config.rs | 2 +- src/install.rs | 15 +++ src/main.rs | 2 + src/self_update.rs | 183 ++++++++++++++++++++++++++++++++++++ src/services/secret_sync.rs | 168 ++++++++++++++++++++++++++++----- src/update.rs | 4 + tests/install.rs | 7 ++ tests/update.rs | 134 +++++++++++++++++++++++++- 14 files changed, 620 insertions(+), 94 deletions(-) create mode 100644 .dockerignore create mode 100644 src/self_update.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7d9312a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +** +!Dockerfile +!artifacts/ +!artifacts/rusthead diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index bcc2a0b..795cb6a 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,15 +1,53 @@ -name: Rust +name: Native Rust and Docker on: push: workflow_dispatch: pull_request: +permissions: + contents: read + jobs: - rust: - # This workflow defines how a maven package is built, tested and published. - # Visit: https://github.com/samply/github-workflows/blob/develop/.github/workflows/docker-ci.yml, for more information - uses: samply/github-workflows/.github/workflows/rust.yml@main + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-musl + components: rustfmt + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@nextest + - name: Install musl + run: | + sudo apt-get update + sudo apt-get install -y musl-tools + docker compose version + - run: cargo fmt --all --check + - name: Test musl binaries + run: cargo nextest run --locked --target x86_64-unknown-linux-musl --no-fail-fast + - name: Test root-only installation flows + run: sudo env "PATH=$PATH" "CARGO_HOME=$HOME/.cargo" "RUSTUP_HOME=$HOME/.rustup" cargo nextest run --locked --target x86_64-unknown-linux-musl --no-fail-fast --test install --run-ignored only + - name: Build static release executable + run: | + cargo build --locked --release --target x86_64-unknown-linux-musl + file target/x86_64-unknown-linux-musl/release/rusthead + if readelf -l target/x86_64-unknown-linux-musl/release/rusthead | grep -q INTERP; then + echo 'Release executable must be statically linked' + exit 1 + fi + mkdir -p artifacts + cp target/x86_64-unknown-linux-musl/release/rusthead artifacts/rusthead + - uses: actions/upload-artifact@v6 + with: + name: rusthead-amd64-musl + path: artifacts/rusthead + if-no-files-found: error + + docker: + needs: build-test + uses: samply/github-workflows/.github/workflows/docker-ci.yml@main permissions: contents: read packages: write @@ -17,11 +55,10 @@ jobs: security-events: write statuses: read with: - image-prefix: "samply/" - components: '[ "rusthead" ]' - architectures: '[ "amd64" ]' - test-via-script: false - cargo-fmt-check: true + image-name: samply/rusthead + build-platforms: linux/amd64 + artifact-name: rusthead-amd64-musl + push-to: ${{ github.event_name == 'pull_request' && 'none' || 'auto' }} secrets: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 78a7d04..f92ae40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1004,6 +1004,7 @@ dependencies = [ "libc", "rand", "rcgen", + "self-replace", "serde", "serde_json", "sha2", @@ -1050,6 +1051,17 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + [[package]] name = "semver" version = "1.0.27" diff --git a/Cargo.toml b/Cargo.toml index 306944f..d7c4f85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,11 +22,18 @@ sha2 = "0.10" libc = "0.2" solvent = "0.8.3" duct = "1.1.2" +self-replace = "1.5" +tempfile = "3.27" [dev-dependencies] insta = { version = "1.47", features = ["filters", "glob", "toml"] } -tempfile = "3.27" toml = { version = "*", features = ["preserve_order"] } [profile.dev] strip = "debuginfo" + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +strip = true diff --git a/Dockerfile b/Dockerfile index 7bbd321..4ebdbc5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,52 +1,3 @@ -FROM samply/secret-sync-local:main AS secret-sync -FROM alpine AS chmodder -ARG TARGETARCH -ARG FEATURE -COPY /artifacts/binaries-$TARGETARCH$FEATURE/rusthead /app/rusthead -RUN chmod +x /app/* - -FROM debian:bookworm-slim AS git - -RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* - -FROM gcr.io/distroless/cc-debian13 -ADD --chmod=+x https://github.com/docker/compose/releases/download/v2.40.0/docker-compose-linux-x86_64 /usr/local/bin/docker-compose - -COPY --from=git /usr/bin/git /usr/bin/git -COPY --from=git /usr/lib/git-core/git-remote-https /usr/lib/git-core/git-remote-https - -COPY --from=git /usr/lib/x86_64-linux-gnu/libcurl-gnutls.so.4 \ - /usr/lib/x86_64-linux-gnu/libnghttp2.so.14 \ - /usr/lib/x86_64-linux-gnu/libidn2.so.0 \ - /usr/lib/x86_64-linux-gnu/librtmp.so.1 \ - /usr/lib/x86_64-linux-gnu/libssh2.so.1 \ - /usr/lib/x86_64-linux-gnu/libpsl.so.5 \ - /usr/lib/x86_64-linux-gnu/libnettle.so.8 \ - /usr/lib/x86_64-linux-gnu/libgnutls.so.30 \ - /usr/lib/x86_64-linux-gnu/libgssapi_krb5.so.2 \ - /usr/lib/x86_64-linux-gnu/libldap-2.5.so.0 \ - /usr/lib/x86_64-linux-gnu/liblber-2.5.so.0 \ - /usr/lib/x86_64-linux-gnu/libzstd.so.1 \ - /usr/lib/x86_64-linux-gnu/libbrotlidec.so.1 \ - /usr/lib/x86_64-linux-gnu/libunistring.so.2 \ - /usr/lib/x86_64-linux-gnu/libhogweed.so.6 \ - /usr/lib/x86_64-linux-gnu/libgmp.so.10 \ - /usr/lib/x86_64-linux-gnu/libp11-kit.so.0 \ - /usr/lib/x86_64-linux-gnu/libtasn1.so.6 \ - /usr/lib/x86_64-linux-gnu/libkrb5.so.3 \ - /usr/lib/x86_64-linux-gnu/libk5crypto.so.3 \ - /usr/lib/x86_64-linux-gnu/libcom_err.so.2 \ - /usr/lib/x86_64-linux-gnu/libkrb5support.so.0 \ - /usr/lib/x86_64-linux-gnu/libsasl2.so.2 \ - /usr/lib/x86_64-linux-gnu/libbrotlicommon.so.1 \ - /usr/lib/x86_64-linux-gnu/libffi.so.8 \ - /usr/lib/x86_64-linux-gnu/libpcre2-8.so.0 \ - /usr/lib/x86_64-linux-gnu/libz.so.1 \ - /lib/x86_64-linux-gnu/libkeyutils.so.1 \ - /usr/lib/x86_64-linux-gnu/ - -COPY --from=secret-sync /usr/local/bin/proxy /usr/local/bin/proxy -COPY --from=secret-sync /usr/local/bin/local /usr/local/bin/local -COPY --from=chmodder /app/rusthead /usr/local/bin/rusthead -ENV RUST_BACKTRACE=1 -ENTRYPOINT [ "/usr/local/bin/rusthead" ] +FROM scratch +COPY --chmod=0755 artifacts/rusthead /usr/local/bin/rusthead +ENTRYPOINT ["/usr/local/bin/rusthead"] diff --git a/docs/updates.md b/docs/updates.md index d39a132..418e42f 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -89,3 +89,61 @@ The generated systemd update service accepts status 3 as success and requests a restart for 3 or 4. Status 4 remains a service failure so the push problem stays visible. Standalone enrollment key or certificate changes are accepted with `update commit`; installation accepts these changes before enabling the timer. + +## Updating the executable + +Both update modes check the distribution image selected by the top-level `image` +configuration (default `samply/rusthead:latest`). Git preflight runs first; `sync` +also pulls configuration first, so a remote configuration can select a different +image tag or digest. Compose and enrollment commands do not self-update. + +The updater pulls `linux/amd64`, inspects the immutable image ID, creates a stopped +container, and copies `/usr/local/bin/rusthead` out. It never starts the distribution +container. `.rusthead/binary.json` caches the image ID and executable SHA-256; +unchanged image IDs and executable content avoid extraction, and identical binary +content avoids replacement even when the image ID changes. + +A changed executable must be an x86_64 ELF file and successfully report `--version` +before [self-replace](https://docs.rs/self-replace/latest/self_replace/) replaces +the running executable. The new version continues the requested update with the +same absolute configuration path and update mode; the original process returns +its exit status. An internal handoff prevents a second binary-update check during +that continuation. A binary replacement alone does not restart services: generated +runtime changes still determine the restart status. + +`install` copies the executable to `.rusthead/bin/rusthead`, owned by the +`bridgehead` service account, and points systemd at that managed copy. Use this +copy for later manual commands too. Running `update` from another copy checks and +updates that executable instead; its containing directory must be writable by the +caller. Pull, extraction, and executable validation failures abort generation and +leave the existing executable in place. + +The distribution image is `FROM scratch` and contains only the static executable. +The host needs Git and Docker with Compose. Secret synchronization runs +`docker.verbis.dkfz.de/cache/samply/secret-sync-local:latest` with an isolated cache +and read-only key/certificate mounts; host `proxy` and `local` programs are no +longer needed. + +## Building and testing + +CI builds and tests `x86_64-unknown-linux-musl`, then builds the size-optimized +release binary. It packages the tested release artifact using Samply's reusable +`docker-ci.yml` workflow, retaining its image-tag publishing conventions. + +With Rust's musl target, `musl-gcc`, Docker Compose, and cargo-nextest installed: + +```sh +cargo nextest run --locked --target x86_64-unknown-linux-musl --no-fail-fast +unshare --user --map-root-user cargo nextest run --locked --target x86_64-unknown-linux-musl --no-fail-fast --test install --run-ignored only +cargo build --locked --release --target x86_64-unknown-linux-musl +mkdir -p artifacts +cp target/x86_64-unknown-linux-musl/release/rusthead artifacts/rusthead +docker build --platform linux/amd64 -t rusthead-local . +docker run --rm rusthead-local --version +``` + +CI runs root-only installer tests with `sudo`; the local command above uses an +isolated user namespace. Self-update tests replace private executable copies and fake +Docker transport; they never replace the developer's compiled binary. CI also +smoke-tests the real scratch image and compares its extracted binary with the +release artifact. diff --git a/src/config.rs b/src/config.rs index c5fc668..060990c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -25,7 +25,7 @@ pub struct Config { pub hostname: Host, #[serde(default)] pub environment: Environment, - /// Rusthead Docker image to use (defaults to "samply/rusthead:latest") + /// Distribution image containing the native executable (defaults to "samply/rusthead:latest") #[serde(default = "default_image")] pub image: String, /// Defaults to docker named volumes diff --git a/src/install.rs b/src/install.rs index 0935fd1..18263f3 100644 --- a/src/install.rs +++ b/src/install.rs @@ -70,6 +70,7 @@ pub fn install(config: &PathBuf) -> anyhow::Result { .run() .context("Failed to initialize shared Git repository")?; configure_git(conf)?; + let executable = install_binary(conf, &executable)?; let systemd = match cmd!("systemctl", "status", "docker") .stdout_null() @@ -120,6 +121,20 @@ pub fn install(config: &PathBuf) -> anyhow::Result { Ok(ExitCode::SUCCESS) } +fn install_binary(conf: &Config, source: &Path) -> anyhow::Result { + let directory = crate::update_state::prepare_directory(&conf.path)?.join("bin"); + fs::create_dir_all(&directory)?; + let destination = directory.join("rusthead"); + if source != destination { + let temporary = directory.join("rusthead.tmp"); + fs::copy(source, &temporary).context("Failed to copy the managed executable")?; + fs::set_permissions(&temporary, fs::Permissions::from_mode(0o755))?; + fs::rename(temporary, &destination)?; + } + cmd!("chown", "-R", "-h", "bridgehead:docker", &directory).run()?; + Ok(destination) +} + fn run_update(executable: &Path, config: &Path, directory: &Path) -> anyhow::Result { let status = cmd!( "sudo", diff --git a/src/main.rs b/src/main.rs index d64d12d..ab5f889 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod enrollment; mod git; mod install; mod modules; +mod self_update; mod services; mod update; mod update_state; @@ -35,6 +36,7 @@ enum Subcommand { } #[derive(Debug, clap::Parser)] +#[clap(version)] struct Args { #[clap( short, diff --git a/src/self_update.rs b/src/self_update.rs new file mode 100644 index 0000000..82b1d9d --- /dev/null +++ b/src/self_update.rs @@ -0,0 +1,183 @@ +//! Pull a distribution image, compare executable content, and replace only when needed. +use anyhow::{Context, ensure}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::Read, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process::ExitCode, +}; + +const BINARY_PATH: &str = "/usr/local/bin/rusthead"; +const HANDOFF: &str = "RUSTHEAD_SELF_UPDATE_HANDOFF"; + +#[derive(Deserialize, Serialize)] +struct InstalledImage { + image_id: String, + binary_hash: String, +} + +pub struct Replacement { + executable: PathBuf, + hash: String, +} +impl Replacement { + // The caller releases its update lock first. The child acquires that lock and + // repeats preflight, while skipping this one already-completed image check. + pub fn resume(self, config: &Path, mode: crate::update::Mode) -> anyhow::Result { + let mode = match mode { + crate::update::Mode::Sync => "sync", + crate::update::Mode::Commit => "commit", + }; + let output = duct::cmd( + &self.executable, + [ + "--config".as_ref(), + config.as_os_str(), + "update".as_ref(), + mode.as_ref(), + ], + ) + .env(HANDOFF, &self.hash) + .unchecked() + .run() + .context("Failed to run the updated rusthead executable")?; + let code = output + .status + .code() + .context("Updated rusthead was killed by a signal")?; + Ok(ExitCode::from(u8::try_from(code)?)) + } +} + +fn hash_file(path: &Path) -> anyhow::Result { + let mut file = fs::File::open(path)?; + let mut hash = Sha256::new(); + let mut buffer = [0u8; 65536]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + hash.update(&buffer[..count]); + } + Ok(format!("{:x}", hash.finalize())) +} + +struct Container(String); +impl Drop for Container { + fn drop(&mut self) { + if let Err(error) = duct::cmd!("docker", "rm", "--force", &self.0) + .stdout_capture() + .stderr_capture() + .run() + { + eprintln!("Failed to remove self-update container {}: {error}", self.0); + } + } +} + +pub fn check(conf: &crate::Config) -> anyhow::Result> { + ensure!( + cfg!(all(target_os = "linux", target_arch = "x86_64")), + "Binary self-update supports Linux x86_64 only" + ); + let executable = std::env::current_exe().context("Cannot locate the running executable")?; + let current_hash = hash_file(&executable)?; + if std::env::var(HANDOFF).ok().as_deref() == Some(¤t_hash) { + return Ok(None); + } + ensure!( + !conf.image.is_empty() && !conf.image.starts_with('-'), + "Invalid rusthead distribution image" + ); + println!("Checking rusthead image {}", conf.image); + duct::cmd!("docker", "pull", "--platform", "linux/amd64", &conf.image) + .run() + .context("Failed to pull rusthead distribution image")?; + let inspection = duct::cmd!( + "docker", + "image", + "inspect", + "--format", + "{{.Os}}/{{.Architecture}} {{.Id}}", + &conf.image + ) + .read() + .context("Failed to inspect rusthead distribution image")?; + let (platform, image_id) = inspection + .split_once(' ') + .context("Invalid Docker image inspection result")?; + ensure!( + platform == "linux/amd64" && image_id.starts_with("sha256:"), + "Rusthead distribution image must target linux/amd64" + ); + let metadata_path = conf.path.join(".rusthead/binary.json"); + let cached = fs::read(&metadata_path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + if cached + .is_some_and(|cached| cached.image_id == image_id && cached.binary_hash == current_hash) + { + return Ok(None); + } + let temp = tempfile::tempdir().context("Cannot prepare binary extraction directory")?; + // Create by immutable ID so a concurrent retag cannot change the extracted artifact. + let id = duct::cmd!("docker", "create", "--platform", "linux/amd64", image_id) + .read() + .context("Failed to create rusthead extraction container")?; + ensure!( + !id.is_empty() && !id.starts_with('-') && !id.contains(char::is_whitespace), + "Invalid extraction container ID" + ); + let container = Container(id); + let candidate = temp.path().join("rusthead"); + duct::cmd!( + "docker", + "cp", + format!("{}:{BINARY_PATH}", container.0), + &candidate + ) + .run() + .context("Failed to extract rusthead executable")?; + drop(container); // The distribution container is never started. + ensure!( + fs::symlink_metadata(&candidate)?.is_file(), + "Extracted rusthead must be a regular file" + ); + let new_hash = hash_file(&candidate)?; + if new_hash != current_hash { + let mut header = [0u8; 20]; + fs::File::open(&candidate)? + .read_exact(&mut header) + .context("Extracted executable is truncated")?; + ensure!( + &header[..4] == b"\x7fELF" + && header[4] == 2 + && header[5] == 1 + && header[18..20] == [62, 0], + "Extracted executable is not an x86_64 ELF binary" + ); + fs::set_permissions(&candidate, fs::Permissions::from_mode(0o755))?; + duct::cmd!(&candidate, "--version") + .stdout_capture() + .stderr_capture() + .run() + .context("Extracted rusthead executable could not run")?; + self_replace::self_replace(&candidate).with_context(|| format!("Failed to replace {}; the executable directory must be writable by the update user", executable.display()))?; + println!("Installed a new rusthead binary; continuing with the updated version."); + } + let metadata = InstalledImage { + image_id: image_id.to_owned(), + binary_hash: new_hash.clone(), + }; + let tmp = metadata_path.with_extension("json.tmp"); + fs::write(&tmp, serde_json::to_vec(&metadata)?)?; + fs::rename(tmp, metadata_path)?; + Ok((new_hash != current_hash).then_some(Replacement { + executable, + hash: new_hash, + })) +} diff --git a/src/services/secret_sync.rs b/src/services/secret_sync.rs index 9dec9aa..97b8fca 100644 --- a/src/services/secret_sync.rs +++ b/src/services/secret_sync.rs @@ -3,10 +3,9 @@ use std::{ cell::RefCell, collections::HashMap, fs, - process::Command, }; -use anyhow::bail; +use anyhow::{Context, bail}; use url::{Host, Url}; use crate::config::{Config, LocalConf}; @@ -118,22 +117,9 @@ impl SyncOidc for OidcClient { if secret_sync_defs.is_empty() { bail!("No secrets to sync") } - let temp_dir = std::env::temp_dir(); - let root_cert_file = temp_dir.join(format!("{}.pem", T::BeamProvider::network_name())); + let temp_dir = tempfile::tempdir().context("Failed to create secret-sync directory")?; + let root_cert_file = temp_dir.path().join("root.crt.pem"); fs::write(&root_cert_file, T::BeamProvider::root_cert())?; - let mut beam_proxy_conf = Command::new("proxy"); - beam_proxy_conf - .env("RUST_LOG", "warn") - .env("PRIVKEY_FILE", &self.beam_proxy.priv_key) - .env("ROOTCERT_FILE", root_cert_file) - .env("BROKER_URL", T::BeamProvider::broker_url().as_str()) - .env("PROXY_ID", &self.beam_proxy.proxy_id) - .env("TLS_CA_CERTIFICATES_DIR", &self.beam_proxy.trusted_ca_certs) - .env("APP_secret-sync_KEY", "NotSecret"); - if let Some(http_proxy) = &self.http_proxy_url { - beam_proxy_conf.env("ALL_PROXY", http_proxy.as_str()); - } - let mut beam_proxy = beam_proxy_conf.spawn()?; let cached_data = self .local_conf .borrow() @@ -143,16 +129,21 @@ impl SyncOidc for OidcClient { .map(|(k, v)| format!("{k}=\"{v}\"")) .collect::>() .join("\n"); - let cache_path = temp_dir.join("cache"); + let cache_path = temp_dir.path().join("cache"); fs::write(&cache_path, cached_data)?; - let mut secret_sync = Command::new("local") - .env("PROXY_ID", &self.beam_proxy.proxy_id) - .env("OIDC_PROVIDER", T::oidc_provider_id()) - .env("SECRET_DEFINITIONS", secret_sync_defs.join("\x1E")) - .env("CACHE_PATH", &cache_path) - .spawn()?; - secret_sync.wait()?; - beam_proxy.kill()?; + secret_sync_command( + &cache_path, + &self.beam_proxy.priv_key, + &root_cert_file, + &self.beam_proxy.trusted_ca_certs, + &self.beam_proxy.proxy_id, + T::BeamProvider::broker_url().as_str(), + &T::oidc_provider_id(), + &secret_sync_defs.join("\x1E"), + self.http_proxy_url.as_ref().map(Url::as_str), + )? + .run() + .context("Secret-sync container failed")?; let out = fs::read_to_string(cache_path)?; let new_cache = out .lines() @@ -175,6 +166,71 @@ impl SyncOidc for OidcClient { } } +// The image starts both the Beam proxy and the local secret-sync client. +#[allow(clippy::too_many_arguments)] +fn secret_sync_command( + cache: &std::path::Path, + key: &std::path::Path, + root_cert: &std::path::Path, + trusted_certs: &std::path::Path, + proxy_id: &str, + broker_url: &str, + provider: &str, + definitions: &str, + http_proxy: Option<&str>, +) -> anyhow::Result { + // Docker owns the container lifecycle, including failure cleanup. The temporary + // cache is mounted read/write; keys and trust material are always read-only. + let mut args = vec!["run".to_owned(), "--rm".to_owned()]; + for (source, target, readonly) in [ + (cache, "/usr/local/cache", false), + (key, "/run/secrets/privkey.pem", true), + (root_cert, "/run/secrets/root.crt.pem", true), + (trusted_certs, "/conf/trusted-ca-certs", true), + ] { + let source = source + .canonicalize() + .with_context(|| format!("Cannot mount {} for secret-sync", source.display()))?; + args.extend([ + "-v".to_owned(), + format!( + "{}:{target}{}", + source.display(), + if readonly { ":ro" } else { "" } + ), + ]); + } + let mut command = duct::cmd("docker", { + for name in [ + "TLS_CA_CERTIFICATES_DIR", + "NO_PROXY", + "ALL_PROXY", + "PROXY_ID", + "BROKER_URL", + "OIDC_PROVIDER", + "SECRET_DEFINITIONS", + "CACHE_PATH", + ] { + args.extend(["-e".to_owned(), name.to_owned()]); + } + args.push("docker.verbis.dkfz.de/cache/samply/secret-sync-local:latest".to_owned()); + args + }); + for (name, value) in [ + ("TLS_CA_CERTIFICATES_DIR", "/conf/trusted-ca-certs"), + ("NO_PROXY", "localhost,127.0.0.1"), + ("ALL_PROXY", http_proxy.unwrap_or("")), + ("PROXY_ID", proxy_id), + ("BROKER_URL", broker_url), + ("OIDC_PROVIDER", provider), + ("SECRET_DEFINITIONS", definitions), + ("CACHE_PATH", "/usr/local/cache"), + ] { + command = command.env(name, value); + } + Ok(command) +} + fn evaluate(provider: TypeId) -> &'static RefCell { OIDC_CLIENTS.with_borrow_mut(|m| { let client_spec = m.get_mut(&provider).unwrap(); @@ -253,3 +309,63 @@ pub trait OidcProvider: 'static { fn admin_group(conf: &Config) -> String; } + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn secret_sync_runs_the_container_with_isolated_cache_and_readonly_keys() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + for name in ["cache", "key", "root-cert"] { + fs::write(root.join(name), "existing").unwrap(); + } + fs::create_dir(root.join("trusted")).unwrap(); + fs::write(root.join("docker"), r#"#!/bin/sh +set -eu +printf '%s\n' "$@" > "$TEST_ROOT/args" +printf '%s\n' "$PROXY_ID" "$BROKER_URL" "$OIDC_PROVIDER" "$SECRET_DEFINITIONS" "$ALL_PROXY" "$NO_PROXY" "$CACHE_PATH" > "$TEST_ROOT/env" +printf 'client="new-secret"\n' > "$TEST_ROOT/cache" +exit "${TEST_EXIT:-0}" +"#).unwrap(); + fs::set_permissions(root.join("docker"), fs::Permissions::from_mode(0o755)).unwrap(); + let command = secret_sync_command( + &root.join("cache"), + &root.join("key"), + &root.join("root-cert"), + &root.join("trusted"), + "site.broker", + "https://broker", + "provider", + "OIDC:client:private;https://site", + Some("http://proxy"), + ) + .unwrap() + .env( + "PATH", + format!("{}:{}", root.display(), std::env::var("PATH").unwrap()), + ) + .env("TEST_ROOT", root); + command.run().unwrap(); + let args = fs::read_to_string(root.join("args")).unwrap(); + assert!(args.starts_with("run\n--rm\n")); + assert!(args.contains("/run/secrets/privkey.pem:ro")); + assert!(args.contains("/run/secrets/root.crt.pem:ro")); + assert!(args.contains("/conf/trusted-ca-certs:ro")); + assert!(args.contains("/usr/local/cache\n")); + assert!(args.contains("secret-sync-local:latest")); + assert!( + fs::read_to_string(root.join("env")) + .unwrap() + .contains("http://proxy\nlocalhost,127.0.0.1\n/usr/local/cache") + ); + assert!( + fs::read_to_string(root.join("cache")) + .unwrap() + .contains("new-secret") + ); + assert!(command.env("TEST_EXIT", "9").run().is_err()); + } +} diff --git a/src/update.rs b/src/update.rs index 746bbf2..f128af7 100644 --- a/src/update.rs +++ b/src/update.rs @@ -124,6 +124,10 @@ pub fn run(config: &PathBuf, mode: Mode) -> anyhow::Result { } // Pulling may have changed both the configuration and its local path settings. let conf = Box::leak(Box::new(Config::load(&config)?)); + if let Some(replacement) = crate::self_update::check(conf)? { + drop(_lock); + return replacement.resume(&config, mode); + } state::ensure_ignore(conf)?; let start_inputs = inputs(&repo, conf, &config)?; let head = repo.head()?; diff --git a/tests/install.rs b/tests/install.rs index 4524796..afd549e 100644 --- a/tests/install.rs +++ b/tests/install.rs @@ -125,6 +125,13 @@ fn repeated_install_preserves_repository_and_private_key() { assert_eq!(log.lines().filter(|line| *line == "useradd").count(), 1); assert_eq!(log.lines().filter(|line| *line == "enroll").count(), 1); assert!(log.contains(root.join("site with spaces/custom.toml").to_str().unwrap())); + let managed = root.join("site with spaces/.rusthead/bin/rusthead"); + assert!(managed.is_file()); + assert!(log.contains(managed.to_str().unwrap())); + assert_ne!( + fs::metadata(managed).unwrap().permissions().mode() & 0o111, + 0 + ); let key = root.join("site with spaces/pki/test.priv.pem"); assert_eq!(fs::read_to_string(&key).unwrap(), "private key\n"); assert_eq!( diff --git a/tests/update.rs b/tests/update.rs index b17f82c..c7771fc 100644 --- a/tests/update.rs +++ b/tests/update.rs @@ -9,6 +9,7 @@ struct Site { _temp: tempfile::TempDir, root: PathBuf, bin: PathBuf, + executable: PathBuf, } impl Site { fn new() -> Self { @@ -17,6 +18,11 @@ impl Site { let bin = temp.path().join("bin"); fs::create_dir(&root).unwrap(); fs::create_dir(&bin).unwrap(); + fs::copy( + env!("CARGO_BIN_EXE_rusthead"), + temp.path().join("image-rusthead"), + ) + .unwrap(); fs::write( root.join("custom.toml"), "site_id = 'test'\nhostname = 'localhost'\n[ccp]\n", @@ -25,6 +31,13 @@ impl Site { fs::write(bin.join("docker"), r#"#!/bin/sh set -eu printf '%s\n' "$*" >> "$TEST_SITE/docker.log" +case "$1" in + pull) test ! -f "$TEST_SITE/fail-binary-pull"; exit 0 ;; + image) printf 'linux/amd64 sha256:%s\n' "$(cat "$TEST_SITE/binary-id" 2>/dev/null || echo one)"; exit 0 ;; + create) echo extraction-container; exit 0 ;; + cp) test ! -f "$TEST_SITE/fail-copy"; cp "$TEST_SITE/image-rusthead" "$3"; exit 0 ;; + rm) exit 0 ;; +esac case " $* " in *' --lock-image-digests '*) test ! -f "$TEST_SITE/fail-resolve" @@ -41,10 +54,11 @@ esac _temp: temp, root, bin, + executable: env!("CARGO_BIN_EXE_rusthead").into(), } } fn command(&self, args: &[&str]) -> duct::Expression { - duct::cmd(env!("CARGO_BIN_EXE_rusthead"), args) + duct::cmd(&self.executable, args) .dir(&self.root) .env( "PATH", @@ -449,7 +463,11 @@ fn bare_update_requires_a_mode_and_empty_installations_are_supported() { .unwrap(); site.expect("sync", 3); site.expect("sync", 0); - assert!(!site._temp.path().join("docker.log").exists()); + assert!( + !fs::read_to_string(site._temp.path().join("docker.log")) + .unwrap() + .contains("compose") + ); } #[test] @@ -496,3 +514,115 @@ fn enrollment_receipts_do_not_dirty_inputs_and_survive_update_baseline_reset() { ); assert!(!site.git(&["ls-files"]).contains("enrollment.json")); } + +#[test] +fn self_update_replaces_only_changed_binaries_and_resumes_once() { + use std::{io::Write, os::unix::fs::MetadataExt}; + let mut site = Site::new(); + site.executable = site._temp.path().join("installed-rusthead"); + fs::copy(env!("CARGO_BIN_EXE_rusthead"), &site.executable).unwrap(); + let original_inode = fs::metadata(&site.executable).unwrap().ino(); + site.expect("commit", 3); + site.expect("sync", 0); + assert_eq!( + original_inode, + fs::metadata(&site.executable).unwrap().ino() + ); + let log = fs::read_to_string(site._temp.path().join("docker.log")).unwrap(); + assert_eq!( + log.lines() + .filter(|line| line.starts_with("create ")) + .count(), + 1 + ); + // Simulate a new build without compiling a second Rust binary: ELF permits trailing data. + let mut image = fs::OpenOptions::new() + .append(true) + .open(site._temp.path().join("image-rusthead")) + .unwrap(); + image.write_all(b"new build").unwrap(); + drop(image); + fs::write(site._temp.path().join("binary-id"), "two").unwrap(); + fs::write(site._temp.path().join("digest"), "two").unwrap(); + let out = site.expect("sync", 3); + assert!(String::from_utf8_lossy(&out.stdout).contains("continuing with the updated version")); + assert_ne!( + original_inode, + fs::metadata(&site.executable).unwrap().ino() + ); + assert_eq!( + fs::read(&site.executable).unwrap(), + fs::read(site._temp.path().join("image-rusthead")).unwrap() + ); + let log = fs::read_to_string(site._temp.path().join("docker.log")).unwrap(); + assert_eq!( + log.lines() + .filter(|line| line.starts_with("pull --platform")) + .count(), + 3 + ); + assert_eq!( + log.lines() + .filter(|line| line.starts_with("create ")) + .count(), + 2 + ); + site.expect("sync", 0); +} + +#[test] +fn self_update_failures_preserve_the_executable_and_clean_up_containers() { + let mut site = Site::new(); + site.executable = site._temp.path().join("installed-rusthead"); + fs::copy(env!("CARGO_BIN_EXE_rusthead"), &site.executable).unwrap(); + let original = fs::read(&site.executable).unwrap(); + fs::write(site._temp.path().join("fail-binary-pull"), "").unwrap(); + site.expect("commit", 1); + assert_eq!(original, fs::read(&site.executable).unwrap()); + fs::remove_file(site._temp.path().join("fail-binary-pull")).unwrap(); + fs::write(site._temp.path().join("fail-copy"), "").unwrap(); + site.expect("commit", 1); + let log = fs::read_to_string(site._temp.path().join("docker.log")).unwrap(); + assert!(log.contains("rm --force extraction-container")); + fs::remove_file(site._temp.path().join("fail-copy")).unwrap(); + fs::write( + site._temp.path().join("image-rusthead"), + "not an executable", + ) + .unwrap(); + site.expect("commit", 1); + assert_eq!(original, fs::read(&site.executable).unwrap()); + assert!(!site.root.join("services").exists()); +} + +#[test] +fn a_new_image_with_identical_binary_does_not_replace_and_dirty_sync_does_not_pull() { + use std::os::unix::fs::MetadataExt; + let mut site = Site::new(); + site.executable = site._temp.path().join("installed-rusthead"); + fs::copy(env!("CARGO_BIN_EXE_rusthead"), &site.executable).unwrap(); + let config = fs::read_to_string(site.root.join("custom.toml")).unwrap(); + fs::write( + site.root.join("custom.toml"), + format!("image = 'registry.example/rusthead:test'\n{config}"), + ) + .unwrap(); + site.expect("commit", 3); + let inode = fs::metadata(&site.executable).unwrap().ino(); + fs::write( + site._temp.path().join("binary-id"), + "different-image-same-binary", + ) + .unwrap(); + site.expect("sync", 0); + assert_eq!(inode, fs::metadata(&site.executable).unwrap().ino()); + let log = fs::read_to_string(site._temp.path().join("docker.log")).unwrap(); + assert!(log.contains("pull --platform linux/amd64 registry.example/rusthead:test")); + assert!(log.contains("create --platform linux/amd64 sha256:different-image-same-binary")); + site.append("custom.toml", "# unfinished edit\n"); + site.expect("sync", 1); + assert_eq!( + log, + fs::read_to_string(site._temp.path().join("docker.log")).unwrap() + ); +} From 82b0ec5f8a646db2593c29f319d03f4b77acd0ed Mon Sep 17 00:00:00 2001 From: Threated Date: Wed, 9 Sep 2026 10:54:06 +0200 Subject: [PATCH 5/8] fix justfile --- docs/updates.md | 42 ++++++++++++-- justfile | 48 ++++++++-------- src/install.rs | 139 +++++++++++++++++++++++++++++++++++++++-------- src/main.rs | 13 ++++- src/update.rs | 4 +- tests/install.rs | 11 +++- tests/update.rs | 52 +++++++++++++++++- 7 files changed, 250 insertions(+), 59 deletions(-) diff --git a/docs/updates.md b/docs/updates.md index 418e42f..f3b4a70 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -112,11 +112,21 @@ that continuation. A binary replacement alone does not restart services: generat runtime changes still determine the restart status. `install` copies the executable to `.rusthead/bin/rusthead`, owned by the -`bridgehead` service account, and points systemd at that managed copy. Use this -copy for later manual commands too. Running `update` from another copy checks and -updates that executable instead; its containing directory must be writable by the -caller. Pull, extraction, and executable validation failures abort generation and -leave the existing executable in place. +`bridgehead` service account, and points systemd at that managed copy. After +successful installation it creates `/usr/local/bin/rusthead` as an absolute +symlink to the same executable, replacing an existing launcher atomically. Manual +`rusthead` commands and systemd therefore use the same binary; self-updates replace +the managed target and preserve the symlink. + +Set `BRIDGEHEAD_BIN_DIR` when installing to choose a different launcher directory +(e.g. `sudo env BRIDGEHEAD_BIN_DIR=/opt/bin rusthead --config /srv/site install`). +Ensure that directory precedes other rusthead installations on `PATH`. Installing +another site into the same launcher directory points the command at that site's +managed binary. The launcher directory stays protected; only the managed binary's +directory needs to be writable by `bridgehead`. Explicitly invoking some other +binary by its path still updates that copy instead. Pull, extraction, and +executable validation failures abort generation and leave the existing executable +in place. The distribution image is `FROM scratch` and contains only the static executable. The host needs Git and Docker with Compose. Secret synchronization runs @@ -147,3 +157,25 @@ isolated user namespace. Self-update tests replace private executable copies and Docker transport; they never replace the developer's compiled binary. CI also smoke-tests the real scratch image and compares its extracted binary with the release artifact. + + +## Local development with just + +`just bootstrap` creates `bridgehead/config.toml` with a local site ID and hostname. +Edit it to enable the modules you want; subsequent commands preserve the file. +Set `BRIDGEHEAD_CONFIG_PATH` to use another directory or a `.toml` file. + +- `just build` builds the debug musl executable and the local scratch image + (`IMAGE`, default `samply/rusthead:localbuild`). +- `just run` builds and installs the local executable with sudo, including + enrollment and systemd setup. +- `just up` runs installation, then stops and starts Compose sequentially. +- `just down` stops Compose without rebuilding. +- `just bridgehead update commit` generates and accepts local configuration edits. + Other CLI arguments can be passed through `just bridgehead` too. + +Development recipes use `--no-self-update` so locally built binaries are not +replaced by a registry image. The installer forwards this option to its update +processes and generated systemd commands. Service image pulls and Git behavior +are unchanged. Install again without this option to enable scheduled binary +updates. The native `bootstrap` subcommand is not used by these recipes. diff --git a/justfile b/justfile index fef7fed..8db0dfe 100644 --- a/justfile +++ b/justfile @@ -1,35 +1,35 @@ set shell := ["bash", "-cue"] +set positional-arguments -ARCH := `docker version --format '{{.Server.Arch}}'` CONFIG_PATH := env("BRIDGEHEAD_CONFIG_PATH", "./bridgehead") +CONFIG_FILE := if CONFIG_PATH =~ '\.toml$' { CONFIG_PATH } else { CONFIG_PATH / "config.toml" } +BINARY := "target/x86_64-unknown-linux-musl/debug/rusthead" export IMAGE := env("IMAGE", "samply/rusthead:localbuild") -run: build ensure_bootstrap - sudo {{ CONFIG_PATH }}/bridgehead install +# Build and install the local executable, including enrollment and systemd setup. +run: build bootstrap + sudo {{ quote(BINARY) }} --config {{ quote(CONFIG_FILE) }} --no-self-update install -up: down_bg run - {{ CONFIG_PATH }}/bridgehead compose up - -[private] -down_bg: - {{ CONFIG_PATH }}/bridgehead compose down & - -[private] -ensure_bootstrap: - if ! {{ path_exists(CONFIG_PATH / "bridgehead") }}; then IMAGE=$IMAGE just bootstrap; fi +# Stop and start Compose sequentially using the native executable. +up: run + {{ quote(BINARY) }} --config {{ quote(CONFIG_FILE) }} compose down + {{ quote(BINARY) }} --config {{ quote(CONFIG_FILE) }} compose up down: - {{ CONFIG_PATH }}/bridgehead compose down + {{ quote(BINARY) }} --config {{ quote(CONFIG_FILE) }} compose down -bridgehead *args: build ensure_bootstrap - {{ CONFIG_PATH }}/bridgehead {{ args }} +# Forward arguments literally; update's restart-needed status (3) is successful. +bridgehead *args: build bootstrap + status=0; {{ quote(BINARY) }} --config {{ quote(CONFIG_FILE) }} --no-self-update "$@" || status=$?; if [ "$status" -eq 3 ] && [ "${1:-}" = update ]; then exit 0; fi; exit "$status" +# The scratch distribution image requires a statically linked executable. build: - cargo build - mkdir -p artifacts/binaries-{{ ARCH }}/ - cp target/debug/rusthead artifacts/binaries-{{ ARCH }}/rusthead - docker build -t $IMAGE . - -bootstrap: build - mkdir -p {{ CONFIG_PATH }} - cd {{ CONFIG_PATH }} && bash <(docker run --rm $IMAGE bootstrap) + cargo build --locked --target x86_64-unknown-linux-musl + mkdir -p artifacts + cp {{ quote(BINARY) }} artifacts/rusthead + docker build --platform linux/amd64 -t "$IMAGE" . + +# Create an editable local configuration without overwriting existing settings. +bootstrap: + if [ ! -e {{ quote(CONFIG_FILE) }} ]; then mkdir -p {{ quote(parent_directory(CONFIG_FILE)) }}; printf 'site_id = "local"\nhostname = "localhost"\nimage = "%s"\n' "$IMAGE" > {{ quote(CONFIG_FILE) }}; fi + @echo "Local configuration: "{{ quote(CONFIG_FILE) }}" (edit it to enable modules)." diff --git a/src/install.rs b/src/install.rs index 18263f3..7f167e1 100644 --- a/src/install.rs +++ b/src/install.rs @@ -28,7 +28,7 @@ fn load_materialized(config: &PathBuf) -> anyhow::Result<&'static Config> { Ok(conf) } -pub fn install(config: &PathBuf) -> anyhow::Result { +pub fn install(config: &PathBuf, no_self_update: bool) -> anyhow::Result { require_root()?; let conf = load_materialized(config)?; // Persist the seed and pending networks before update runs in another process. @@ -83,7 +83,12 @@ pub fn install(config: &PathBuf) -> anyhow::Result { Err(error) => return Err(error).context("Failed to check Docker systemd service"), }; if systemd { - install_systemd(Path::new("/etc/systemd/system"), &executable, config)?; + install_systemd( + Path::new("/etc/systemd/system"), + &executable, + config, + no_self_update, + )?; cmd!("systemctl", "daemon-reload").run()?; cmd!("systemctl", "enable", "bridgehead.service").run()?; } else { @@ -91,7 +96,7 @@ pub fn install(config: &PathBuf) -> anyhow::Result { "Systemd is not active or docker is not running via systemd. Skipping systemd setup." ); } - let status = run_update(&executable, config, &conf.path)?; + let status = run_update(&executable, config, &conf.path, no_self_update)?; if !matches!(status, 0 | 3) { return Ok(ExitCode::from(status)); } @@ -105,11 +110,15 @@ pub fn install(config: &PathBuf) -> anyhow::Result { }; if needs_enrollment { // Accept enrollment's local inputs before the clean-only timer starts. - let status = run_update(&executable, config, &conf.path)?; + let status = run_update(&executable, config, &conf.path, no_self_update)?; if !matches!(status, 0 | 3) { return Ok(ExitCode::from(status)); } } + let bin_dir = std::env::var_os("BRIDGEHEAD_BIN_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/usr/local/bin")); + install_launcher(&executable, &bin_dir)?; if systemd { cmd!("systemctl", "enable", "--now", "bridgehead-update.timer").run()?; } @@ -135,22 +144,54 @@ fn install_binary(conf: &Config, source: &Path) -> anyhow::Result { Ok(destination) } -fn run_update(executable: &Path, config: &Path, directory: &Path) -> anyhow::Result { - let status = cmd!( - "sudo", - "-u", - "bridgehead", - executable, - "--config", - config, - "update", - "commit" - ) - .dir(directory) - .unchecked() - .run() - .context("Failed to run bridgehead update")? - .status; +/// Publish a PATH entry without making its directory writable by the service user. +fn install_launcher(executable: &Path, bin_dir: &Path) -> anyhow::Result<()> { + let executable = executable + .canonicalize() + .context("Cannot resolve managed executable")?; + fs::create_dir_all(bin_dir).with_context(|| format!("Cannot create {}", bin_dir.display()))?; + let launcher = bin_dir.canonicalize()?.join("rusthead"); + ensure!( + launcher != executable, + "The launcher directory must differ from the managed binary directory" + ); + if fs::read_link(&launcher).ok().as_ref() == Some(&executable) { + return Ok(()); + } + // Rename replaces an old binary or symlink, without following an old link's target. + let temp = tempfile::Builder::new() + .prefix(".rusthead-link-") + .tempdir_in(bin_dir)?; + let link = temp.path().join("rusthead"); + std::os::unix::fs::symlink(&executable, &link)?; + fs::rename(link, &launcher) + .with_context(|| format!("Cannot install launcher {}", launcher.display()))?; + Ok(()) +} + +fn run_update( + executable: &Path, + config: &Path, + directory: &Path, + no_self_update: bool, +) -> anyhow::Result { + let mut args = vec![ + std::ffi::OsString::from("-u"), + "bridgehead".into(), + executable.into(), + "--config".into(), + config.into(), + ]; + if no_self_update { + args.push("--no-self-update".into()); + } + args.extend(["update".into(), "commit".into()]); + let status = duct::cmd("sudo", args) + .dir(directory) + .unchecked() + .run() + .context("Failed to run bridgehead update")? + .status; match status.code() { Some(code) => { if !matches!(code, 0 | 3) { @@ -272,8 +313,16 @@ fn unit_arg(path: &Path) -> anyhow::Result { )) } -fn install_systemd(directory: &Path, executable: &Path, config: &Path) -> anyhow::Result<()> { - let command = format!("{} --config {}", unit_arg(executable)?, unit_arg(config)?); +fn install_systemd( + directory: &Path, + executable: &Path, + config: &Path, + no_self_update: bool, +) -> anyhow::Result<()> { + let mut command = format!("{} --config {}", unit_arg(executable)?, unit_arg(config)?); + if no_self_update { + command.push_str(" --no-self-update"); + } let units = [ ("bridgehead.service", format!("[Unit]\nDescription=Bridgehead Service\nRequires=docker.service\n\n[Service]\nExecStart={command} compose up --abort-on-container-exit\nRestart=always\nUser=bridgehead\nGroup=docker\n\n[Install]\nWantedBy=multi-user.target\n")), ("bridgehead-update.service", format!("[Unit]\nDescription=Bridgehead Update Service\nRequires=docker.service\n\n[Service]\nExecStart={command} update sync\nSuccessExitStatus=3\nUser=bridgehead\nGroup=docker\nExecStopPost=+/bin/bash -c 'if [ \"$$EXIT_STATUS\" = \"3\" ] || [ \"$$EXIT_STATUS\" = \"4\" ]; then systemctl restart bridgehead.service; fi'\n")), @@ -413,10 +462,10 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let executable = Path::new("/opt/bridge head/rusthead"); let config = Path::new("/srv/bridge head/custom.toml"); - install_systemd(temp.path(), executable, config).unwrap(); + install_systemd(temp.path(), executable, config, false).unwrap(); let service = temp.path().join("bridgehead.service"); let modified = fs::metadata(&service).unwrap().modified().unwrap(); - install_systemd(temp.path(), executable, config).unwrap(); + install_systemd(temp.path(), executable, config, false).unwrap(); assert_eq!( fs::metadata(&service).unwrap().modified().unwrap(), modified @@ -443,6 +492,48 @@ mod tests { assert!(unit_arg(Path::new("/srv/line\nbreak")).is_err()); } + #[test] + fn launcher_replaces_old_binary_and_links_without_touching_their_targets() { + use std::os::unix::fs::{MetadataExt, symlink}; + let temp = tempfile::tempdir().unwrap(); + let bin = temp.path().join("bin"); + fs::create_dir(&bin).unwrap(); + let managed = temp.path().join("managed"); + fs::write(&managed, "new binary").unwrap(); + let launcher = bin.join("rusthead"); + fs::write(&launcher, "old binary").unwrap(); + install_launcher(&managed, &bin).unwrap(); + assert_eq!(fs::read_link(&launcher).unwrap(), managed); + let inode = fs::symlink_metadata(&launcher).unwrap().ino(); + install_launcher(&managed, &bin).unwrap(); + assert_eq!(inode, fs::symlink_metadata(&launcher).unwrap().ino()); + fs::remove_file(&launcher).unwrap(); + let other = temp.path().join("other"); + fs::write(&other, "keep this binary").unwrap(); + symlink(&other, &launcher).unwrap(); + install_launcher(&managed, &bin).unwrap(); + assert_eq!(fs::read_link(&launcher).unwrap(), managed); + assert_eq!(fs::read_to_string(other).unwrap(), "keep this binary"); + fs::remove_file(&launcher).unwrap(); + symlink(temp.path().join("missing"), &launcher).unwrap(); + install_launcher(&managed, &bin).unwrap(); + assert_eq!(fs::read_link(&launcher).unwrap(), managed); + } + + #[test] + fn development_install_keeps_binary_updates_disabled_in_systemd() { + let temp = tempfile::tempdir().unwrap(); + install_systemd( + temp.path(), + Path::new("/srv/site/.rusthead/bin/rusthead"), + Path::new("/srv/site/config.toml"), + true, + ) + .unwrap(); + let unit = fs::read_to_string(temp.path().join("bridgehead-update.service")).unwrap(); + assert!(unit.contains("--no-self-update update sync")); + } + #[test] fn sharing_permissions_preserves_keys_and_does_not_follow_symlinks() { use std::os::unix::fs::symlink; diff --git a/src/main.rs b/src/main.rs index ab5f889..ef51312 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,6 +47,10 @@ struct Args { /// Path to the bridgehead configuration file config: PathBuf, + /// Keep the current executable when developing with a local build. + #[clap(long, global = true)] + no_self_update: bool, + #[clap(subcommand)] command: Subcommand, } @@ -76,10 +80,10 @@ fn main() -> anyhow::Result { .and_then(|c| Some(ExitCode::from(u8::try_from(c).ok()?))) .ok_or(anyhow::anyhow!("Killed by signal")) } - Subcommand::Update { mode } => update::run(&config, *mode), + Subcommand::Update { mode } => update::run(&config, *mode, args.no_self_update), Subcommand::Bootstrap => todo!("Not implemented"), Subcommand::Enroll => install::enroll(&config), - Subcommand::Install => install::install(&config), + Subcommand::Install => install::install(&config, args.no_self_update), } } @@ -113,6 +117,11 @@ fn compose_command_with_lock( "--project-directory".into(), config_dir.as_os_str().to_owned(), ]; + if services.is_empty() { + anyhow::bail!( + "There are currently no services defined by your configuration.\nPlease enable a service in your config.toml and run `rusthead update commit`" + ); + } for service in services { args.push(std::ffi::OsString::from("-f")); args.push(service.into_os_string()); diff --git a/src/update.rs b/src/update.rs index f128af7..754ef73 100644 --- a/src/update.rs +++ b/src/update.rs @@ -60,7 +60,7 @@ fn snapshot(repo: &Repository, conf: &Config, config: &Path) -> anyhow::Result anyhow::Result { +pub fn run(config: &PathBuf, mode: Mode, no_self_update: bool) -> anyhow::Result { let config = if config.is_dir() { config.join("config.toml") } else { @@ -124,7 +124,7 @@ pub fn run(config: &PathBuf, mode: Mode) -> anyhow::Result { } // Pulling may have changed both the configuration and its local path settings. let conf = Box::leak(Box::new(Config::load(&config)?)); - if let Some(replacement) = crate::self_update::check(conf)? { + if !no_self_update && let Some(replacement) = crate::self_update::check(conf)? { drop(_lock); return replacement.resume(&config, mode); } diff --git a/tests/install.rs b/tests/install.rs index afd549e..52cb898 100644 --- a/tests/install.rs +++ b/tests/install.rs @@ -63,7 +63,13 @@ if [ ! -f "$key" ]; then printf 'private key\n' > "$key"; fi } fn run_command(&self, command: &str, update_status: u8) -> Output { - Command::new(env!("CARGO_BIN_EXE_rusthead")) + let launcher = self.temp.path().join("bin/rusthead"); + let executable = if launcher.exists() { + launcher + } else { + env!("CARGO_BIN_EXE_rusthead").into() + }; + Command::new(executable) .args(["--config", "site with spaces/custom.toml", command]) .current_dir(self.temp.path()) .env( @@ -74,6 +80,7 @@ if [ ! -f "$key" ]; then printf 'private key\n' > "$key"; fi std::env::var("PATH").unwrap() ), ) + .env("BRIDGEHEAD_BIN_DIR", self.temp.path().join("bin")) .env("INSTALL_TEST_ROOT", self.temp.path()) .env("INSTALL_TEST_UPDATE_STATUS", update_status.to_string()) .env( @@ -127,6 +134,7 @@ fn repeated_install_preserves_repository_and_private_key() { assert!(log.contains(root.join("site with spaces/custom.toml").to_str().unwrap())); let managed = root.join("site with spaces/.rusthead/bin/rusthead"); assert!(managed.is_file()); + assert_eq!(fs::read_link(root.join("bin/rusthead")).unwrap(), managed); assert!(log.contains(managed.to_str().unwrap())); assert_ne!( fs::metadata(managed).unwrap().permissions().mode() & 0o111, @@ -159,6 +167,7 @@ fn failed_update_stops_installation_before_enrollment() { assert_eq!(unsafe { libc::geteuid() }, 0); let installation = Installation::new(); let result = installation.run(7); + assert!(!installation.temp.path().join("bin/rusthead").exists()); assert_eq!( result.status.code(), Some(7), diff --git a/tests/update.rs b/tests/update.rs index c7771fc..03725b7 100644 --- a/tests/update.rs +++ b/tests/update.rs @@ -58,7 +58,7 @@ esac } } fn command(&self, args: &[&str]) -> duct::Expression { - duct::cmd(&self.executable, args) + duct::cmd(self.executable.as_os_str(), args) .dir(&self.root) .env( "PATH", @@ -626,3 +626,53 @@ fn a_new_image_with_identical_binary_does_not_replace_and_dirty_sync_does_not_pu fs::read_to_string(site._temp.path().join("docker.log")).unwrap() ); } + +#[test] +fn development_updates_skip_binary_download_but_still_update_services() { + let site = Site::new(); + fs::write(site._temp.path().join("fail-binary-pull"), "").unwrap(); + let output = site + .command(&[ + "--config", + "custom.toml", + "--no-self-update", + "update", + "commit", + ]) + .run() + .unwrap(); + assert_eq!( + output.status.code(), + Some(3), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let log = fs::read_to_string(site._temp.path().join("docker.log")).unwrap(); + assert!(!log.lines().any(|line| line.starts_with("pull --platform"))); + assert!( + log.lines() + .any(|line| line.starts_with("compose ") && line.contains("pull --quiet")) + ); +} + +#[test] +fn self_update_through_path_preserves_launcher_and_replaces_managed_binary() { + use std::{io::Write, os::unix::fs::symlink}; + let mut site = Site::new(); + let managed = site._temp.path().join("managed-rusthead"); + fs::copy(env!("CARGO_BIN_EXE_rusthead"), &managed).unwrap(); + let launcher = site.bin.join("rusthead"); + symlink(&managed, &launcher).unwrap(); + site.executable = "rusthead".into(); // Resolve the symlink through this site's PATH. + let image = site._temp.path().join("image-rusthead"); + fs::OpenOptions::new() + .append(true) + .open(&image) + .unwrap() + .write_all(b"updated through PATH") + .unwrap(); + site.expect("commit", 3); + assert_eq!(fs::read_link(&launcher).unwrap(), managed); + assert_eq!(fs::read(&managed).unwrap(), fs::read(image).unwrap()); + site.expect("sync", 0); +} From b331d491b1f4f8d7039b4305d61b1a71c73308d6 Mon Sep 17 00:00:00 2001 From: Threated Date: Wed, 9 Sep 2026 11:31:56 +0200 Subject: [PATCH 6/8] simplify fingerprinting --- docs/updates.md | 24 +++++++--- src/git.rs | 29 ------------ src/main.rs | 2 +- src/services/mod.rs | 9 ---- src/update.rs | 110 +++++++++++++------------------------------- src/update_state.rs | 90 +++++++++++++++++------------------- tests/update.rs | 21 ++++----- 7 files changed, 103 insertions(+), 182 deletions(-) diff --git a/docs/updates.md b/docs/updates.md index f3b4a70..a7a463a 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -16,7 +16,7 @@ Manual generated-file edits are rejected and preserved. `update sync` requires a clean repository and unchanged local inputs. It is the command used by the daily timer. Pending edits to ignored configuration, -certificates, keys, or overrides require an explicit `update commit` too. The +public trust certificates, or overrides require an explicit `update commit` too. The first update initializes a shared local Git repository and accepts the existing inputs. An existing repository must be rooted at the installation directory; worktrees are supported. A configured `volume_dir` inside the installation is @@ -46,8 +46,12 @@ when the remote is reachable and the histories permit synchronization. Ignored `.rusthead/` metadata stores update fingerprints, an update lock, and a separate `enrollment.json` containing completed enrollment records. It contains no copies of secret contents and is accessible only to the installation owner -and group. Runtime change detection includes generated files, pinned image -versions, environment values, overrides, and certificates; a config-only commit +and group. `state.json` stores only ignored `local_inputs` and generated `outputs`, +with paths relative to the site. Git-tracked inputs are not fingerprinted. +Runtime changes are derived from outputs and local inputs other than +`config.local.toml`; there is no separate stored runtime map. +Runtime change detection includes generated files, pinned image +versions, environment values, overrides, and public trust certificates; a config-only commit need not request a restart. No-op or ignored-only changes create no empty commits. `enrollment.json` is durable operational state: resetting `state.json` must not @@ -56,7 +60,12 @@ are not persisted. The seed, credentials, and user configuration remain in `config.local.toml`. Enrollment saves each completed network independently. Receipt changes alone do -not dirty update inputs; changes to actual keys or certificates still do. A missing +not dirty update inputs. `pki/` and `traefik-tls/` are excluded from fingerprints. +Custom TLS certificate/key paths are not added to the fingerprint inputs; keep +those files outside the repository or ignored by Git. +Their contents need not be readable by the user running updates, and rotation +does not block `update sync` or trigger a restart. Handle any required reload or +restart separately. TLS paths in configuration remain tracked inputs. A missing site private key invalidates its enrollment records, retaining the existing reenrollment behavior. @@ -71,8 +80,11 @@ A migrated installation without a fingerprint baseline establishes one on its first successful update and conservatively requests a restart. Untracked legacy `.env` edits cannot be identified before this baseline exists. -Compose commands warn about pending inputs but remain usable, including diagnostics -and shutdown with invalid source configuration. Generation, image pulling, and +Compose commands warn about uncommitted Git changes, local input changes, or +changed outputs, but do not detect committed configuration changes awaiting +generation. They remain usable, including diagnostics and shutdown with invalid +source configuration. Updates do not check for edits made while they run; the +update lock still prevents simultaneous rusthead updates. Generation, image pulling, and Compose commands all use `docker compose`; pulling and launching include the image lockfile after the override file. diff --git a/src/git.rs b/src/git.rs index cd1ebad..38d4132 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,7 +1,6 @@ //! Git history and transport. Generation and runtime change detection live elsewhere. use anyhow::{Context, ensure}; use std::{ - ffi::OsString, path::{Path, PathBuf}, process::Output, }; @@ -88,13 +87,6 @@ impl Repository { .status .success()) } - pub fn head(&self) -> anyhow::Result> { - if self.has_head()? { - self.run(&["rev-parse", "HEAD"]) - } else { - Ok(Vec::new()) - } - } pub fn ensure_idle(&self) -> anyhow::Result<()> { for marker in [ "MERGE_HEAD", @@ -160,27 +152,6 @@ impl Repository { ])? .is_empty()) } - pub fn input_paths(&self) -> anyhow::Result> { - use std::os::unix::ffi::OsStringExt; - let files = self.run(&[ - "ls-files", - "-z", - "--cached", - "--others", - "--exclude-standard", - ])?; - Ok(files - .split(|b| *b == 0) - .filter(|p| !p.is_empty()) - .map(|p| PathBuf::from(OsString::from_vec(p.to_vec()))) - .filter(|p| { - !p.starts_with("services") - && !p.starts_with(".rusthead") - && p != Path::new("docker-image.lock.yml") - && p != Path::new(".env") - }) - .collect()) - } pub fn upstream(&self) -> anyhow::Result<(String, String)> { let branch = self .run(&["symbolic-ref", "--quiet", "--short", "HEAD"]) diff --git a/src/main.rs b/src/main.rs index ef51312..09e98ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -70,7 +70,7 @@ fn main() -> anyhow::Result { let cwd = std::env::current_dir()?; match &args.command { Subcommand::Compose { compose_args } => { - update::warn_pending(&cwd, &config); + update::warn_pending(&cwd); compose_command(&cwd, compose_args)? .unchecked() .run() diff --git a/src/services/mod.rs b/src/services/mod.rs index 9b1360e..31ef251 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -251,15 +251,7 @@ impl ServiceMap { self.map.len() } - #[cfg(test)] pub fn write_all(&mut self) -> anyhow::Result<()> { - self.write_all_checked(|| Ok(())) - } - - pub fn write_all_checked( - &mut self, - check_inputs: impl FnOnce() -> anyhow::Result<()>, - ) -> anyhow::Result<()> { self.materialize(); // Render everything before replacing the previous service definitions. let rendered = self @@ -267,7 +259,6 @@ impl ServiceMap { .values() .map(|service| Ok((service.service_name(), service.render(self.config)?))) .collect::>>()?; - check_inputs()?; let services_dir = self.config.path.join("services"); fs::create_dir_all(&services_dir)?; let expected: std::collections::HashSet<_> = rendered diff --git a/src/update.rs b/src/update.rs index 754ef73..8567c1a 100644 --- a/src/update.rs +++ b/src/update.rs @@ -3,7 +3,7 @@ use crate::{ git::Repository, modules, services::ServiceMap, - update_state::{self as state, Fingerprints, OUTPUTS, State, UpdateLock}, + update_state::{self as state, OUTPUTS, State, UpdateLock}, }; use anyhow::{Context, ensure}; use std::{ @@ -19,44 +19,29 @@ pub enum Mode { Commit, } -fn inputs(repo: &Repository, conf: &Config, config: &Path) -> anyhow::Result { - let mut paths = repo.input_paths()?; - if let Some(volume) = state::volume_path(conf)? { - paths.retain(|path| !repo.root.join(path).starts_with(&volume)); - if let Ok(relative) = volume.strip_prefix(&repo.root) { - ensure!( - repo.run(&[ - "ls-files", - "--", - relative - .to_str() - .context("Volume directory must be UTF-8")? - ])? - .is_empty(), - "Runtime data is tracked in Git; untrack the volume directory before updating" - ); - } +fn ensure_volume_untracked(repo: &Repository, conf: &Config) -> anyhow::Result<()> { + if let Some(volume) = state::volume_path(conf)? + && let Ok(relative) = volume.strip_prefix(&repo.root) + { + ensure!( + repo.run(&[ + "ls-files", + "--", + relative + .to_str() + .context("Volume directory must be UTF-8")? + ])? + .is_empty(), + "Runtime data is tracked in Git; untrack the volume directory before updating" + ); } - paths.push(config.to_owned()); - paths.extend(state::local_paths(conf)); - state::fingerprint(&repo.root, paths) + Ok(()) } -fn snapshot(repo: &Repository, conf: &Config, config: &Path) -> anyhow::Result { - let local_inputs = state::fingerprint(&repo.root, state::local_paths(conf))?; - let outputs = state::fingerprint(&repo.root, OUTPUTS.iter().map(PathBuf::from))?; - let mut runtime = outputs.clone(); - // config.local is a generation input. Its derived .env is the runtime artifact. - runtime.extend( - local_inputs - .iter() - .filter(|(key, _)| key.as_str() != "config.local.toml") - .map(|(k, v)| (k.clone(), v.clone())), - ); + +fn snapshot(root: &Path) -> anyhow::Result { Ok(State { - inputs: inputs(repo, conf, config)?, - local_inputs, - outputs, - runtime, + local_inputs: state::fingerprint(root, state::local_paths())?, + outputs: state::fingerprint(root, OUTPUTS.iter().map(PathBuf::from))?, }) } @@ -84,7 +69,8 @@ pub fn run(config: &PathBuf, mode: Mode, no_self_update: bool) -> anyhow::Result } else { None }; - let before = snapshot(&repo, &conf, &config)?; + ensure_volume_untracked(&repo, &conf)?; + let before = snapshot(root)?; if !initial { match mode { Mode::Sync => { @@ -128,42 +114,18 @@ pub fn run(config: &PathBuf, mode: Mode, no_self_update: bool) -> anyhow::Result drop(_lock); return replacement.resume(&config, mode); } + ensure_volume_untracked(&repo, conf)?; state::ensure_ignore(conf)?; - let start_inputs = inputs(&repo, conf, &config)?; - let head = repo.head()?; - let index = repo.run(&["ls-files", "--stage", "-z"])?; let mut services = ServiceMap::new(conf); for module in modules::MODULES { services.install_module(*module); } - services.write_all_checked(|| { - let mut now = inputs(&repo, conf, &config)?; - // Constructors may create missing certificates. Existing inputs must remain unchanged. - now.retain(|key, _| start_inputs.contains_key(key) || !["pki/", "trusted-ca-certs/", "traefik-tls/"].iter().any(|prefix| key.starts_with(prefix))); - ensure!(now == start_inputs, "Inputs changed during generation; retry after finishing your edits"); - Ok(()) - }).context("Generation failed; no update commit was created. Inspect any partial generated files before retrying")?; - let expected = inputs(&repo, conf, &config)?; - let mut expected_outputs = state::record_pending(root)?; - services.generate_lockfile_and_pull(|| { - let now = state::fingerprint(root, OUTPUTS.iter().map(PathBuf::from))?; - let without_lock = |fp: &Fingerprints| fp.iter().filter(|(k, _)| k.as_str() != "docker-image.lock.yml").map(|(k,v)| (k.clone(),v.clone())).collect::(); - ensure!(without_lock(&now) == without_lock(&expected_outputs), "Generated files changed while resolving images; no commit was created"); - expected_outputs = state::record_pending(root)?; - Ok(()) - }).context("Image update failed; no update commit was created. Run update commit to retry; generated files must remain unedited")?; - ensure!( - expected == inputs(&repo, conf, &config)? - && head == repo.head()? - && index == repo.run(&["ls-files", "--stage", "-z"])?, - "Inputs or Git state changed during update; no commit was created. Inspect generated files before retrying" - ); - let after = snapshot(&repo, conf, &config)?; - ensure!( - after.outputs == expected_outputs, - "Generated files changed during image validation or pull; no commit was created" - ); - let changed = baseline.as_ref().is_none_or(|s| s.runtime != after.runtime); + services.write_all().context("Generation failed; no update commit was created. Inspect any partial generated files before retrying")?; + state::record_pending(root)?; + services.generate_lockfile_and_pull(|| state::record_pending(root).map(|_| ())) + .context("Image update failed; no update commit was created. Run update commit to retry; generated files must remain unedited")?; + let after = snapshot(root)?; + let changed = baseline.as_ref().is_none_or(|s| s.runtime_changed(&after)); repo.commit()?; after.save(root)?; std::fs::remove_file(root.join(".rusthead/pending.json"))?; @@ -189,7 +151,7 @@ pub fn run(config: &PathBuf, mode: Mode, no_self_update: bool) -> anyhow::Result })) } -pub fn warn_pending(root: &Path, config: &Path) { +pub fn warn_pending(root: &Path) { let check = || -> anyhow::Result { let Some(repo) = Repository::open(root)? else { return Ok(true); @@ -200,14 +162,8 @@ pub fn warn_pending(root: &Path, config: &Path) { let Some(baseline) = State::load(root)? else { return Ok(true); }; - let config = if config.is_dir() { - config.join("config.toml") - } else { - config.to_owned() - }; - let conf = Config::load(&config)?; - let now = snapshot(&repo, &conf, &config)?; - Ok(now.inputs != baseline.inputs || now.outputs != baseline.outputs) + let now = snapshot(root)?; + Ok(now != baseline) }; match check() { Ok(false) => {} diff --git a/src/update_state.rs b/src/update_state.rs index 6fd7d2c..77f9dc3 100644 --- a/src/update_state.rs +++ b/src/update_state.rs @@ -3,7 +3,7 @@ use anyhow::{Context, ensure}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ - collections::{BTreeMap, BTreeSet}, + collections::BTreeMap, fs::{self, File, OpenOptions}, os::{ fd::AsRawFd, @@ -16,18 +16,14 @@ pub type Fingerprints = BTreeMap; pub const LOCAL_INPUTS: &[&str] = &[ "config.local.toml", "docker-compose.override.yml", - "pki", "trusted-ca-certs", - "traefik-tls", ]; pub const OUTPUTS: &[&str] = &["services", "docker-image.lock.yml", ".env"]; -#[derive(Default, Serialize, Deserialize)] +#[derive(Default, PartialEq, Eq, Serialize, Deserialize)] pub struct State { - pub inputs: Fingerprints, pub local_inputs: Fingerprints, pub outputs: Fingerprints, - pub runtime: Fingerprints, } pub struct UpdateLock { @@ -79,6 +75,19 @@ pub(crate) fn prepare_directory(root: &Path) -> anyhow::Result { } impl State { + pub fn runtime_changed(&self, other: &Self) -> bool { + // config.local affects runtime through the generated .env and service files. + self.outputs != other.outputs + || self + .local_inputs + .iter() + .filter(|(key, _)| key.as_str() != "config.local.toml") + .ne(other + .local_inputs + .iter() + .filter(|(key, _)| key.as_str() != "config.local.toml")) + } + pub fn load(root: &Path) -> anyhow::Result> { match fs::read(root.join(".rusthead/state.json")) { Ok(bytes) => Ok(Some( @@ -111,62 +120,49 @@ pub fn fingerprint( ) -> anyhow::Result { let mut result = Fingerprints::new(); for path in paths { - visit(root, &path, &mut result, &mut BTreeSet::new())?; + let full = root.join(&path); + let metadata = match fs::metadata(&full) { + Ok(metadata) => metadata, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(e).with_context(|| format!("Cannot inspect {}", full.display())), + }; + if metadata.is_dir() { + // Generated services and trust certificates are flat directories. + for entry in fs::read_dir(&full)? { + fingerprint_file(root, &path.join(entry?.file_name()), &mut result)?; + } + } else { + fingerprint_file(root, &path, &mut result)?; + } } Ok(result) } -fn visit( - root: &Path, - path: &Path, - result: &mut Fingerprints, - ancestors: &mut BTreeSet, -) -> anyhow::Result<()> { + +fn fingerprint_file(root: &Path, path: &Path, result: &mut Fingerprints) -> anyhow::Result<()> { let full = root.join(path); - let meta = match fs::symlink_metadata(&full) { - Ok(meta) => meta, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(e) => return Err(e).with_context(|| format!("Cannot inspect {}", full.display())), - }; + ensure!( + fs::metadata(&full)?.is_file(), + "Expected a file while fingerprinting {}", + full.display() + ); let key = path .to_str() .context("Update fingerprint paths must be UTF-8")? .to_owned(); - if meta.file_type().is_symlink() { - let target = fs::read_link(&full)?; + if fs::symlink_metadata(&full)?.file_type().is_symlink() { result.insert( format!("{key}/@symlink"), - target.to_string_lossy().into_owned(), - ); - } - let meta = fs::metadata(&full).with_context(|| format!("Cannot follow {}", full.display()))?; - if meta.is_dir() { - let canonical = full.canonicalize()?; - ensure!( - ancestors.insert(canonical.clone()), - "Symlink cycle at {}", - full.display() + fs::read_link(&full)?.to_string_lossy().into_owned(), ); - for entry in fs::read_dir(full)? { - visit(root, &path.join(entry?.file_name()), result, ancestors)?; - } - ancestors.remove(&canonical); - } else { - ensure!( - meta.is_file(), - "Cannot fingerprint special file {}", - full.display() - ); - result.insert(key, format!("{:x}", Sha256::digest(fs::read(&full)?))); } + let contents = + fs::read(&full).with_context(|| format!("Cannot fingerprint {}", full.display()))?; + result.insert(key, format!("{:x}", Sha256::digest(contents))); Ok(()) } -pub fn local_paths(conf: &crate::Config) -> Vec { - let mut paths: Vec<_> = LOCAL_INPUTS.iter().map(PathBuf::from).collect(); - if let Some(tls) = conf.traefik.as_ref().and_then(|t| t.tls.as_ref()) { - paths.extend([tls.cert_file.clone(), tls.key_file.clone()]); - } - paths +pub fn local_paths() -> Vec { + LOCAL_INPUTS.iter().map(PathBuf::from).collect() } pub fn ensure_ignore(conf: &crate::Config) -> anyhow::Result<()> { diff --git a/tests/update.rs b/tests/update.rs index 03725b7..341e788 100644 --- a/tests/update.rs +++ b/tests/update.rs @@ -45,7 +45,6 @@ case " $* " in ;; *' pull '*) test ! -f "$TEST_SITE/fail-pull" - if test -f "$TEST_SITE/edit-during-pull"; then echo '# concurrent edit' >> "$TEST_SITE/site with spaces/custom.toml"; fi ;; esac "#).unwrap(); @@ -332,7 +331,7 @@ fn explicit_sync_opt_in_and_missing_upstream() { } #[test] -fn concurrent_input_edits_and_update_lock() { +fn update_lock_rejects_another_update() { use std::os::fd::AsRawFd; let site = Site::new(); site.expect("commit", 3); @@ -346,15 +345,7 @@ fn concurrent_input_edits_and_update_lock() { ); assert!(String::from_utf8_lossy(&site.expect("sync", 1).stderr).contains("Another update")); drop(file); - let head = site.head(); - fs::write(site._temp.path().join("edit-during-pull"), "").unwrap(); - site.expect("sync", 1); - assert_eq!(head, site.head()); - assert!( - fs::read_to_string(site.root.join("custom.toml")) - .unwrap() - .contains("concurrent edit") - ); + site.expect("sync", 0); } #[test] @@ -420,13 +411,17 @@ fn ignored_runtime_changes_restart_without_empty_commits() { let site = Site::new(); site.expect("commit", 3); let head = site.head(); - fs::write(site.root.join("pki/runtime.pem"), "certificate").unwrap(); + fs::write( + site.root.join("trusted-ca-certs/runtime.pem"), + "certificate", + ) + .unwrap(); site.expect("sync", 1); site.expect("commit", 3); assert_eq!(head, site.head()); assert!(!site.git(&["ls-files"]).contains("runtime.pem")); site.expect("sync", 0); - fs::remove_file(site.root.join("pki/runtime.pem")).unwrap(); + fs::remove_file(site.root.join("trusted-ca-certs/runtime.pem")).unwrap(); site.expect("sync", 1); site.expect("commit", 3); assert_eq!(head, site.head()); From 194cfdab3113e799ed65673971f7b79b46638a69 Mon Sep 17 00:00:00 2001 From: Threated Date: Wed, 9 Sep 2026 13:17:39 +0200 Subject: [PATCH 7/8] add bootstrap command --- docs/updates.md | 56 +++++++++++++++++++++-------- justfile | 6 ++-- src/install.rs | 57 ------------------------------ src/main.rs | 7 +++- static/bootstrap.sh | 85 +++++++++++++++++++++++++++++++++++++++++++++ tests/install.rs | 12 +++---- 6 files changed, 141 insertions(+), 82 deletions(-) create mode 100644 static/bootstrap.sh diff --git a/docs/updates.md b/docs/updates.md index a7a463a..5e554a5 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -1,5 +1,29 @@ # Native installation updates +For a new x86_64 Linux installation with Docker available, run: + +```sh +bash <(docker run --rm samply/rusthead bootstrap) +``` + +The script prompts for a site directory, site ID, hostname, optional HTTPS proxy, +and binary directory (default `/usr/local/bin`). It extracts the executable from +the scratch image and creates `config.toml`, then prints the sudo install command +to run after enabling the desired modules. Existing configuration files are never +overwritten; bootstrap can install the binary and link for an existing config too. +Bootstrap stores the binary in `.rusthead/bin/rusthead` and creates a symlink +in the selected binary directory. + +To select the binary image and the image tracked by future self-updates, set +`IMAGE` for the host Bash process: + +```sh +IMAGE=samply/rusthead:my-tag bash <(docker run --rm samply/rusthead bootstrap) +``` + +This value is written to `config.toml`; it does not need to be passed into the +container that prints the script. + Run commands against the installation directory or its selected configuration file: ```sh @@ -123,18 +147,17 @@ its exit status. An internal handoff prevents a second binary-update check durin that continuation. A binary replacement alone does not restart services: generated runtime changes still determine the restart status. -`install` copies the executable to `.rusthead/bin/rusthead`, owned by the -`bridgehead` service account, and points systemd at that managed copy. After -successful installation it creates `/usr/local/bin/rusthead` as an absolute -symlink to the same executable, replacing an existing launcher atomically. Manual -`rusthead` commands and systemd therefore use the same binary; self-updates replace -the managed target and preserve the symlink. - -Set `BRIDGEHEAD_BIN_DIR` when installing to choose a different launcher directory -(e.g. `sudo env BRIDGEHEAD_BIN_DIR=/opt/bin rusthead --config /srv/site install`). -Ensure that directory precedes other rusthead installations on `PATH`. Installing -another site into the same launcher directory points the command at that site's -managed binary. The launcher directory stays protected; only the managed binary's +Bootstrap creates the executable at `.rusthead/bin/rusthead` and a symlink in the +selected binary directory (default `/usr/local/bin`). `install` assigns ownership +to the `bridgehead` service account and points systemd at the managed binary. When +invoked from another executable, such as a development build, it copies that +executable into the managed location. It does not create or change PATH symlinks. +Manual commands through the symlink and systemd use the same binary; self-updates +replace the managed target and preserve the symlink. + +Ensure the selected directory precedes other rusthead installations on `PATH`. +Bootstrapping another site into the same launcher directory points the command at +that site's managed binary. The launcher directory stays protected; only the managed directory needs to be writable by `bridgehead`. Explicitly invoking some other binary by its path still updates that copy instead. Pull, extraction, and executable validation failures abort generation and leave the existing executable @@ -173,8 +196,10 @@ release artifact. ## Local development with just -`just bootstrap` creates `bridgehead/config.toml` with a local site ID and hostname. -Edit it to enable the modules you want; subsequent commands preserve the file. +`just bootstrap` builds the local image and runs the interactive bootstrap script +if `bridgehead/config.toml` is missing. It prompts for site settings and a binary +directory, extracting from the local image without pulling from a registry. +Edit the config to enable the modules you want; subsequent commands preserve it. Set `BRIDGEHEAD_CONFIG_PATH` to use another directory or a `.toml` file. - `just build` builds the debug musl executable and the local scratch image @@ -190,4 +215,5 @@ Development recipes use `--no-self-update` so locally built binaries are not replaced by a registry image. The installer forwards this option to its update processes and generated systemd commands. Service image pulls and Git behavior are unchanged. Install again without this option to enable scheduled binary -updates. The native `bootstrap` subcommand is not used by these recipes. +updates. The recipes run `static/bootstrap.sh`, the same script emitted by the +native `bootstrap` subcommand. diff --git a/justfile b/justfile index 8db0dfe..816e0c4 100644 --- a/justfile +++ b/justfile @@ -29,7 +29,7 @@ build: cp {{ quote(BINARY) }} artifacts/rusthead docker build --platform linux/amd64 -t "$IMAGE" . -# Create an editable local configuration without overwriting existing settings. -bootstrap: - if [ ! -e {{ quote(CONFIG_FILE) }} ]; then mkdir -p {{ quote(parent_directory(CONFIG_FILE)) }}; printf 'site_id = "local"\nhostname = "localhost"\nimage = "%s"\n' "$IMAGE" > {{ quote(CONFIG_FILE) }}; fi +# Run interactive bootstrap against the local image when configuration is missing. +bootstrap: build + if [ ! -e {{ quote(CONFIG_FILE) }} ]; then BRIDGEHEAD_CONFIG_PATH={{ quote(CONFIG_FILE) }} BOOTSTRAP_SKIP_PULL=1 bash static/bootstrap.sh; fi @echo "Local configuration: "{{ quote(CONFIG_FILE) }}" (edit it to enable modules)." diff --git a/src/install.rs b/src/install.rs index 7f167e1..9d5191e 100644 --- a/src/install.rs +++ b/src/install.rs @@ -115,10 +115,6 @@ pub fn install(config: &PathBuf, no_self_update: bool) -> anyhow::Result anyhow::Result { Ok(destination) } -/// Publish a PATH entry without making its directory writable by the service user. -fn install_launcher(executable: &Path, bin_dir: &Path) -> anyhow::Result<()> { - let executable = executable - .canonicalize() - .context("Cannot resolve managed executable")?; - fs::create_dir_all(bin_dir).with_context(|| format!("Cannot create {}", bin_dir.display()))?; - let launcher = bin_dir.canonicalize()?.join("rusthead"); - ensure!( - launcher != executable, - "The launcher directory must differ from the managed binary directory" - ); - if fs::read_link(&launcher).ok().as_ref() == Some(&executable) { - return Ok(()); - } - // Rename replaces an old binary or symlink, without following an old link's target. - let temp = tempfile::Builder::new() - .prefix(".rusthead-link-") - .tempdir_in(bin_dir)?; - let link = temp.path().join("rusthead"); - std::os::unix::fs::symlink(&executable, &link)?; - fs::rename(link, &launcher) - .with_context(|| format!("Cannot install launcher {}", launcher.display()))?; - Ok(()) -} - fn run_update( executable: &Path, config: &Path, @@ -492,34 +463,6 @@ mod tests { assert!(unit_arg(Path::new("/srv/line\nbreak")).is_err()); } - #[test] - fn launcher_replaces_old_binary_and_links_without_touching_their_targets() { - use std::os::unix::fs::{MetadataExt, symlink}; - let temp = tempfile::tempdir().unwrap(); - let bin = temp.path().join("bin"); - fs::create_dir(&bin).unwrap(); - let managed = temp.path().join("managed"); - fs::write(&managed, "new binary").unwrap(); - let launcher = bin.join("rusthead"); - fs::write(&launcher, "old binary").unwrap(); - install_launcher(&managed, &bin).unwrap(); - assert_eq!(fs::read_link(&launcher).unwrap(), managed); - let inode = fs::symlink_metadata(&launcher).unwrap().ino(); - install_launcher(&managed, &bin).unwrap(); - assert_eq!(inode, fs::symlink_metadata(&launcher).unwrap().ino()); - fs::remove_file(&launcher).unwrap(); - let other = temp.path().join("other"); - fs::write(&other, "keep this binary").unwrap(); - symlink(&other, &launcher).unwrap(); - install_launcher(&managed, &bin).unwrap(); - assert_eq!(fs::read_link(&launcher).unwrap(), managed); - assert_eq!(fs::read_to_string(other).unwrap(), "keep this binary"); - fs::remove_file(&launcher).unwrap(); - symlink(temp.path().join("missing"), &launcher).unwrap(); - install_launcher(&managed, &bin).unwrap(); - assert_eq!(fs::read_link(&launcher).unwrap(), managed); - } - #[test] fn development_install_keeps_binary_updates_disabled_in_systemd() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/main.rs b/src/main.rs index 09e98ac..75eac28 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ enum Subcommand { #[clap(subcommand)] mode: update::Mode, }, + /// Print a Bash script that installs the native binary and creates a configuration. Bootstrap, Enroll, Install, @@ -57,6 +58,10 @@ struct Args { fn main() -> anyhow::Result { let args = Args::parse(); + if let Subcommand::Bootstrap = args.command { + println!("{}", include_str!("../static/bootstrap.sh")); + return Ok(ExitCode::SUCCESS); + } let config = args .config .canonicalize() @@ -81,7 +86,7 @@ fn main() -> anyhow::Result { .ok_or(anyhow::anyhow!("Killed by signal")) } Subcommand::Update { mode } => update::run(&config, *mode, args.no_self_update), - Subcommand::Bootstrap => todo!("Not implemented"), + Subcommand::Bootstrap => unreachable!("bootstrap is handled before loading configuration"), Subcommand::Enroll => install::enroll(&config), Subcommand::Install => install::install(&config, args.no_self_update), } diff --git a/static/bootstrap.sh b/static/bootstrap.sh new file mode 100644 index 0000000..5e982db --- /dev/null +++ b/static/bootstrap.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +image=${IMAGE:-samply/rusthead:latest} +if [[ -n ${BRIDGEHEAD_CONFIG_PATH:-} ]]; then + config_file=$BRIDGEHEAD_CONFIG_PATH + [[ "$config_file" == *.toml ]] || config_file=$config_file/config.toml + config_dir=$(dirname -- "$config_file") + config_name=$(basename -- "$config_file") +else + read -r -p 'Installation directory [.]: ' config_dir + config_dir=${config_dir:-.} + config_name=config.toml +fi +mkdir -p -- "$config_dir" +config_dir=$(cd -- "$config_dir" && pwd -P) +config_file=$config_dir/$config_name +create_config=true +if [[ -e "$config_file" || -L "$config_file" ]]; then + create_config=false + echo "Using existing configuration at $config_file" +else + read -r -p 'Site ID: ' site_id + default_hostname=$(hostname -f 2>/dev/null || hostname) + read -r -p "Hostname [$default_hostname]: " hostname + hostname=${hostname:-$default_hostname} + read -r -p "HTTPS proxy [${HTTPS_PROXY:-None}]: " proxy + proxy=${proxy:-${HTTPS_PROXY:-}} +fi + +read -r -p 'Binary directory (ideally on PATH) [/usr/local/bin]: ' bin_dir +bin_dir=${bin_dir:-/usr/local/bin} + +as_root=() +if ! mkdir -p -- "$bin_dir" 2>/dev/null || [[ ! -w "$bin_dir" ]]; then + as_root=(sudo) + sudo mkdir -p -- "$bin_dir" +fi +bin_dir=$(cd -- "$bin_dir" && pwd -P) +binary=$bin_dir/rusthead + +tmp=$(mktemp -d) +container= +cleanup() { + if [[ -n "$container" ]]; then docker rm "$container" >/dev/null || true; fi + rm -rf -- "$tmp" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +if [[ ${BOOTSTRAP_SKIP_PULL:-0} != 1 ]]; then + echo "Pulling $image..." + docker pull --platform linux/amd64 "$image" +fi +container=$(docker create --platform linux/amd64 "$image") +docker cp "$container:/usr/local/bin/rusthead" "$tmp/rusthead" +docker rm "$container" >/dev/null +container= +chmod 755 "$tmp/rusthead" +"$tmp/rusthead" --version + +managed_binary=$config_dir/.rusthead/bin/rusthead +mkdir -p -- "$config_dir/.rusthead/bin" +chmod 2770 "$config_dir/.rusthead" +install -m 755 -T -- "$tmp/rusthead" "$managed_binary" +if [[ "$binary" != "$managed_binary" ]]; then + "${as_root[@]}" ln -sfnT -- "$managed_binary" "$binary" +fi + +if "$create_config"; then + cat > "$config_file" <> "$config_file" + fi +fi + +printf '\nInstalled rusthead at %s\nConfiguration: %s\n' "$binary" "$config_file" +printf '\nEdit config.toml to enable your modules, then run:\n\n' +printf ' cd %q\n' "$config_dir" +printf ' sudo rusthead install\n' diff --git a/tests/install.rs b/tests/install.rs index 52cb898..201e51e 100644 --- a/tests/install.rs +++ b/tests/install.rs @@ -63,9 +63,12 @@ if [ ! -f "$key" ]; then printf 'private key\n' > "$key"; fi } fn run_command(&self, command: &str, update_status: u8) -> Output { - let launcher = self.temp.path().join("bin/rusthead"); - let executable = if launcher.exists() { - launcher + let managed = self + .temp + .path() + .join("site with spaces/.rusthead/bin/rusthead"); + let executable = if managed.exists() { + managed } else { env!("CARGO_BIN_EXE_rusthead").into() }; @@ -80,7 +83,6 @@ if [ ! -f "$key" ]; then printf 'private key\n' > "$key"; fi std::env::var("PATH").unwrap() ), ) - .env("BRIDGEHEAD_BIN_DIR", self.temp.path().join("bin")) .env("INSTALL_TEST_ROOT", self.temp.path()) .env("INSTALL_TEST_UPDATE_STATUS", update_status.to_string()) .env( @@ -134,7 +136,6 @@ fn repeated_install_preserves_repository_and_private_key() { assert!(log.contains(root.join("site with spaces/custom.toml").to_str().unwrap())); let managed = root.join("site with spaces/.rusthead/bin/rusthead"); assert!(managed.is_file()); - assert_eq!(fs::read_link(root.join("bin/rusthead")).unwrap(), managed); assert!(log.contains(managed.to_str().unwrap())); assert_ne!( fs::metadata(managed).unwrap().permissions().mode() & 0o111, @@ -167,7 +168,6 @@ fn failed_update_stops_installation_before_enrollment() { assert_eq!(unsafe { libc::geteuid() }, 0); let installation = Installation::new(); let result = installation.run(7); - assert!(!installation.temp.path().join("bin/rusthead").exists()); assert_eq!( result.status.code(), Some(7), From 04a474f92c5efc3863e7aee87181c522bc3f4b01 Mon Sep 17 00:00:00 2001 From: Threated Date: Mon, 28 Sep 2026 12:57:50 +0200 Subject: [PATCH 8/8] update readme --- Readme.md | 180 +++++++++++++++++++++++++-------------- docs/updates.md | 219 ------------------------------------------------ 2 files changed, 117 insertions(+), 282 deletions(-) delete mode 100644 docs/updates.md diff --git a/Readme.md b/Readme.md index 0e22f2a..372f23e 100644 --- a/Readme.md +++ b/Readme.md @@ -4,99 +4,153 @@ A tool for generating docker compose files for the `Samply.Bridgehead` based on ## Installation -The native Rust CLI can install an existing configuration directly: +### Prerequisites -```bash -sudo /path/to/rusthead --config /path/to/config.toml install -``` +- An x86_64 Linux host with Bash and Git installed. +- Docker with the Docker Compose plugin, running and accessible to your user. +- Sudo access to create the service account and configure systemd, if available. -This requires Docker (including its `docker` group), Git, and sudo. It creates or reuses -the `bridgehead` account, configures a shared Git repository, and enables the systemd -service and daily update timer when Docker runs under systemd. Otherwise it runs an -update immediately. Re-running `install` preserves existing Beam private keys and -does not duplicate Git trust entries. Systemd uses the current Rust executable's -absolute path, so keep the binary at that location. To enroll pending networks, run -the same command with `enroll` instead of `install`. - -`config.local.toml` persists `beam_networks` required by the current configuration -and `enrolled_beam_networks` for which the enrollment command has succeeded. Both -`install` and `enroll` process only pending networks, saving each success separately -so interrupted enrollment can be retried. CSR approval remains a separate step. -Disabled networks retain their enrollment history. After changing `site_id`, clear -`enrolled_beam_networks` manually. Removing the private key resets that history. -Existing installations without enrollment -history treat all configured networks as pending, even when a private key exists. -To re-enroll a network explicitly, remove it from `enrolled_beam_networks` first. - -The rustehead can either be installed by following the installation wizard run with: +### Setup + +The rustehead can installed by running the installation wizard: ```bash bash <(docker run --rm samply/rusthead bootstrap) ``` -Or by manually creating a minimal config file like this: +This will install the rusthead binary. This can then be used to do the final installtion by running the intall command in the config directory. -```toml -site_id = "test" -# The hostname under which the bridgehead will be accessible. Can also be an IP address. -hostname = "test-bridgehead.local" +```bash +sudo rusthead install ``` -and running: +After that you have an empty rusthead installation that does not start any services yet. + +## Adding services + +Run the commands below from your installation directory. Your account needs write +access to that directory and permission to use Docker. To run an update as the +service account, use `sudo -u bridgehead rusthead update commit`. + +Edit `config.toml` to enable the services you need. See the +[example configuration](tests/configs/example.config.toml) for available options. +Then apply your changes: ```bash -docker run --rm -v $(pwd):$(pwd) samply/rusthead bootstrap bridgehead --config $(pwd) -# Followed by -sudo ./bridgehead install +rusthead update commit ``` -After that you have an empty rusthead installation that does not start any services yet. -Before adding any services, let's go over the generated files. -If you want to skip learning about the generated files, you can skip to [Adding Services](#adding-services). +This generates the service configuration, pulls the required container images, +and commits your configuration changes together with the generated files. You do +not need to make a Git commit first. +If the services require a new Beam network, enroll the installation and follow +the instructions printed by the command: + +```bash +sudo rusthead enroll +rusthead update commit ``` --rwxr-xr-x 1 bridgehead docker 4106 May 11 07:35 bridgehead --rw-rw-r-- 1 bridgehead docker 18 May 11 07:35 config.local.toml --rw-rwSr-- 1 bridgehead docker 35 May 11 07:16 config.toml --rw-rw-r-- 1 bridgehead docker 87 May 11 07:35 .env -drwxrwsr-x 8 bridgehead docker 4096 May 11 07:41 .git --rw-rw-r-- 1 bridgehead docker 87 May 11 07:35 .gitignore -drwxrwsr-x 2 bridgehead docker 4096 May 11 07:35 services + +Once enrollment is approved, start the services: + +```bash +sudo systemctl start bridgehead ``` -### Why is there a git repository? +For an already running installation, use `sudo systemctl restart bridgehead` +after an update requests a restart. + +## Configuration files -All changes to compose files and `config.toml` are committed to the git repository. This allows you to easily track and revert changes. And makes reviewing changes of updates easier (just inspect them with `git diff`). You can also enable the `git_sync` feature in the config to automatically push and pull changes from a git remote repository. +| File | What it is for | +| --- | --- | +| `config.toml` | Your site settings and enabled services. Edit this to configure the installation. | +| `config.local.toml` | Local credentials and generated secrets. Kept out of Git; preserve it when backing up the installation. | +| `docker-compose.override.yml` | Optional Docker Compose customizations. Kept out of Git. | +| `services/`, `.env`, `docker-image.lock.yml` | Generated service configuration and image versions. Change the configuration or Compose override instead of editing these files. | -### Private files +For any custom changes to the Compose configuration, create a top-level +`docker-compose.override.yml` next to `config.toml`. Rusthead includes this file +when running Compose. Other Compose files are regenerated during updates, so +direct edits may be rejected or overwritten. -The `.env` and `config.local.toml` files are private and not committed to the git repository. The `.env` is auto generated based on the `config.local.toml` file that means edits to the `.env` file will be overwritten on updates. -The `config.local.toml` contains the credentials for the local basic auth users, oidc configuration and a seed to generate other api keys. -The file is mostly autogenerated by the `bridgehead update` command but will try to preserve sensible modifications you have made. For example it is recommended to remove the plaintext password found under `basic_auth_users..pw` and save it to a password manager. +`config.local.toml` can contain initial plaintext passwords under +`basic_auth_users..pw`. Save these in a password manager; you can then +remove the plaintext values while keeping the password hashes. -### The `bridgehead` script +Service data uses Docker named volumes by default. Set `volume_dir` in +`config.toml` to store it in a directory instead, such as `./data`. Keep backups of +service data as well as configuration and credentials; the Git history is not a +complete backup. -#### `bridgehead compose` +## Updating -The `bridgehead` script is the main entrypoint that you will use to manage your installation. -It's most interesting command is `bridgehead compose` which is just a wrapper around `docker compose` so it can be used with all the standard `docker compose` subcommands (e.g. `up`, `down`, `logs -f`). +There are two update commands: -#### `bridgehead update` +| Command | When to use it | +| --- | --- | +| `rusthead update commit` | After editing configuration or Compose overrides. Applies and records your changes. | +| `rusthead update sync` | For routine updates when there are no pending local edits. | -This command will update the generated compose files to the latest versions. It will also make a commit to the git repository so you can easily revert to the previous version if needed. +Both commands update rusthead itself when a different binary is available in the +configured image, regenerate service configuration, and pull service images. +Updates leave your edits in place: `update sync` asks you to run `update commit` +when local changes need to be accepted. Manual edits to generated files are +rejected; move those customizations into your configuration or Compose override. -## Adding Services +When an image pull fails, fix the reported problem and retry with +`rusthead update commit`. -To add services you need to edit the `config.toml` file with the desired service configuration. For more information on the available options, see the [example config](tests/configs/example.config.toml). -After making changes to the `config.toml` you need to commit them to the git repository and run the update command. +On installations using systemd with Docker, `install` enables daily updates at +06:00 using `bridgehead-update.timer`. Services restart automatically when the +scheduled update requires it. Check the schedule and update logs with: ```bash -git commit -am "Added service xyz" -./bridgehead update +systemctl list-timers bridgehead-update.timer +journalctl -u bridgehead-update.service ``` -> [!NOTE] -> When adding services that require a new beam network you need to also run `sudo ./bridgehead enroll` and follow the provided instructions. +To choose a different rusthead version or image for future updates, set `image` +at the top level of `config.toml`: + +```toml +image = "samply/rusthead:latest" +``` + +For a new installation, you can also select the image during bootstrap: + +```bash +IMAGE=samply/rusthead:my-tag bash <(docker run --rm samply/rusthead bootstrap) +``` + +The selected image is written into the new configuration. Bootstrap preserves an +existing configuration and can reinstall the rusthead command if needed. +Rusthead runs directly on the host; the services still run in Docker. + +## Managing services + +`rusthead compose` accepts Docker Compose commands and selects the installation's +configuration files for you: + +```bash +rusthead compose ps +rusthead compose logs -f +``` + +If you manage services without systemd, start or recreate them with +`rusthead compose up -d`, and stop them with `rusthead compose down`. + +## Reviewing changes and using a Git remote + +The installation keeps a local Git history of `config.toml` and generated service +files. Use `git log` to review updates and `git show` to inspect the latest commit. +`update commit` includes all nonignored changes in the installation directory, so +keep unrelated files elsewhere. -To see what changes were made by the update command by running `git diff HEAD~1`. -After that you can restart the bridgehead by either restarting the systemd unit (`sudo systemctl restart bridgehead`) or by running `./bridgehead compose down` followed by `./bridgehead compose up`. +Remote synchronization is optional and disabled by default. To enable it, set +`git_sync = true` at the top level of `config.toml` and configure a Git remote and +an upstream branch. With it enabled, `update sync` pulls upstream changes before +updating, and both update commands push their commits. Conflicting branch +histories must be resolved manually; rusthead does not stash your edits or merge +conflicts for you. diff --git a/docs/updates.md b/docs/updates.md deleted file mode 100644 index 5e554a5..0000000 --- a/docs/updates.md +++ /dev/null @@ -1,219 +0,0 @@ -# Native installation updates - -For a new x86_64 Linux installation with Docker available, run: - -```sh -bash <(docker run --rm samply/rusthead bootstrap) -``` - -The script prompts for a site directory, site ID, hostname, optional HTTPS proxy, -and binary directory (default `/usr/local/bin`). It extracts the executable from -the scratch image and creates `config.toml`, then prints the sudo install command -to run after enabling the desired modules. Existing configuration files are never -overwritten; bootstrap can install the binary and link for an existing config too. -Bootstrap stores the binary in `.rusthead/bin/rusthead` and creates a symlink -in the selected binary directory. - -To select the binary image and the image tracked by future self-updates, set -`IMAGE` for the host Bash process: - -```sh -IMAGE=samply/rusthead:my-tag bash <(docker run --rm samply/rusthead bootstrap) -``` - -This value is written to `config.toml`; it does not need to be passed into the -container that prints the script. - -Run commands against the installation directory or its selected configuration file: - -```sh -rusthead --config /srv/bridgehead/config.toml update commit -rusthead --config /srv/bridgehead/config.toml update sync -``` - -`update commit` accepts your edited inputs, regenerates service definitions and -image pins, pulls the pinned images, and commits all nonignored installation -changes together. This includes staged, unstaged, and unrelated nonignored files; -use a dedicated installation repository. Customize configuration or -`docker-compose.override.yml`, rather than generated service files or `.env`. -Manual generated-file edits are rejected and preserved. - -`update sync` requires a clean repository and unchanged local inputs. It is the -command used by the daily timer. Pending edits to ignored configuration, -public trust certificates, or overrides require an explicit `update commit` too. The -first update initializes a shared local Git repository and accepts the existing -inputs. An existing repository must be rooted at the installation directory; -worktrees are supported. A configured `volume_dir` inside the installation is -excluded from Git and input fingerprints. Use a dedicated subdirectory (such as -`./data`) or an external directory, not the installation root or an ancestor. -Previously tracked runtime data must be untracked before updating. - -## Remote synchronization - -Set `git_sync = true` at the top level of the configuration to enable network Git -operations. The default is `false`, even when a remote exists. Configure the -current branch's upstream with Git before enabling synchronization. - -With synchronization enabled, `update sync` fetches the upstream branch and -fast-forwards before generation. A locally ahead branch can proceed. Both update -commands push successful local commits to the upstream; `update commit` never -pulls. The setting loaded at command start controls that invocation, even if a -pull changes it. - -Divergent histories require an administrator to reconcile them using Git. -Rusthead never stashes, rebases, merges divergent branches, resets, or force-pushes. -A failed push preserves the local update and commit; retry with `update sync` -when the remote is reachable and the histories permit synchronization. - -## Local state and recovery - -Ignored `.rusthead/` metadata stores update fingerprints, an update lock, and a -separate `enrollment.json` containing completed enrollment records. It contains -no copies of secret contents and is accessible only to the installation owner -and group. `state.json` stores only ignored `local_inputs` and generated `outputs`, -with paths relative to the site. Git-tracked inputs are not fingerprinted. -Runtime changes are derived from outputs and local inputs other than -`config.local.toml`; there is no separate stored runtime map. -Runtime change detection includes generated files, pinned image -versions, environment values, overrides, and public trust certificates; a config-only commit -need not request a restart. No-op or ignored-only changes create no empty commits. - -`enrollment.json` is durable operational state: resetting `state.json` must not -remove it. Required Beam networks are derived from the configured services and -are not persisted. The seed, credentials, and user configuration remain in -`config.local.toml`. - -Enrollment saves each completed network independently. Receipt changes alone do -not dirty update inputs. `pki/` and `traefik-tls/` are excluded from fingerprints. -Custom TLS certificate/key paths are not added to the fingerprint inputs; keep -those files outside the repository or ignored by Git. -Their contents need not be readable by the user running updates, and rotation -does not block `update sync` or trigger a restart. Handle any required reload or -restart separately. TLS paths in configuration remain tracked inputs. A missing -site private key invalidates its enrollment records, retaining the existing -reenrollment behavior. - -Generation renders all service templates before replacing service files, but the -whole update is not a filesystem transaction: certificate creation, output -writes, or a fast-forward can remain after a later failure. No successful update -commit is made when generation or image pulling fails. After an image failure, -`update commit` can retry outputs recorded by the failed run, provided nobody has -edited those outputs. Earlier write failures require inspecting partial outputs. - -A migrated installation without a fingerprint baseline establishes one on its -first successful update and conservatively requests a restart. Untracked legacy -`.env` edits cannot be identified before this baseline exists. - -Compose commands warn about uncommitted Git changes, local input changes, or -changed outputs, but do not detect committed configuration changes awaiting -generation. They remain usable, including diagnostics and shutdown with invalid -source configuration. Updates do not check for edits made while they run; the -update lock still prevents simultaneous rusthead updates. Generation, image pulling, and -Compose commands all use `docker compose`; pulling and launching include the -image lockfile after the override file. - -## Automation status - -| Exit status | Meaning | -| --- | --- | -| 0 | Successful update; runtime artifacts unchanged | -| 3 | Successful update; restart needed | -| 4 | Local update succeeded and needs restart, but push failed | -| 1 | Other update failure, or push failure without runtime changes | - -The generated systemd update service accepts status 3 as success and requests a -restart for 3 or 4. Status 4 remains a service failure so the push problem stays -visible. Standalone enrollment key or certificate changes are accepted with -`update commit`; installation accepts these changes before enabling the timer. - -## Updating the executable - -Both update modes check the distribution image selected by the top-level `image` -configuration (default `samply/rusthead:latest`). Git preflight runs first; `sync` -also pulls configuration first, so a remote configuration can select a different -image tag or digest. Compose and enrollment commands do not self-update. - -The updater pulls `linux/amd64`, inspects the immutable image ID, creates a stopped -container, and copies `/usr/local/bin/rusthead` out. It never starts the distribution -container. `.rusthead/binary.json` caches the image ID and executable SHA-256; -unchanged image IDs and executable content avoid extraction, and identical binary -content avoids replacement even when the image ID changes. - -A changed executable must be an x86_64 ELF file and successfully report `--version` -before [self-replace](https://docs.rs/self-replace/latest/self_replace/) replaces -the running executable. The new version continues the requested update with the -same absolute configuration path and update mode; the original process returns -its exit status. An internal handoff prevents a second binary-update check during -that continuation. A binary replacement alone does not restart services: generated -runtime changes still determine the restart status. - -Bootstrap creates the executable at `.rusthead/bin/rusthead` and a symlink in the -selected binary directory (default `/usr/local/bin`). `install` assigns ownership -to the `bridgehead` service account and points systemd at the managed binary. When -invoked from another executable, such as a development build, it copies that -executable into the managed location. It does not create or change PATH symlinks. -Manual commands through the symlink and systemd use the same binary; self-updates -replace the managed target and preserve the symlink. - -Ensure the selected directory precedes other rusthead installations on `PATH`. -Bootstrapping another site into the same launcher directory points the command at -that site's managed binary. The launcher directory stays protected; only the managed -directory needs to be writable by `bridgehead`. Explicitly invoking some other -binary by its path still updates that copy instead. Pull, extraction, and -executable validation failures abort generation and leave the existing executable -in place. - -The distribution image is `FROM scratch` and contains only the static executable. -The host needs Git and Docker with Compose. Secret synchronization runs -`docker.verbis.dkfz.de/cache/samply/secret-sync-local:latest` with an isolated cache -and read-only key/certificate mounts; host `proxy` and `local` programs are no -longer needed. - -## Building and testing - -CI builds and tests `x86_64-unknown-linux-musl`, then builds the size-optimized -release binary. It packages the tested release artifact using Samply's reusable -`docker-ci.yml` workflow, retaining its image-tag publishing conventions. - -With Rust's musl target, `musl-gcc`, Docker Compose, and cargo-nextest installed: - -```sh -cargo nextest run --locked --target x86_64-unknown-linux-musl --no-fail-fast -unshare --user --map-root-user cargo nextest run --locked --target x86_64-unknown-linux-musl --no-fail-fast --test install --run-ignored only -cargo build --locked --release --target x86_64-unknown-linux-musl -mkdir -p artifacts -cp target/x86_64-unknown-linux-musl/release/rusthead artifacts/rusthead -docker build --platform linux/amd64 -t rusthead-local . -docker run --rm rusthead-local --version -``` - -CI runs root-only installer tests with `sudo`; the local command above uses an -isolated user namespace. Self-update tests replace private executable copies and fake -Docker transport; they never replace the developer's compiled binary. CI also -smoke-tests the real scratch image and compares its extracted binary with the -release artifact. - - -## Local development with just - -`just bootstrap` builds the local image and runs the interactive bootstrap script -if `bridgehead/config.toml` is missing. It prompts for site settings and a binary -directory, extracting from the local image without pulling from a registry. -Edit the config to enable the modules you want; subsequent commands preserve it. -Set `BRIDGEHEAD_CONFIG_PATH` to use another directory or a `.toml` file. - -- `just build` builds the debug musl executable and the local scratch image - (`IMAGE`, default `samply/rusthead:localbuild`). -- `just run` builds and installs the local executable with sudo, including - enrollment and systemd setup. -- `just up` runs installation, then stops and starts Compose sequentially. -- `just down` stops Compose without rebuilding. -- `just bridgehead update commit` generates and accepts local configuration edits. - Other CLI arguments can be passed through `just bridgehead` too. - -Development recipes use `--no-self-update` so locally built binaries are not -replaced by a registry image. The installer forwards this option to its update -processes and generated systemd commands. Service image pulls and Git behavior -are unchanged. Install again without this option to enable scheduled binary -updates. The recipes run `static/bootstrap.sh`, the same script emitted by the -native `bootstrap` subcommand.