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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ init = []
serde = { version = "1", features = ["derive"] }
serde_json = "1"
clap = { version = "4", features = ["derive"] }
# Git HTTPS from rust-commons (`main`; bump with `make deps-update`).
bigfred-shared-daemon = { git = "https://github.com/dcc-bigfred/rust-commons.git", branch = "main" }
log = "0.4"
nix = { version = "0.29", features = [
"process",
"signal",
Expand All @@ -46,7 +49,6 @@ nix = { version = "0.29", features = [
thiserror = "2"
libc = "0.2"
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
notify = "8.2.0"
ureq = { version = "2.12", default-features = false, features = ["json", "tls"] }

[profile.release]
Expand Down
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export RUSTUP_TOOLCHAIN
PREFIX ?= /usr
MANDIR ?= $(PREFIX)/share/man

.PHONY: all build release release-musl release-android check test test-release-assertions clean man install-man fmt clippy
.PHONY: all build release release-musl release-android check test test-release-assertions \
clean man install-man fmt clippy deps-update

all: build

Expand Down Expand Up @@ -53,6 +54,10 @@ fmt:
clippy:
$(CARGO) clippy --all-targets -- -D warnings

# Refresh git crates (bigfred-shared-daemon) and rewrite Cargo.lock. Commit the lockfile afterwards.
deps-update:
$(CARGO) update -p bigfred-shared-daemon

clean:
$(CARGO) clean
rm -f man/man5/*.gz man/man8/*.gz
Expand Down
194 changes: 34 additions & 160 deletions src/config_watch.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
//! Linux inotify-based configuration watcher (no polling).
//!
//! Watches `$DATA_DIR/etc/` for `microinit.json`, the enabled-override file, and
//! recursively watches `microinit.d/` when present. Debounces bursts of events
//! (atomic write+rename) before signaling a reload.

use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::atomic::AtomicBool;
use std::sync::mpsc::Receiver;
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use std::time::Duration;

use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use bigfred_shared_daemon::config::{spawn_signal, PathFilter, WatchSpec};

use crate::error::{Error, Result};
use crate::logs::LogHub;
Expand All @@ -21,30 +16,23 @@ use crate::protocol::LogLevel;
const DEBOUNCE: Duration = Duration::from_millis(300);

/// Signal that configuration files may have changed.
pub struct ReloadSignal;

/// Filter path events relevant to microinit JSON config.
pub fn is_relevant_path(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
return false;
};
if name.starts_with('.') {
return false;
}
if name.ends_with('~') || name.ends_with(".swp") || name.ends_with(".tmp") {
return false;
pub type ReloadSignal = bigfred_shared_daemon::config::Reload;

fn microinit_filter() -> PathFilter {
PathFilter::Any {
extensions: vec!["json".into()],
extra_names: vec![
"microinit.json".into(),
"microinit.services.enabled-override.json".into(),
"microinit.d".into(),
],
ignore_suffixes: Vec::new(),
}
if name == "microinit.json"
|| name == "microinit.services.enabled-override.json"
|| name == "microinit.d"
{
return true;
}
path.extension().and_then(|s| s.to_str()) == Some("json")
}

fn event_paths(event: &Event) -> impl Iterator<Item = &PathBuf> {
event.paths.iter()
/// Filter path events relevant to microinit JSON config.
pub fn is_relevant_path(path: &Path) -> bool {
bigfred_shared_daemon::config::is_relevant_path(path, &microinit_filter())
}

/// Spawn an inotify watcher thread. Returns a receiver of debounce-coalesced reload signals.
Expand All @@ -53,147 +41,33 @@ pub fn spawn(
dropins_dir: PathBuf,
hub: Arc<LogHub>,
) -> Result<(Receiver<ReloadSignal>, Arc<AtomicBool>)> {
let (tx, rx) = mpsc::channel();
let stop = Arc::new(AtomicBool::new(false));
let stop_thr = Arc::clone(&stop);

thread::Builder::new()
.name("config-watch".into())
.spawn(move || {
if let Err(e) = watch_loop(etc_dir, dropins_dir, hub, tx, stop_thr) {
eprintln!("microinit: config watcher stopped: {e}");
}
})
.map_err(|e| Error::Other(e.to_string()))?;

Ok((rx, stop))
}

fn watch_loop(
etc_dir: PathBuf,
dropins_dir: PathBuf,
hub: Arc<LogHub>,
reload_tx: Sender<ReloadSignal>,
stop: Arc<AtomicBool>,
) -> Result<()> {
let (raw_tx, raw_rx) = mpsc::channel();

let mut watcher = RecommendedWatcher::new(
move |res: std::result::Result<Event, notify::Error>| {
let _ = raw_tx.send(res);
let specs = vec![
WatchSpec {
path: etc_dir.clone(),
recursive: false,
filter: microinit_filter(),
},
notify::Config::default(),
)
.map_err(|e| Error::Other(format!("inotify watcher: {e}")))?;

// Always watch etc/ so late creation of microinit.d / config files is seen.
if etc_dir.is_dir() {
watcher
.watch(&etc_dir, RecursiveMode::NonRecursive)
.map_err(|e| Error::Other(format!("watch {}: {e}", etc_dir.display())))?;
} else if let Some(parent) = etc_dir.parent() {
let _ = std::fs::create_dir_all(&etc_dir);
if etc_dir.is_dir() {
let _ = watcher.watch(&etc_dir, RecursiveMode::NonRecursive);
} else if parent.is_dir() {
let _ = watcher.watch(parent, RecursiveMode::NonRecursive);
}
}

let mut watching_dropins = false;
if dropins_dir.is_dir() {
if let Err(e) = watcher.watch(&dropins_dir, RecursiveMode::Recursive) {
hub.emit(
INIT_SERVICE,
LogLevel::Warn,
format!(
"config watch: cannot watch drop-ins {}: {e}",
dropins_dir.display()
),
);
} else {
watching_dropins = true;
}
}

// Always listed: `bigfred-shared-daemon` late-attaches when `microinit.d/services`
// appears after first boot.
WatchSpec {
path: dropins_dir.clone(),
recursive: true,
filter: microinit_filter(),
},
];
let pair = spawn_signal(specs, DEBOUNCE).map_err(|e| Error::Other(e.to_string()))?;
hub.emit(
INIT_SERVICE,
LogLevel::Info,
format!(
"config watch active on {} (drop-ins {})",
etc_dir.display(),
if watching_dropins {
if dropins_dir.is_dir() {
"recursive"
} else {
"pending"
}
),
);

let mut pending: Option<Instant> = None;

loop {
if stop.load(Ordering::SeqCst) {
break;
}

let timeout = pending
.map(|t| {
let elapsed = t.elapsed();
if elapsed >= DEBOUNCE {
Duration::from_millis(0)
} else {
DEBOUNCE - elapsed
}
})
.unwrap_or(Duration::from_secs(1));

match raw_rx.recv_timeout(timeout) {
Ok(Ok(event)) => {
let relevant = matches!(
event.kind,
EventKind::Create(_)
| EventKind::Modify(_)
| EventKind::Remove(_)
| EventKind::Any
) && event_paths(&event).any(|p| is_relevant_path(p));

if relevant {
// Late-create of microinit.d / dropins tree
if !watching_dropins
&& dropins_dir.is_dir()
&& watcher
.watch(&dropins_dir, RecursiveMode::Recursive)
.is_ok()
{
watching_dropins = true;
hub.emit(
INIT_SERVICE,
LogLevel::Info,
format!(
"config watch: now watching drop-ins {}",
dropins_dir.display()
),
);
}
pending = Some(Instant::now());
}
}
Ok(Err(e)) => {
hub.emit(
INIT_SERVICE,
LogLevel::Warn,
format!("config watch error: {e}"),
);
}
Err(mpsc::RecvTimeoutError::Timeout) => {
if pending.is_some_and(|t| t.elapsed() >= DEBOUNCE) {
pending = None;
let _ = reload_tx.send(ReloadSignal);
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
Ok(())
Ok(pair)
}
Loading
Loading