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>
219 lines
7.2 KiB
Dart
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?,
|
|
);
|
|
}
|
|
}
|