feat(firstboot): идемпотентный provision /data + machine-id (A06)
lib provision (idempotent; factory-reset; recover mid-run) + bin; machine-id из /dev/urandom. Привязка к /etc/machine-id — every-boot юнит (План 5). Дефолты настроек — Settings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Alexander <akotenev2003@gmail.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "shturman-firstboot"
|
||||
version = "0.0.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
shturman-common = { path = "../../shturman-common" }
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,81 @@
|
||||
//! First-boot provisioning (A06): идемпотентная подготовка `/data` + генерация `machine-id`.
|
||||
//!
|
||||
//! Привязка `machine-id` → `/etc/machine-id` — **отдельный every-boot юнит** (План 5), не здесь
|
||||
//! (bind волатилен, не переживает ребут). Дефолты настроек сеет сам `Settings` (single source формата).
|
||||
|
||||
use shturman_common::{write_atomic, Layout};
|
||||
use std::io::{self, Read};
|
||||
|
||||
/// Идемпотентно подготовить `/data`. `Ok(true)` — провизионинг выполнен; `Ok(false)` — уже было (no-op).
|
||||
/// Маркер пишется durable-write **последним**: частичный сбой mid-run → следующий прогон довосстановит.
|
||||
pub fn provision(layout: &Layout) -> io::Result<bool> {
|
||||
if layout.provisioned_marker().exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
for dir in [
|
||||
layout.apps(),
|
||||
layout.settings(),
|
||||
layout.state(),
|
||||
layout.log(),
|
||||
] {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
}
|
||||
let mid = layout.state().join("machine-id");
|
||||
if !mid.exists() {
|
||||
write_atomic(&mid, generate_machine_id()?.as_bytes())?;
|
||||
}
|
||||
write_atomic(&layout.provisioned_marker(), b"1\n")?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 32 hex-символа из `/dev/urandom` (формат systemd `machine-id`).
|
||||
fn generate_machine_id() -> io::Result<String> {
|
||||
let mut buf = [0u8; 16];
|
||||
std::fs::File::open("/dev/urandom")?.read_exact(&mut buf)?;
|
||||
Ok(buf.iter().map(|b| format!("{b:02x}")).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shturman_common::Layout;
|
||||
|
||||
#[test]
|
||||
fn provisions_then_idempotent() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let l = Layout::new(d.path());
|
||||
assert!(provision(&l).unwrap()); // первый запуск — провизионинг
|
||||
assert!(l.settings().is_dir());
|
||||
assert!(l.state().join("machine-id").exists());
|
||||
assert!(l.provisioned_marker().exists());
|
||||
let mid1 = std::fs::read(l.state().join("machine-id")).unwrap();
|
||||
assert!(!provision(&l).unwrap()); // второй — no-op
|
||||
let mid2 = std::fs::read(l.state().join("machine-id")).unwrap();
|
||||
assert_eq!(mid1, mid2); // machine-id стабилен между прогонами
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_reset_regenerates() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let l = Layout::new(d.path());
|
||||
provision(&l).unwrap();
|
||||
let mid1 = std::fs::read(l.state().join("machine-id")).unwrap();
|
||||
// factory-reset: очистка /data
|
||||
std::fs::remove_dir_all(d.path()).unwrap();
|
||||
std::fs::create_dir_all(d.path()).unwrap();
|
||||
assert!(provision(&l).unwrap());
|
||||
let mid2 = std::fs::read(l.state().join("machine-id")).unwrap();
|
||||
assert_ne!(mid1, mid2); // новый machine-id после wipe
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_when_marker_missing() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let l = Layout::new(d.path());
|
||||
// имитация прерывания mid-run: каталоги есть, маркера нет
|
||||
std::fs::create_dir_all(l.settings()).unwrap();
|
||||
assert!(provision(&l).unwrap()); // довосстанавливает
|
||||
assert!(l.provisioned_marker().exists());
|
||||
assert!(l.state().join("machine-id").exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! First-boot provisioning (A06) — oneshot. Гейт `ConditionPathExists=!/data/.shturman-provisioned`
|
||||
//! и порядок (After data.mount, до сервисов) задаёт systemd (План 5).
|
||||
|
||||
use shturman_common::{init_tracing, Layout};
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
init_tracing("shturman-firstboot");
|
||||
let layout = Layout::from_env();
|
||||
if shturman_firstboot::provision(&layout)? {
|
||||
tracing::info!(root = %layout.root().display(), "first-boot: provisioning выполнен");
|
||||
} else {
|
||||
tracing::info!("first-boot: уже провижено — no-op");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user