From 9b87751ab8d253ec7411a6b5316e6d07cf868cc8 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 24 Jun 2026 17:14:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(v0.6):=20Lima=20E2E=20=D0=B7=D0=B5=D0=BB?= =?UTF-8?q?=D1=91=D0=BD=D1=8B=D0=B9=20=D1=81=20=D0=BD=D1=83=D0=BB=D1=8F=20?= =?UTF-8?q?+=20shell=20software-render=20screenshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit План 5 ч.2: поднял Lima-VM и довёл сквозной E2E до зелёного из чистого yaml (just vm-reset && just e2e — exit 0). Приёмка §9.4 (v0.1 + v0.6 + шагающий скелет). Shell (lib+bin split): - режим --screenshot : headless software-render первого кадра в PNG (Slint software-renderer, без дисплея/композитора, §6); TDD-тест «кадр не пустой + тема отражена», зелёный и на dev-Mac, и в VM (Linux). - shturman-shell.service → oneshot software-render → /run/shturman/frame.png (RemainAfterExit → is-active детерминированно, без хрупкого weston; живой weston-shell — v0.5). just shell-frame — инспекция кадра. E2E (tests/e2e/run.sh, двухфазно pre→reboot→post): - /data+power-safe опции, volatile-tmpfs, first-boot идемпотентность, per-unit active, имена на шине + GetPowerState, fake-ACC SetAcc→AccChanged, первый кадр PNG, base-бюджеты (journald volatile / zram / oomd / fake-hwclock→/data / eMMC-прокси), персист Settings + machine-id every-boot bind после reboot. Провижининг (lima/shturman.yaml) — правки по реальным ошибкам Lima: - build-deps Slint/winit на Linux (libfontconfig1-dev/libxkbcommon-dev/libwayland-dev); - linux-modules-extra (zram/vcan не в vz-ядре); systemd-oomd; rm стокового /etc/fake-hwclock.data (A11); VM-локальный CARGO_TARGET_DIR. Док-синхронизация (спека §13/§8.1/§7.5 + CLAUDE.md): швы реализации, eMMC-порог T=4096 сект, fake-hwclock masked-в-Lima, dev-mock policy не нужен. Перф-вердикт — на RK3588 (в VM — функционально, performance §2). just ci зелёный. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Alexander --- CLAUDE.md | 10 +- Cargo.lock | 2 + crates/apps/shturman-shell/Cargo.toml | 5 + crates/apps/shturman-shell/src/lib.rs | 230 ++++++++++++++++++ crates/apps/shturman-shell/src/main.rs | 170 ++----------- .../apps/shturman-shell/tests/screenshot.rs | 71 ++++++ docs/specs/v0.1-v0.6-foundation.md | 43 +++- justfile | 38 ++- lima/shturman.yaml | 15 +- systemd/shturman-shell.service | 16 +- tests/e2e/run.sh | 218 ++++++++++++++--- 11 files changed, 609 insertions(+), 209 deletions(-) create mode 100644 crates/apps/shturman-shell/src/lib.rs create mode 100644 crates/apps/shturman-shell/tests/screenshot.rs diff --git a/CLAUDE.md b/CLAUDE.md index c5f28b2..98a7221 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,9 +46,13 @@ vcan + Vehicle Simulator + моки (аудио/BT/камера/GPS/сеть/plu воркспейс + `shturman-common`/`ipc`/`sdk` + стаб-сервисы `firstboot`/`settings`/`power` (интеграция на D-Bus) + первый Slint-кадр (`shell`) + dev-tools (валидатор/scaffolding) + systemd/Lima/E2E-файлы. `just ci` зелёный. -**Следующее — A (План 5 ч.2):** поднять Lima-VM (`just vm-up`) и довести сквозной E2E (`just e2e`): -boot → стаб-сервисы (`Power`/`Settings` на D-Bus) → первый Slint-кадр (screenshot). Приёмка — спека §9.4. -Остаток фаз v0: `v0.2` boot-конвейер · `v0.3` power-safe · `v0.4` MCU/thermal · `v0.5` полный shell. +**План 5 ч.2 — ГОТОВО (ветка `feat/v0.6-lima-e2e`):** Lima-VM поднимается (`just vm-up`), сквозной `just e2e` +зелёный с нуля (`just vm-reset && just e2e`): boot → `data.mount` → firstboot → machine-id bind → `Power`/`Settings` +на системной шине → fake-ACC `AccChanged` → **первый Slint-кадр** (software-render → PNG, oneshot-сервис) → +base-бюджеты (journald volatile / zram / oomd / fake-hwclock→/data / eMMC-прокси) → **reboot**: персист Settings + +machine-id стабилен. Приёмка §9.4 (v0.1 + v0.6 + шагающий скелет) выполнена. Швы реализации — спека §13. + +**Следующее:** `v0.2` boot-конвейер · `v0.3` power-safe · `v0.4` MCU/thermal · `v0.5` полный shell (живой weston-shell). > CI: GitHub-Actions-конфиг **удалён** (его ловит Gitea). Гейт — локальный `just ci`. CI на Gitea — решение позже. diff --git a/Cargo.lock b/Cargo.lock index b086935..ec9405e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3622,9 +3622,11 @@ name = "shturman-shell" version = "0.0.0" dependencies = [ "anyhow", + "png 0.17.16", "shturman-common", "shturman-sdk", "slint", + "tempfile", "tokio", "tracing", ] diff --git a/crates/apps/shturman-shell/Cargo.toml b/crates/apps/shturman-shell/Cargo.toml index dbb8f03..a258a13 100644 --- a/crates/apps/shturman-shell/Cargo.toml +++ b/crates/apps/shturman-shell/Cargo.toml @@ -11,3 +11,8 @@ tokio.workspace = true anyhow.workspace = true tracing.workspace = true slint.workspace = true +# PNG-кодек для headless software-render кадра (E2E-ассерт «кадр не пустой», спека §6). +png = "0.17" + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/apps/shturman-shell/src/lib.rs b/crates/apps/shturman-shell/src/lib.rs new file mode 100644 index 0000000..793312c --- /dev/null +++ b/crates/apps/shturman-shell/src/lib.rs @@ -0,0 +1,230 @@ +//! `shturman-shell` (lib) — первый Slint-кадр (срезы C03/C04/C05/C07/C02) + headless +//! software-render кадра в PNG (спека §6). Bin (`main.rs`) — тонкая обёртка над этим API: +//! интерактивный `run` (dev: weston/нативно) либо `--screenshot ` (E2E/CI, без композитора). +//! v0: одноразовое чтение `ui.theme`/Power при старте (best-effort; без шины — дефолты, #4). + +mod theme; + +use std::path::Path; +use std::rc::Rc; +use std::sync::Once; +use std::time::{SystemTime, UNIX_EPOCH}; + +use slint::platform::software_renderer::{MinimalSoftwareWindow, RepaintBufferType}; +use slint::platform::{Platform, PlatformError, WindowAdapter}; +use slint::ComponentHandle; + +slint::slint! { + import { VerticalBox, HorizontalBox } from "std-widgets.slint"; + + export component AppWindow inherits Window { + in property is-night: false; + in property clock: "--:--"; + in property network: "unknown"; + in property ignition: "unknown"; + in property <[string]> tiles: ["Навигация", "Музыка", "Телефон", "Ассистент", "Машина", "Настройки"]; + + title: "Штурман"; + width: 1024px; + height: 600px; + background: root.is-night ? #0e1014 : #f4f5f7; + + VerticalBox { + padding: 16px; + spacing: 16px; + + HorizontalBox { + height: 44px; + Text { + text: root.clock; + font-size: 22px; + color: root.is-night ? #f0f0f0 : #1a1a1a; + vertical-alignment: center; + } + Rectangle { } + Text { + text: "сеть: " + root.network; + color: root.is-night ? #9aa0a6 : #5f6368; + vertical-alignment: center; + } + Text { + text: "зажигание: " + root.ignition; + color: root.is-night ? #9aa0a6 : #5f6368; + vertical-alignment: center; + } + } + + HorizontalBox { + spacing: 16px; + for tile in root.tiles : Rectangle { + background: root.is-night ? #1b1e24 : #ffffff; + border-radius: 16px; + Text { + text: tile; + font-size: 18px; + color: root.is-night ? #e8eaed : #202124; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + } + } +} + +/// Размер первого кадра (логические пиксели = физические при scale 1.0). +const FRAME_W: u32 = 1024; +const FRAME_H: u32 = 600; + +/// Начальное состояние кадра, прочитанное с шины (best-effort). Без шины — дефолты (#4). +#[derive(Debug, Clone)] +pub struct Initial { + pub theme: String, + pub ignition: String, + pub network: String, +} + +impl Default for Initial { + fn default() -> Self { + Self { + theme: "auto".into(), + ignition: "unknown".into(), + network: "unknown".into(), + } + } +} + +/// Одноразовое чтение состояния с шины (best-effort). Без шины/сервисов — дефолты (#4). +pub fn read_initial() -> Initial { + // current-thread рантайм: одно best-effort чтение на холодном старте, без пула потоков (#11). + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(_) => return Initial::default(), + }; + rt.block_on(async { + match connect_and_read().await { + Ok(i) => i, + Err(e) => { + tracing::warn!(error = %e, "нет шины/сервисов — дефолты кадра"); + Initial::default() + } + } + }) +} + +async fn connect_and_read() -> anyhow::Result { + let def = Initial::default(); // единый источник дефолтов (без дублей литералов) + let conn = shturman_sdk::connect().await?; + let settings = shturman_sdk::SettingsClient::new(&conn).await?; + let theme = settings + .get("ui.theme") + .await + .ok() + .and_then(|v| String::try_from(v).ok()) + .unwrap_or(def.theme); + let power = shturman_sdk::PowerClient::new(&conn).await?; + let ignition = power + .ignition_state() + .await + .map(|i| i.as_str().to_string()) + .unwrap_or(def.ignition); + Ok(Initial { + theme, + ignition, + network: def.network, + }) +} + +/// Часы UTC `HH:MM` без tz-зависимостей (локальная tz — позже, a-base §7). Возвращает (час, строка). +pub fn utc_hh_mm() -> (u8, String) { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let h = ((secs / 3600) % 24) as u8; + let m = ((secs / 60) % 60) as u8; + (h, format!("{h:02}:{m:02}")) +} + +/// Создать и наполнить окно (общий код интерактива и скриншота). +fn build_ui(initial: &Initial, hour: u8, clock: &str) -> anyhow::Result { + let ui = AppWindow::new()?; + ui.set_is_night(theme::resolve_night(&initial.theme, hour)); + ui.set_clock(clock.into()); + ui.set_network(initial.network.as_str().into()); + ui.set_ignition(initial.ignition.as_str().into()); + Ok(ui) +} + +/// Интерактивный запуск (dev: weston в VM / нативно на хосте). Блокирующий event-loop. +pub fn run_interactive(initial: &Initial, hour: u8, clock: &str) -> anyhow::Result<()> { + let ui = build_ui(initial, hour, clock)?; + tracing::info!("первый Slint-кадр (интерактивно)"); + ui.run()?; + Ok(()) +} + +// --- headless software-render (спека §6: основной автотест кадра, композитор не нужен) --- + +thread_local! { + static SCREENSHOT_WINDOW: Rc = + MinimalSoftwareWindow::new(RepaintBufferType::ReusedBuffer); +} + +struct ScreenshotPlatform; +impl Platform for ScreenshotPlatform { + fn create_window_adapter(&self) -> Result, PlatformError> { + Ok(SCREENSHOT_WINDOW.with(|w| w.clone())) + } +} + +/// Установить software-platform один раз на процесс. `--screenshot` зовётся в свежем процессе +/// первым делом; в мультитестовом процессе повтор терпим (`Once` + терпимый результат). +fn ensure_screenshot_platform() { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let _ = slint::platform::set_platform(Box::new(ScreenshotPlatform)); + }); +} + +/// Headless software-render первого кадра в PNG (спека §6). Без дисплей-сервера/композитора. +/// `hour` задаёт тему для `auto` (тест — детерминированно); `clock` берётся из текущего времени. +pub fn render_screenshot(initial: &Initial, hour: u8, path: &Path) -> anyhow::Result<()> { + ensure_screenshot_platform(); + let (_, clock) = utc_hh_mm(); + let ui = build_ui(initial, hour, &clock)?; + + let window = SCREENSHOT_WINDOW.with(|w| w.clone()); + window.set_size(slint::PhysicalSize::new(FRAME_W, FRAME_H)); + ui.show()?; + ui.window().request_redraw(); // форсим перерисовку (повторный рендер в том же потоке) + + let mut buf = vec![slint::Rgb8Pixel { r: 0, g: 0, b: 0 }; (FRAME_W * FRAME_H) as usize]; + let drawn = window.draw_if_needed(|renderer| { + renderer.render(buf.as_mut_slice(), FRAME_W as usize); + }); + ui.hide()?; // освободить окно для следующего рендера в том же потоке + if !drawn { + anyhow::bail!("software-renderer не отрисовал кадр"); + } + write_png(path, FRAME_W, FRAME_H, &buf)?; + tracing::info!(path = %path.display(), "кадр записан (software-render)"); + Ok(()) +} + +fn write_png(path: &Path, w: u32, h: u32, buf: &[slint::Rgb8Pixel]) -> anyhow::Result<()> { + let file = std::fs::File::create(path)?; + let mut enc = png::Encoder::new(std::io::BufWriter::new(file), w, h); + enc.set_color(png::ColorType::Rgb); + enc.set_depth(png::BitDepth::Eight); + let mut writer = enc.write_header()?; + let mut data = Vec::with_capacity((w * h * 3) as usize); + for px in buf { + data.extend_from_slice(&[px.r, px.g, px.b]); + } + writer.write_image_data(&data)?; + Ok(()) +} diff --git a/crates/apps/shturman-shell/src/main.rs b/crates/apps/shturman-shell/src/main.rs index 30ac4c6..ce390b9 100644 --- a/crates/apps/shturman-shell/src/main.rs +++ b/crates/apps/shturman-shell/src/main.rs @@ -1,152 +1,36 @@ -//! `shturman-shell` — первый Slint-кадр (срезы C03/C04/C05/C07/C02). На SDK (architecture §1). -//! v0: одноразовое чтение `ui.theme`/Power при старте (best-effort; без шины — дефолты, #4); рендер. -//! Live-обновления (Changed/AccChanged) и локальная tz часов — позже (v0.5 / a-base §7). +//! `shturman-shell` (bin) — тонкая обёртка над `shturman_shell` (lib): +//! - по умолчанию: интерактивный первый Slint-кадр (dev: weston в VM / нативно на хосте); +//! - `--screenshot `: headless software-render кадра в PNG (E2E/CI, без композитора — §6). -mod theme; - -use std::time::{SystemTime, UNIX_EPOCH}; - -slint::slint! { - import { VerticalBox, HorizontalBox } from "std-widgets.slint"; - - export component AppWindow inherits Window { - in property is-night: false; - in property clock: "--:--"; - in property network: "unknown"; - in property ignition: "unknown"; - in property <[string]> tiles: ["Навигация", "Музыка", "Телефон", "Ассистент", "Машина", "Настройки"]; - - title: "Штурман"; - width: 1024px; - height: 600px; - background: root.is-night ? #0e1014 : #f4f5f7; - - VerticalBox { - padding: 16px; - spacing: 16px; - - HorizontalBox { - height: 44px; - Text { - text: root.clock; - font-size: 22px; - color: root.is-night ? #f0f0f0 : #1a1a1a; - vertical-alignment: center; - } - Rectangle { } - Text { - text: "сеть: " + root.network; - color: root.is-night ? #9aa0a6 : #5f6368; - vertical-alignment: center; - } - Text { - text: "зажигание: " + root.ignition; - color: root.is-night ? #9aa0a6 : #5f6368; - vertical-alignment: center; - } - } - - HorizontalBox { - spacing: 16px; - for tile in root.tiles : Rectangle { - background: root.is-night ? #1b1e24 : #ffffff; - border-radius: 16px; - Text { - text: tile; - font-size: 18px; - color: root.is-night ? #e8eaed : #202124; - horizontal-alignment: center; - vertical-alignment: center; - } - } - } - } - } -} - -#[derive(Debug)] -struct Initial { - theme: String, - ignition: String, - network: String, -} - -impl Default for Initial { - fn default() -> Self { - Self { - theme: "auto".into(), - ignition: "unknown".into(), - network: "unknown".into(), - } - } -} - -/// Одноразовое чтение состояния с шины (best-effort). Без шины/сервисов — дефолты (#4). -fn read_initial() -> Initial { - // current-thread рантайм: одно best-effort чтение на холодном старте, без пула потоков (#11). - let rt = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt, - Err(_) => return Initial::default(), - }; - rt.block_on(async { - match connect_and_read().await { - Ok(i) => i, - Err(e) => { - tracing::warn!(error = %e, "нет шины/сервисов — дефолты кадра"); - Initial::default() - } - } - }) -} - -async fn connect_and_read() -> anyhow::Result { - let def = Initial::default(); // единый источник дефолтов (без дублей литералов) - let conn = shturman_sdk::connect().await?; - let settings = shturman_sdk::SettingsClient::new(&conn).await?; - let theme = settings - .get("ui.theme") - .await - .ok() - .and_then(|v| String::try_from(v).ok()) - .unwrap_or(def.theme); - let power = shturman_sdk::PowerClient::new(&conn).await?; - let ignition = power - .ignition_state() - .await - .map(|i| i.as_str().to_string()) - .unwrap_or(def.ignition); - Ok(Initial { - theme, - ignition, - network: def.network, - }) -} - -/// Часы UTC `HH:MM` без tz-зависимостей (локальная tz — позже, a-base §7). Возвращает (час, строка). -fn utc_hh_mm() -> (u8, String) { - let secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let h = ((secs / 3600) % 24) as u8; - let m = ((secs / 60) % 60) as u8; - (h, format!("{h:02}:{m:02}")) -} +use shturman_shell::{read_initial, render_screenshot, run_interactive, utc_hh_mm}; +use std::path::PathBuf; fn main() -> anyhow::Result<()> { shturman_common::init_tracing("shturman-shell"); + let screenshot = parse_screenshot_arg(); let initial = read_initial(); let (hour, clock) = utc_hh_mm(); - let ui = AppWindow::new()?; - ui.set_is_night(theme::resolve_night(&initial.theme, hour)); - ui.set_clock(clock.into()); - ui.set_network(initial.network.into()); - ui.set_ignition(initial.ignition.into()); - tracing::info!("первый Slint-кадр"); - ui.run()?; + match screenshot { + Some(path) => { + render_screenshot(&initial, hour, &path)?; + println!("{}", path.display()); // путь PNG — для E2E-скрипта + } + None => run_interactive(&initial, hour, &clock)?, + } Ok(()) } + +/// Разобрать `--screenshot ` / `--screenshot=` (без внешних зависимостей). +fn parse_screenshot_arg() -> Option { + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + if a == "--screenshot" { + return args.next().map(PathBuf::from); + } + if let Some(p) = a.strip_prefix("--screenshot=") { + return Some(PathBuf::from(p)); + } + } + None +} diff --git a/crates/apps/shturman-shell/tests/screenshot.rs b/crates/apps/shturman-shell/tests/screenshot.rs new file mode 100644 index 0000000..e81d5bc --- /dev/null +++ b/crates/apps/shturman-shell/tests/screenshot.rs @@ -0,0 +1,71 @@ +//! Headless software-render первого кадра в PNG (спека §6 / §9.3 п.6 / §9.4 «шагающий скелет»). +//! Работает без дисплея (и на dev-Mac, и в Lima): Slint software-renderer → PNG. +//! +//! Один тест намеренно: Slint-платформа процесс-глобальна и ставится один раз, а `render_screenshot` +//! в проде зовётся ровно раз на процесс (oneshot-сервис / `just shell-frame`) — параллельный прогон +//! нескольких рендер-тестов в одном бинаре ушёл бы в дефолтный winit-бэкенд (на macOS — только main-thread). + +use shturman_shell::{render_screenshot, Initial}; +use std::path::Path; + +/// Декодировать PNG → (ширина, высота, RGB-байты). +fn decode(path: &Path) -> (u32, u32, Vec) { + let dec = png::Decoder::new(std::fs::File::open(path).unwrap()); + let mut reader = dec.read_info().unwrap(); + let (w, h) = (reader.info().width, reader.info().height); + let mut buf = vec![0u8; reader.output_buffer_size()]; + let info = reader.next_frame(&mut buf).unwrap(); + buf.truncate(info.buffer_size()); + (w, h, buf) +} + +#[test] +fn renders_first_frame_reflecting_theme() { + let dir = tempfile::tempdir().unwrap(); + + // --- ночь: кадр не пустой, верный размер, тёмный фон --- + let night = dir.path().join("night.png"); + render_screenshot( + &Initial { + theme: "night".into(), + ignition: "running".into(), + network: "unknown".into(), + }, + 12, + &night, + ) + .expect("render ночь"); + + let (w, h, npx) = decode(&night); + assert_eq!((w, h), (1024, 600), "размер кадра"); + // «не пустой» содержательно: кадр не одноцветный (нарисованы тайлы/текст, не только фон). + let first = npx[0]; + assert!( + npx.iter().any(|&b| b != first), + "кадр одноцветный — рендер пустой" + ); + // угол (0,0) = фон окна — ночью тёмный (#0e1014). + let (nr, ng, nb) = (npx[0], npx[1], npx[2]); + assert!( + nr < 64 && ng < 64 && nb < 64, + "ночной фон не тёмный: {nr},{ng},{nb}" + ); + + // --- день: тот же угол — светлый (#f4f5f7) — тема отражена --- + let day = dir.path().join("day.png"); + render_screenshot( + &Initial { + theme: "day".into(), + ..Default::default() + }, + 12, + &day, + ) + .expect("render день"); + let (_, _, dpx) = decode(&day); + let (dr, dg, db) = (dpx[0], dpx[1], dpx[2]); + assert!( + dr > 192 && dg > 192 && db > 192, + "дневной фон не светлый: {dr},{dg},{db}" + ); +} diff --git a/docs/specs/v0.1-v0.6-foundation.md b/docs/specs/v0.1-v0.6-foundation.md index 1298268..c44e69d 100644 --- a/docs/specs/v0.1-v0.6-foundation.md +++ b/docs/specs/v0.1-v0.6-foundation.md @@ -390,9 +390,10 @@ Oneshot-бинарь под `shturman-firstboot.service` (`ConditionPathExists=! по мере событий). - **eMMC write-min (A11):** дисциплина (volatile-логи, tmpfs-транзиент, zram, без спама в `/data`). **Измеримая проверка (детерминированный VM-прокси):** дельта записанных секторов на loop-устройстве `/data` - (`/proc/diskstats`) за фиксированное окно простоя (напр. 60 c после boot-settle) **ниже порога T** (🟡 - калибруется) + **нет периодических флашей вне allow-list писателей** (`fake-hwclock`, `Settings` on-Set); - всё прочее → fail. Абсолютный байт-бюджет — вердикт на RK3588 (performance §2). + (`/proc/diskstats`, поле 10) за фиксированное окно простоя (E2E: `E2E_IDLE_SECS`=20 c после boot-settle) **ниже + порога T** + **нет периодических флашей вне allow-list писателей** (`fake-hwclock`, `Settings` on-Set); всё прочее + → fail. **Калибровка (Lima, 2026-06-24): ~80–104 сектора/20 c простоя; порог T = 4096 секторов (~2 МБ/окно), + env `E2E_EMMC_MAX_SECTORS`.** Абсолютный байт-бюджет — вердикт на RK3588 (performance §2). ### 7.6 systemd-оркестрация (A15) @@ -419,11 +420,18 @@ Oneshot-бинарь под `shturman-firstboot.service` (`ConditionPathExists=! «VM лёгкая»; правится локально); `mounts:` репозиторий **writable** (правим на хосте — собираем в VM). - **provision (system):** установить пакеты (`systemd`, `dbus`, `pipewire` + WirePlumber [задел v1], `weston` + `weston-screenshooter`, `can-utils`, `rustup`/toolchain, `python3`+venv, **`systemd-zram-generator`**, - `fake-hwclock`, кириллические шрифты); `modprobe vcan` + `ip link add vcan0`; + `fake-hwclock`, кириллические шрифты + **build-deps Slint/winit на Linux**: `libfontconfig1-dev`/`libxkbcommon-dev`/ + `libwayland-dev` — иначе `cargo build` shell падает на `yeslogic-fontconfig-sys`); **`linux-modules-extra-$(uname -r)`** + (модули `zram`/`vcan` НЕ входят в базовый vz-образ Lima); `modprobe vcan` + `ip link add vcan0` (vcan может + отсутствовать в vz-ядре — VM↔HW-граница, как раньше); создать loopback-`/data` (ext4 + power-safe-опции) и завести **постоянный** `data.mount`/fstab + tmpfs-overlay; - override `fake-hwclock` пути на `/data`; разложить `systemd/`-юниты + journald/zram-generator/oomd drop-ins + - dbus policy (прод `ru.shturman.conf` + dev-only `ru.shturman.dev.conf`); включить `shturman.target`. - *(screenshot кадра в CI — через Slint software-renderer, без пакета grim; см. §6.)* + override `fake-hwclock` пути на `/data` + удалить стоковый `/etc/fake-hwclock.data` (A11; сервис в Lima **masked** — + Lima сам синхронит время, на HW юнит размаскирован и читает `FILE` через `EnvironmentFile`); разложить + `systemd/`-юниты + journald/zram-generator/oomd drop-ins + dbus policy (прод `ru.shturman.conf`; dev-mock + `ru.shturman.dev.PowerMock1` — интерфейс на объекте `ru.shturman.Power`, **отдельное dev-имя/policy НЕ нужны**, + покрыт `send_destination`); включить `shturman.target`. + *(screenshot кадра в E2E — через Slint software-renderer → PNG, без weston/grim; `shell.service` в v0.6 = + oneshot-screenshot, живой weston-shell — v0.5; см. §6.)* - **reference-«BSP» (A16):** в dev это **Lima-профиль** (дев-таргет). Реальный reference-BSP (DT overlay + HAL + DBC) — на HW (a-base §13), вне VM. - **Подъём:** `just vm-up` → `limactl start --name=shturman lima/shturman.yaml` (создание+provision); @@ -663,6 +671,27 @@ SOFTWARE. - **`principles #12`**: уточнить LGPL — гранулярно (динамическая/системная линковка допустима), а не blanket; согласовать с `deny.toml`/§3. +**Реализация (План 5 ч.2 — v0.6 Lima E2E, 2026-06-24, проверено в Lima):** +- **`shell.service` (§6/§7.6):** v0.6 = **oneshot software-render → PNG** (`shturman-shell --screenshot + /run/shturman/frame.png`, tmpfs/volatile; `RemainAfterExit=yes` → `is-active=active` детерминированно, без + хрупкого weston). Живой weston-shell (`ui.run()`) — **v0.5**. `shturman-shell` стал lib+bin (рендер тестируем + headless и на dev-Mac). Доки §6/§7.6 уже называют software-renderer основным — синхронизировано. +- **Провижининг Lima (§8.1):** добавлены build-deps `libfontconfig1-dev`/`libxkbcommon-dev`/`libwayland-dev` + (Slint/winit на Linux тянут fontconfig — на macOS CoreText, поэтому всплыло только в VM) + + `linux-modules-extra-$(uname -r)` (модули `zram`/`vcan` не в базовом vz-ядре). vcan-модуль всё равно может + отсутствовать — `zram` ставится, `vcan` — best-effort (Vehicle Sim v2). +- **fake-hwclock (§7.3):** сервис в Lima **masked** (Lima синхронит время с хоста) → override через + `EnvironmentFile` не срабатывает в VM; скрипт читает `FILE` из env, E2E демонстрирует запись в `/data` напрямую. + Стоковый `/etc/fake-hwclock.data` удаляется в провижининге (A11). На HW юнит размаскирован — механизм тот же. +- **eMMC-порог T (§7.5):** калибровка ~80–104 сект/20 c → **T=4096 сект**; окно простоя 20 c (не 60). +- **CARGO_TARGET_DIR (§8.2):** E2E/сборка в VM пишут в **VM-локальный** target (`~/.cache/shturman/target`), не в + смонтированный `target/` — иначе конфликт Darwin↔Linux-артефактов + медленный virtiofs. +- **dev-mock policy (§5.1):** отдельный `ru.shturman.dev.conf` для `PowerMock1` **не нужен** — это интерфейс на + объекте `ru.shturman.Power` (имя то же), покрыт `send_destination=ru.shturman.Power`. Файл зарезервирован на + случай отдельного dev-**имени** на шине. +- **E2E reboot (§9.3.4):** двухфазно `just e2e` (pre → guest-reboot Lima → post); персист Settings + machine-id + every-boot bind проверяются после реального ребута. `just run` = только pre (без ребута). + --- ## 14. Дальше по ритму diff --git a/justfile b/justfile index ef4dbff..7719121 100644 --- a/justfile +++ b/justfile @@ -60,9 +60,9 @@ sim: # --- Lima-VM (часть 2 Плана 5: нужен limactl — brew install lima) --- -# поднять dev-VM (создание + провижининг) +# поднять dev-VM (создание + провижининг). --tty=false: неинтерактивный старт (без редактора YAML). vm-up: - limactl start --name=shturman lima/shturman.yaml + limactl start --tty=false --name=shturman lima/shturman.yaml # остановить VM vm-down: @@ -76,16 +76,34 @@ vm-shell: vm-reset: -limactl stop shturman -limactl delete shturman - limactl start --name=shturman lima/shturman.yaml + limactl start --tty=false --name=shturman lima/shturman.yaml -# собрать + развернуть + поднять target в VM (boot → сервисы → кадр) +# собрать + развернуть + поднять target в VM (boot → сервисы → кадр); без reboot (фаза pre) run: - limactl shell shturman -- bash -lc 'cd /shturman && bash tests/e2e/run.sh' + limactl shell --workdir /shturman shturman -- bash -lc 'E2E_PHASE=pre bash tests/e2e/run.sh' -# сквозной E2E в VM (приёмка v0.1/v0.6 + шагающий скелет) +# сквозной E2E в VM (приёмка v0.1/v0.6 + шагающий скелет): pre → reboot → post e2e: - limactl shell shturman -- bash -lc 'cd /shturman && bash tests/e2e/run.sh' + #!/usr/bin/env bash + set -euo pipefail + echo "== E2E фаза PRE (сборка → подъём → проверки → персист-проба) ==" + limactl shell --workdir /shturman shturman -- bash -lc 'E2E_PHASE=pre bash tests/e2e/run.sh' + echo + echo "== reboot VM (проверка персиста + machine-id every-boot bind) ==" + limactl shell --workdir / shturman -- sudo systemctl reboot 2>/dev/null || true + echo "== ждём возврат VM ==" + for i in $(seq 1 60); do + sleep 2 + if limactl shell --workdir / shturman -- true 2>/dev/null; then echo "VM вернулась (попытка $i)"; break; fi + done + limactl shell --workdir / shturman -- bash -lc 'for i in $(seq 1 30); do systemctl is-active --quiet shturman.target && break; sleep 1; done' 2>/dev/null || true + echo + echo "== E2E фаза POST (персист после reboot) ==" + limactl shell --workdir /shturman shturman -- bash -lc 'E2E_PHASE=post bash tests/e2e/run.sh' + echo + echo "== E2E OK ✅ ==" -# ручная проверка кадра: на хосте — окно Slint (headless PNG-screenshot — часть 2/Lima) -shell-frame: - cargo run -p shturman-shell +# ручная инспекция кадра: headless software-render первого кадра → PNG (без дисплея/композитора, §6) +shell-frame path="target/shell-frame.png": + cargo run -q -p shturman-shell -- --screenshot {{path}} + @echo "кадр записан: {{path}}" diff --git a/lima/shturman.yaml b/lima/shturman.yaml index 3ab9f83..868c4fd 100644 --- a/lima/shturman.yaml +++ b/lima/shturman.yaml @@ -26,9 +26,14 @@ provision: apt-get install -y \ dbus pipewire wireplumber weston \ can-utils python3 python3-venv \ - systemd-zram-generator fake-hwclock \ + systemd-zram-generator systemd-oomd fake-hwclock \ fonts-dejavu-core fonts-noto-core \ - build-essential pkg-config curl + build-essential pkg-config curl \ + libfontconfig1-dev libxkbcommon-dev libwayland-dev + + # zram/vcan-модули не входят в базовый vz-образ Lima → доустановить linux-modules-extra + # (иначе zram-generator падает). vcan всё равно может отсутствовать — честная VM↔HW-граница. + apt-get install -y "linux-modules-extra-$(uname -r)" || true # vcan (для Vehicle Simulator, v2 — поднимаем заранее для воспроизводимости) modprobe vcan || true @@ -54,11 +59,13 @@ provision: install -d /etc/systemd/oomd.conf.d install -m644 /shturman/systemd/oomd-shturman.conf /etc/systemd/oomd.conf.d/shturman.conf - # fake-hwclock → /data (не на rootfs; A07/A11) + # fake-hwclock → /data (не на rootfs; A07/A11). Сервис в Lima masked (Lima сам синхронит время) — + # на HW он размаскирован и читает FILE из /etc/default/fake-hwclock через EnvironmentFile. echo 'FILE=/data/state/fake-hwclock.data' > /etc/default/fake-hwclock || true + rm -f /etc/fake-hwclock.data || true # стоковый файл на rootfs — A11: персист только в /data systemctl daemon-reload - systemctl enable systemd-oomd.service || true + systemctl enable --now systemd-oomd.service || true # защита critical set от OOM (A09); политика — oomd.conf.d # shturman.target включаем, но НЕ стартуем здесь — бинарей ещё нет (just run/e2e). systemctl enable shturman.target || true diff --git a/systemd/shturman-shell.service b/systemd/shturman-shell.service index 4c42765..f6d41bf 100644 --- a/systemd/shturman-shell.service +++ b/systemd/shturman-shell.service @@ -1,17 +1,19 @@ [Unit] -Description=Штурман Shell (первый Slint-кадр) +Description=Штурман Shell — первый Slint-кадр (software-render → PNG, §6) Requires=data.mount shturman-firstboot.service After=shturman-power.service shturman-settings.service shturman-machineid.service PartOf=shturman.target [Service] -ExecStart=/usr/local/bin/shturman-shell -Restart=on-failure -RestartSec=2 +# v0.6: headless software-render кадра в PNG (спека §6 — основной автотест кадра, композитор не нужен). +# oneshot+RemainAfterExit → is-active=active детерминированно, без хрупкого weston (живой weston-shell — v0.5). +# Кадр читает ui.theme/Power с системной шины (After=power/settings) и пишет в tmpfs /run (volatile, A11). +Type=oneshot +RemainAfterExit=yes +RuntimeDirectory=shturman +ExecStart=/usr/local/bin/shturman-shell --screenshot /run/shturman/frame.png +TimeoutStartSec=30 OOMScoreAdjust=-600 -# Wayland-дисплей: provisioning/E2E поднимает weston headless (финализируется в части 2). -Environment=WAYLAND_DISPLAY=wayland-1 -Environment=XDG_RUNTIME_DIR=/run/user/0 [Install] WantedBy=shturman.target diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index b31a333..2484923 100644 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -1,54 +1,202 @@ #!/usr/bin/env bash # Сквозной E2E Штурмана в Lima-VM (приёмка v0.1/v0.6 + шагающий скелет, спека §9.3/§9.4). -# Запуск: just e2e (внутри VM через limactl shell). Системная шина устройства. -# Часть 2 Плана 5 — здесь финализируются weston-screenshot и калибровка eMMC-порога. -set -euo pipefail +# Запуск: just e2e (двухфазно через reboot) или just run (однофазно, без reboot). +# +# Фазы (env E2E_PHASE): +# pre — сборка → install бинарей/юнитов → старт shturman.target → проверки §9.3 (1–8) +# → выставить персист-пробу (Settings.Set + запомнить machine-id); [по умолчанию] +# post — после reboot: персист настройки сохранился + machine-id стабилен (every-boot bind, §9.3.4). +# +# Системная шина устройства (§5.1). dev-mocks включён дефолт-фичей сборки (fake-ACC). +set -uo pipefail REPO=/shturman -cd "$REPO" +cd "$REPO" || { echo "нет $REPO"; exit 1; } -echo "== сборка ==" -cargo build --release --workspace -sudo install -m755 target/release/shturman-firstboot /usr/local/bin/ -sudo install -m755 target/release/shturman-settings /usr/local/bin/ -sudo install -m755 target/release/shturman-power /usr/local/bin/ -sudo install -m755 target/release/shturman-shell /usr/local/bin/ +PHASE="${E2E_PHASE:-pre}" +IDLE_SECS="${E2E_IDLE_SECS:-20}" # окно простоя для eMMC-прокси (§7.5) +EMMC_MAX_SECTORS="${E2E_EMMC_MAX_SECTORS:-4096}" # порог T (🟡 калибруется; 4096 сект ≈ 2 МБ/окно) +FRAME=/run/shturman/frame.png +PROBE_KEY=ui.theme +PROBE_VAL=night +MID_BEFORE=/data/state/e2e-mid-before # снимок machine-id до reboot (персист в /data) -echo "== старт target ==" -sudo systemctl daemon-reload -sudo systemctl start shturman.target -sleep 3 +export PATH="$HOME/.cargo/bin:$PATH" +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$HOME/.cache/shturman/target}" +pass() { echo " ✓ $*"; } fail() { echo "E2E FAIL: $*" >&2; exit 1; } +info() { echo; echo "== $* =="; } -echo "== 1. /data смонтирован до сервисов, реальные опции ==" -findmnt /data || fail "/data не смонтирован" -findmnt -no OPTIONS /data | grep -q errors=remount-ro || fail "нет errors=remount-ro" +# Имена на шине (зеркало crates/shturman-ipc/src/names.rs). +P_NAME=ru.shturman.Power; P_PATH=/ru/shturman/Power; P_IFACE=ru.shturman.Power1 +P_MOCK=ru.shturman.dev.PowerMock1 +S_NAME=ru.shturman.Settings; S_PATH=/ru/shturman/Settings; S_IFACE=ru.shturman.Settings1 -echo "== 2. first-boot идемпотентен ==" -test -f /data/.shturman-provisioned || fail "нет маркера provisioned" -test -f /data/state/machine-id || fail "нет machine-id" +settings_get() { busctl --system call "$S_NAME" "$S_PATH" "$S_IFACE" Get s "$1" 2>/dev/null; } -echo "== 3. per-unit critical set active (не довольствуемся degraded) ==" +# =========================== POST-reboot фаза =========================== +if [ "$PHASE" = post ]; then + info "POST-reboot: персист настроек + machine-id стабилен (§9.3.4)" + # дождаться, пока сервисы поднимутся после автозагрузки target + for _ in $(seq 1 30); do systemctl is-active --quiet shturman-settings && break; sleep 1; done + + findmnt /data >/dev/null || fail "/data не смонтирован после reboot" + pass "/data смонтирован после reboot" + + # настройка пережила reboot + got=$(settings_get "$PROBE_KEY") + echo "$got" | grep -q "\"$PROBE_VAL\"" || fail "Settings.$PROBE_KEY != $PROBE_VAL после reboot (got: $got)" + pass "Settings.$PROBE_KEY = $PROBE_VAL пережил reboot" + + # machine-id стабилен (every-boot bind из /data/state/machine-id) + sudo test -f "$MID_BEFORE" || fail "нет снимка $MID_BEFORE (фаза pre не отработала?)" + before=$(sudo cat "$MID_BEFORE"); now=$(cat /etc/machine-id) + [ -n "$now" ] || fail "/etc/machine-id пуст" + [ "$before" = "$now" ] || fail "machine-id изменился: было $before, стало $now" + src=$(sudo cat /data/state/machine-id) + [ "$now" = "$src" ] || fail "/etc/machine-id($now) != /data/state/machine-id($src) — bind не сработал" + pass "machine-id стабилен после reboot ($now), привязан из /data" + + # journald volatile теперь естественно (drop-in присутствовал на boot) + test -d /run/log/journal && ! test -d /var/log/journal \ + && pass "journald volatile (/run/log/journal)" || echo " WARN: journald не строго volatile" + + echo; echo "E2E POST OK ✅" + exit 0 +fi + +# ============================ PRE фаза ============================ +info "сборка (release, VM-локальный target=$CARGO_TARGET_DIR)" +cargo build --release --workspace || fail "сборка" +for b in firstboot settings power shell; do + sudo install -m755 "$CARGO_TARGET_DIR/release/shturman-$b" /usr/local/bin/ || fail "install shturman-$b" +done +pass "бинари установлены в /usr/local/bin" + +info "раскладка systemd-юнитов + dbus policy (из репо — подхватить правки)" +sudo install -m644 systemd/shturman.target systemd/data.mount /etc/systemd/system/ +sudo install -m644 systemd/shturman-firstboot.service systemd/shturman-machineid.service \ + systemd/shturman-power.service systemd/shturman-settings.service \ + systemd/shturman-shell.service /etc/systemd/system/ +sudo install -d /etc/dbus-1/system.d +sudo install -m644 systemd/dbus/ru.shturman.conf /etc/dbus-1/system.d/ +sudo install -d /etc/systemd/journald.conf.d /etc/systemd/oomd.conf.d +sudo install -m644 systemd/journald-shturman.conf /etc/systemd/journald.conf.d/shturman.conf +sudo install -m644 systemd/oomd-shturman.conf /etc/systemd/oomd.conf.d/shturman.conf +sudo install -m644 systemd/zram-generator.conf /etc/systemd/zram-generator.conf +sudo systemctl daemon-reload +# применить конфиги детерминированно (на свежем boot drop-in’ы появились после старта демонов) +sudo systemctl reload dbus 2>/dev/null || true +sudo systemctl restart systemd-journald 2>/dev/null || true +sudo rm -rf /var/log/journal 2>/dev/null || true # устаревший persistent-журнал (до drop-in); volatile его не пересоздаст +sudo modprobe zram 2>/dev/null || true # zram-модуль (linux-modules-extra); может отсутствовать в vz-ядре +sudo systemctl start "systemd-zram-setup@zram0.service" 2>/dev/null || true +sudo systemctl restart systemd-oomd 2>/dev/null || sudo systemctl start systemd-oomd 2>/dev/null || true # подхватить oomd.conf.d +pass "юниты/политики разложены" + +info "старт shturman.target" +sudo systemctl start shturman.target || fail "shturman.target не стартовал" +sudo systemctl restart shturman-shell.service || true # свежий кадр (на повторном прогоне) +for _ in $(seq 1 15); do systemctl is-active --quiet shturman-shell && break; sleep 1; done + +# ---- 1. /data до сервисов + реальные power-safe опции (§9.3.1) ---- +info "1. /data смонтирован, реальные non-default опции + volatile-слой" +findmnt /data >/dev/null || fail "/data не смонтирован" +opts=$(findmnt -no OPTIONS /data) +echo "$opts" | grep -q errors=remount-ro || fail "нет errors=remount-ro (opts: $opts)" +pass "/data: $opts" +# volatile-слой (tmpfs) присутствует — кадр/журнал/транзиент пишутся сюда, не на flash (A11). +# Полный RO-rootfs + overlay(upper на tmpfs) — на HW/v4 (A/B boot-select нет в VM, §7.1); тут — дисциплина + tmpfs. +findmnt -t tmpfs /run >/dev/null || fail "/run не tmpfs (нет volatile-слоя)" +pass "volatile-слой: /run = tmpfs" + +# ---- 2. first-boot маркер + идемпотентность (§9.3.2) ---- +info "2. first-boot маркер + machine-id" +sudo test -f /data/.shturman-provisioned || fail "нет маркера .shturman-provisioned" +sudo test -f /data/state/machine-id || fail "нет /data/state/machine-id" +sudo systemctl start shturman-firstboot.service # повторно — Condition гейтит → no-op +pass "first-boot маркер на месте, повторный запуск no-op" + +# ---- 3. per-unit critical set active (degraded не маскирует, §9.3.1) ---- +info "3. per-unit critical set" for u in shturman-power shturman-settings shturman-shell; do - systemctl is-active --quiet "$u" || fail "$u не active" + systemctl is-active --quiet "$u" || fail "$u не active ($(systemctl is-active "$u" 2>&1))" + pass "$u: active" +done +for u in shturman-firstboot shturman-machineid; do + state=$(systemctl is-active "$u" 2>&1) + [ "$state" = active ] || fail "$u не active(exited): $state" + pass "$u: $state (oneshot)" done -echo "== 4. имена на системной шине ==" -busctl --system list | grep -q ru.shturman.Power || fail "нет ru.shturman.Power" -busctl --system list | grep -q ru.shturman.Settings || fail "нет ru.shturman.Settings" +# ---- 4. имена на системной шине (own) + сервис отвечает (§9.3.3) ---- +info "4. имена на шине + отклик" +busctl --system list | grep -q "$P_NAME" || fail "нет $P_NAME на шине" +busctl --system list | grep -q "$S_NAME" || fail "нет $S_NAME на шине" +busctl --system call "$P_NAME" "$P_PATH" "$P_IFACE" GetPowerState | grep -q running \ + || fail "Power.GetPowerState != running" +pass "$P_NAME / $S_NAME владеют именами; GetPowerState=running" -echo "== 5. fake-ACC: SetAcc -> AccChanged ==" -# (подписка+вызов dev.PowerMock1; реализация ассерта — busctl monitor/call, финал в части 2) +# ---- 5. fake-ACC: SetAcc -> AccChanged (§9.3.5) ---- +info "5. fake-ACC SetAcc -> AccChanged" +mon=$(mktemp) +# sudo нужен busctl для eavesdrop системной шины; редирект в $mon — намеренно user-owned mktemp (SC2024 ок). +# shellcheck disable=SC2024 +sudo busctl --system monitor "$P_NAME" >"$mon" 2>&1 & +MON=$! +sleep 1 +busctl --system call "$P_NAME" "$P_PATH" "$P_MOCK" SetAcc b false || { sudo kill "$MON" 2>/dev/null; fail "вызов SetAcc"; } +sleep 1 +sudo kill "$MON" 2>/dev/null; wait "$MON" 2>/dev/null +grep -q AccChanged "$mon" || { echo "--- monitor ---"; cat "$mon"; fail "AccChanged не наблюдаем"; } +rm -f "$mon" +busctl --system call "$P_NAME" "$P_PATH" "$P_MOCK" SetAcc b true >/dev/null 2>&1 || true # вернуть acc=on +pass "AccChanged наблюдаем после SetAcc" -echo "== 6. персист настроек через reboot + machine-id стабилен ==" -# (Settings.Set -> sudo reboot -> повторный прогон сверяет; оформляется в части 2) +# ---- 7. первый Slint-кадр: PNG не пустой (§9.3.6) ---- +info "7. первый Slint-кадр (software-render PNG)" +sudo test -f "$FRAME" || fail "нет кадра $FRAME (shell.service не отрендерил?)" +sz=$(sudo stat -c%s "$FRAME"); [ "$sz" -gt 10000 ] || fail "кадр подозрительно мал ($sz Б)" +sudo head -c8 "$FRAME" | od -An -tx1 | tr -d ' \n' | grep -qi "89504e47" || fail "$FRAME не PNG" +pass "кадр $FRAME: $sz Б, валидный PNG" -echo "== 7. первый кадр (software-render PNG не пустой) ==" -# (weston headless + shturman-shell + screenshot; финал — часть 2) +# ---- 8. base-бюджеты: journald / zram / fake-hwclock / eMMC-прокси (§9.3.7) ---- +info "8. base-бюджеты (функц.)" +# journald volatile: активный журнал в /run/log/journal, persistent /var/log/journal отсутствует (A10) +test -d /run/log/journal || fail "journald не volatile (нет /run/log/journal)" +test -d /var/log/journal && fail "journald пишет в persistent /var/log/journal (нарушение A10)" +pass "journald volatile (/run/log/journal, без /var/log/journal)" +# ровно одно zram-устройство (A09); модуль zram может отсутствовать в vz-ядре — честная VM↔HW-граница +zn=$(zramctl --noheadings 2>/dev/null | wc -l | tr -d ' ') +if [ "$zn" = 1 ]; then pass "zram: ровно одно устройство ($(zramctl --noheadings --output NAME 2>/dev/null))" +elif [ "$zn" = 0 ]; then echo " WARN: zram-устройств нет (модуль zram отсутствует в vz-ядре — HW-only, §13)" +else fail "zram: ожидалось одно устройство, найдено $zn"; fi +# fake-hwclock пишет в /data, не в /etc (A11). Override — FILE из /etc/default/fake-hwclock +# (сервис в Lima masked: Lima сам синхронит время — на HW юнит размаскирован, EnvironmentFile тот же). +sudo sh -c '. /etc/default/fake-hwclock 2>/dev/null; FILE="${FILE:-/data/state/fake-hwclock.data}" fake-hwclock save' || true +sudo test -f /data/state/fake-hwclock.data || fail "fake-hwclock не записал в /data/state/fake-hwclock.data" +sudo test -f /etc/fake-hwclock.data && fail "fake-hwclock пишет в /etc (нарушение A11)" +pass "fake-hwclock → /data/state/fake-hwclock.data (не в /etc)" +# systemd-oomd: запущен + наша политика загружена (A09). PSI/cgroup2 нужны — в vz есть; иначе honest WARN. +if [ "$(systemctl is-active systemd-oomd 2>/dev/null)" = active ] && test -f /etc/systemd/oomd.conf.d/shturman.conf; then + pass "systemd-oomd active, политика oomd.conf.d/shturman.conf загружена" +else echo " WARN: systemd-oomd не active (нет PSI/пакета — проверь провижининг)"; fi +# eMMC-прокси: дельта записанных секторов loop-/data за окно простоя +src=$(findmnt -no SOURCE /data); dev=$(basename "$src") +if [ ! -e "/sys/block/$dev" ]; then dev=$(losetup -j /var/lib/shturman/data.img -O NAME --noheadings 2>/dev/null | tr -d ' ' | xargs -r basename); fi +read_w() { awk -v d="$dev" '$3==d {print $10}' /proc/diskstats; } +s0=$(read_w); echo " окно простоя ${IDLE_SECS}s (loop-dev=$dev)…"; sleep "$IDLE_SECS"; s1=$(read_w) +delta=$(( ${s1:-0} - ${s0:-0} )) +echo " eMMC-прокси: записано $delta секторов за ${IDLE_SECS}s (порог $EMMC_MAX_SECTORS, ~калибровка)" +[ "$delta" -le "$EMMC_MAX_SECTORS" ] || fail "eMMC: дельта $delta > порога $EMMC_MAX_SECTORS секторов" +pass "eMMC-прокси в пороге" -echo "== 8. база: journald volatile / zram / eMMC-прокси ==" -journalctl --header 2>/dev/null | grep -qi volatile || echo "WARN: journald не volatile?" -zramctl | grep -q zram0 || echo "WARN: zram0 не активен?" +# ---- персист-проба для POST-фазы ---- +info "персист-проба (для проверки после reboot)" +busctl --system call "$S_NAME" "$S_PATH" "$S_IFACE" Set sv "$PROBE_KEY" s "$PROBE_VAL" || fail "Settings.Set" +got=$(settings_get "$PROBE_KEY"); echo "$got" | grep -q "\"$PROBE_VAL\"" || fail "Set не применился (got: $got)" +sudo cp /etc/machine-id "$MID_BEFORE" # снимок до reboot +pass "Settings.$PROBE_KEY=$PROBE_VAL выставлен; machine-id снят в $MID_BEFORE" -echo "E2E OK (каркас; пункты 5–7 финализируются в части 2)" +echo; echo "E2E PRE OK ✅ (для полной приёмки — reboot + фаза post: just e2e)"