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:
2026-06-24 12:38:21 +03:00
parent d62ba80755
commit b7a76d78f6
5 changed files with 131 additions and 1 deletions
Generated
+16
View File
@@ -11,6 +11,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -764,6 +770,16 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "shturman-firstboot"
version = "0.0.0"
dependencies = [
"anyhow",
"shturman-common",
"tempfile",
"tracing",
]
[[package]]
name = "shturman-ipc"
version = "0.0.0"
+6 -1
View File
@@ -2,7 +2,12 @@
resolver = "2"
# Члены растут по планам реализации. crates/core, crates/apps, crates/tools —
# группировка привилегированного ядра / first-party-апов / dev-инструментов (architecture §3).
members = ["crates/shturman-common", "crates/shturman-ipc", "crates/shturman-sdk"]
members = [
"crates/shturman-common",
"crates/shturman-ipc",
"crates/shturman-sdk",
"crates/core/shturman-firstboot",
]
[workspace.package]
edition = "2021"
+13
View File
@@ -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
+81
View File
@@ -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(())
}