layer-wizard/lib/history/history_service.dart
Philip Peterson cb5dcdd666 Add automated tests for HistoryService instead of throwaway scripts
Gives HistoryDb an in-memory mode and reconcile() an injectable
deployments list so HistoryService can be exercised hermetically,
without touching the real history.db or shelling out to rpm-ostree.
Covers bootstrap, idempotent reconcile, install-then-reboot,
manual/detected changes, last_known_safe, and chain walking.

Also fixes the pre-existing widget boot-smoke test: startup now makes
a second real Process.run call (via HistoryService.reconcile), which
tipped flutter_test's fake-time zone into flagging a dangling Timer
from that real I/O. Wrapping the pump in tester.runAsync() is the
standard fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-07 13:30:07 -08:00

219 lines
7.2 KiB
Dart

import 'dart:convert';
import 'package:sqlite3/sqlite3.dart';
import '../ostree_service.dart';
import 'history_db.dart';
import 'layer_node.dart';
/// Our own append-only record of what's been layered onto the root
/// filesystem, since rpm-ostree/ostree only retain a couple of deployments
/// and prune the rest.
///
/// Every row is keyed on the ostree deployment `checksum` — the same string
/// `rpm-ostree status --json` reports — so [reconcile] can always tell
/// whether the currently-booted checksum is one we already know about (our
/// own install, or a previously-seen rollback target) or a brand new one
/// (a manual `rpm-ostree`/`bootc` change made outside this app, or an
/// upstream image update). Unknown checksums are recorded as `detected`
/// nodes rather than silently ignored, so the tree stays a true picture of
/// the running system instead of just "what Layer Wizard did".
class HistoryService {
static final HistoryService instance = HistoryService._();
HistoryService._();
Database get _db => HistoryDb.database();
/// Compares the live `rpm-ostree status` against our DB and brings
/// `current` (and, on first run, `last_known_safe`) up to date. Safe to
/// call on every launch — a no-op if nothing has changed since last time.
///
/// [deployments] overrides the live query — tests pass a fixed list so
/// they don't depend on `rpm-ostree` actually being present/accurate.
Future<LayerNode> reconcile({List<DeploymentInfo>? deployments}) async {
final db = _db;
final deps = deployments ?? await OstreeService.getAllDeployments();
final booted = deps.firstWhere(
(d) => d.booted,
orElse: () => deps.first,
);
LayerNode node;
final existing = _findByChecksum(db, booted.checksum);
if (existing == null) {
final parent = await getCurrent();
_insertNode(
db,
checksum: booted.checksum,
baseChecksum: booted.baseChecksum,
parentId: parent?.id,
action: 'detected',
version: booted.version,
containerImageRef: booted.containerImageRef,
requestedPackages: booted.requestedPackages,
source: 'detected',
appliedAt: DateTime.now(),
);
node = _findByChecksum(db, booted.checksum)!;
} else {
if (existing.appliedAt == null) {
db.execute(
'UPDATE nodes SET applied_at = ? WHERE id = ?',
[DateTime.now().toIso8601String(), existing.id],
);
}
node = _findByChecksum(db, booted.checksum)!;
}
_setPointer(db, 'current', node.id);
if (await getLastKnownSafe() == null) {
_setPointer(db, 'last_known_safe', node.id);
}
return node;
}
/// Records a Layer Wizard-initiated install. Called right after
/// `rpm-ostree install` succeeds, while the new checksum is still only
/// *staged* — `current` deliberately doesn't move until [reconcile] sees
/// it actually booted.
Future<LayerNode> recordInstall({
required String packageName,
required DeploymentInfo staged,
}) async {
final db = _db;
final existing = _findByChecksum(db, staged.checksum);
if (existing != null) return existing;
var parent = await getCurrent();
parent ??= await reconcile();
_insertNode(
db,
checksum: staged.checksum,
baseChecksum: staged.baseChecksum,
parentId: parent.id,
action: 'install',
packageName: packageName,
version: staged.version,
containerImageRef: staged.containerImageRef,
requestedPackages: staged.requestedPackages,
source: 'layer_wizard',
);
return _findByChecksum(db, staged.checksum)!;
}
/// Moves `last_known_safe` to wherever `current` points. Called when the
/// user confirms a post-reboot update is working.
Future<void> markCurrentAsSafe() async {
final current = await getCurrent();
if (current == null) return;
_setPointer(_db, 'last_known_safe', current.id);
}
Future<LayerNode?> getCurrent() => _getPointer('current');
Future<LayerNode?> getLastKnownSafe() => _getPointer('last_known_safe');
/// Root-to-current path through the tree, by walking `parent_id`.
Future<List<LayerNode>> getChainToCurrent() async => _chainTo(await getCurrent());
Future<List<LayerNode>> getAllNodes() async {
final rs = _db.select('SELECT * FROM nodes ORDER BY id');
return rs.map(_nodeFromRow).toList();
}
Future<List<LayerNode>> _chainTo(LayerNode? node) async {
final db = _db;
final chain = <LayerNode>[];
var cur = node;
while (cur != null) {
chain.add(cur);
final parentId = cur.parentId;
if (parentId == null) break;
final rs = db.select('SELECT * FROM nodes WHERE id = ?', [parentId]);
cur = rs.isEmpty ? null : _nodeFromRow(rs.first);
}
return chain.reversed.toList();
}
Future<LayerNode?> _getPointer(String name) async {
final rs = _db.select(
'SELECT nodes.* FROM pointers JOIN nodes ON nodes.id = pointers.node_id WHERE pointers.name = ?',
[name],
);
if (rs.isEmpty) return null;
return _nodeFromRow(rs.first);
}
void _setPointer(Database db, String name, int nodeId) {
db.execute(
'INSERT INTO pointers (name, node_id) VALUES (?, ?) '
'ON CONFLICT(name) DO UPDATE SET node_id = excluded.node_id',
[name, nodeId],
);
}
LayerNode? _findByChecksum(Database db, String checksum) {
final rs = db.select('SELECT * FROM nodes WHERE checksum = ?', [checksum]);
if (rs.isEmpty) return null;
return _nodeFromRow(rs.first);
}
int _insertNode(
Database db, {
required String checksum,
String? baseChecksum,
int? parentId,
required String action,
String? packageName,
String? version,
String? containerImageRef,
List<String> requestedPackages = const [],
required String source,
DateTime? appliedAt,
String? notes,
}) {
db.execute(
'''
INSERT INTO nodes (
checksum, base_checksum, parent_id, action, package_name, version,
container_image_ref, requested_packages, source, created_at, applied_at, notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
[
checksum,
baseChecksum,
parentId,
action,
packageName,
version,
containerImageRef,
jsonEncode(requestedPackages),
source,
DateTime.now().toIso8601String(),
appliedAt?.toIso8601String(),
notes,
],
);
return db.lastInsertRowId;
}
LayerNode _nodeFromRow(Row row) {
final requestedRaw = row['requested_packages'] as String?;
return LayerNode(
id: row['id'] as int,
checksum: row['checksum'] as String,
baseChecksum: row['base_checksum'] as String?,
parentId: row['parent_id'] as int?,
action: row['action'] as String,
packageName: row['package_name'] as String?,
version: row['version'] as String?,
containerImageRef: row['container_image_ref'] as String?,
requestedPackages:
requestedRaw == null ? const [] : (jsonDecode(requestedRaw) as List).cast<String>(),
source: row['source'] as String,
createdAt: DateTime.parse(row['created_at'] as String),
appliedAt: row['applied_at'] == null ? null : DateTime.parse(row['applied_at'] as String),
notes: row['notes'] as String?,
);
}
}