layer-wizard/lib/ostree_service.dart
Philip Peterson c879ac73f2 Support installing a local .rpm file alongside name search
Adds a package-source toggle so the wizard can take a path to a
downloaded .rpm (verified via `rpm -qp` and resolved to its NEVRA)
in addition to the existing repo-name search flow, since both are
valid targets for `rpm-ostree install`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-10 11:54:59 -08:00

181 lines
7.2 KiB
Dart

import 'dart:convert';
import 'dart:io';
/// Thrown when `dnf` itself fails to run (e.g. a plugin that's enabled in
/// config but missing on disk) rather than genuinely finding no match.
/// Distinct from "package not found" so the UI can tell a broken search
/// engine apart from a bad package name.
class PackageEngineException implements Exception {
final String stderr;
PackageEngineException(this.stderr);
@override
String toString() => stderr.isEmpty ? 'dnf failed to run' : stderr;
}
class DeploymentInfo {
final String version;
final String checksum;
final String containerImageRef;
final String? baseChecksum;
final List<String> requestedPackages;
final bool booted;
final bool staged;
DeploymentInfo({
required this.version,
required this.checksum,
required this.containerImageRef,
this.baseChecksum,
this.requestedPackages = const [],
this.booted = false,
this.staged = false,
});
Map<String, dynamic> toJson() => {
'version': version,
'checksum': checksum,
'containerImageRef': containerImageRef,
'baseChecksum': baseChecksum,
'requestedPackages': requestedPackages,
'booted': booted,
'staged': staged,
};
factory DeploymentInfo.fromJson(Map<String, dynamic> json) => DeploymentInfo(
version: json['version'] as String,
checksum: json['checksum'] as String,
containerImageRef: json['containerImageRef'] as String,
baseChecksum: json['baseChecksum'] as String?,
requestedPackages:
(json['requestedPackages'] as List?)?.cast<String>() ?? const [],
booted: json['booted'] as bool? ?? false,
staged: json['staged'] as bool? ?? false,
);
static DeploymentInfo _fromStatusJson(Map<String, dynamic> d) => DeploymentInfo(
version: d['version'] as String? ?? 'unknown',
checksum: d['checksum'] as String? ?? 'unknown',
containerImageRef: d['container-image-reference'] as String? ?? 'unknown',
baseChecksum: d['base-checksum'] as String?,
requestedPackages: (d['requested-packages'] as List?)?.cast<String>() ?? const [],
booted: d['booted'] as bool? ?? false,
staged: d['staged'] as bool? ?? false,
);
}
class OstreeService {
/// Reads the currently booted deployment via `rpm-ostree status --json`
/// (unprivileged, read-only).
static Future<DeploymentInfo> getStatus() async {
final deployments = await getAllDeployments();
return deployments.firstWhere(
(d) => d.booted,
orElse: () => deployments.first,
);
}
/// Reads every deployment rpm-ostree currently knows about (booted,
/// staged, rollback), not just the booted one. Used to reconcile our own
/// history against reality and to pick up the freshly-staged checksum
/// right after an install, before any reboot has happened.
static Future<List<DeploymentInfo>> getAllDeployments() async {
final result = await Process.run('rpm-ostree', ['status', '--json']);
if (result.exitCode != 0) {
throw Exception('rpm-ostree status failed: ${result.stderr}');
}
final data = jsonDecode(result.stdout as String) as Map<String, dynamic>;
final deployments = data['deployments'] as List;
return deployments
.cast<Map<String, dynamic>>()
.map(DeploymentInfo._fromStatusJson)
.toList();
}
/// Unprivileged existence check so typos surface before escalating to
/// pkexec. Matches on exact name or name.arch, then falls back to
/// `--whatprovides` so virtual names like "vim" (satisfied by
/// vim-enhanced etc., never a package themselves) check out the same way
/// `rpm-ostree install` would actually resolve them. A nonzero exit means
/// dnf itself failed to run (e.g. a broken plugin) rather than "not
/// found", so that's thrown instead of silently reported as false.
static Future<bool> checkPackageExists(String name) async {
final byName = await Process.run('dnf', ['repoquery', name]);
if (byName.exitCode != 0) {
throw PackageEngineException((byName.stderr as String).trim());
}
if ((byName.stdout as String).trim().isNotEmpty) return true;
final byProvides = await Process.run('dnf', ['repoquery', '--whatprovides', name]);
if (byProvides.exitCode != 0) {
throw PackageEngineException((byProvides.stderr as String).trim());
}
return (byProvides.stdout as String).trim().isNotEmpty;
}
/// Verifies a local RPM file and resolves it to the NEVRA name
/// `rpm-ostree install` will actually layer. Unlike [checkPackageExists],
/// a query failure here really does mean "not a usable package" (a
/// corrupt download, wrong file type, etc.) rather than a broken search
/// engine, so this throws a plain [Exception] instead of
/// [PackageEngineException] — the caller doesn't need to distinguish an
/// engine failure for file-based installs.
static Future<String> inspectLocalRpm(String path) async {
if (!await File(path).exists()) {
throw Exception('No file found at "$path".');
}
final result = await Process.run(
'rpm',
['-qp', '--queryformat', '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}', path],
);
if (result.exitCode != 0) {
final stderr = (result.stderr as String).trim();
throw Exception(stderr.isEmpty ? 'Not a valid RPM package.' : stderr);
}
return (result.stdout as String).trim();
}
/// Permanently layers a package via `rpm-ostree install`. [target] is
/// either a repo package name or a local filesystem path to an RPM —
/// `rpm-ostree install` accepts both. Streams combined stdout+stderr
/// lines. Mirrors pkg_launcher's DnfService.installTransient pattern
/// (pkexec through the desktop PolicyKit agent).
static Future<int> installPackage(String target, void Function(String line) onLine) async {
final process = await Process.start('pkexec', ['rpm-ostree', 'install', '-y', target]);
process.stdout.transform(const SystemEncoding().decoder).listen((chunk) {
for (final line in chunk.split('\n')) {
if (line.isNotEmpty) onLine(line);
}
});
process.stderr.transform(const SystemEncoding().decoder).listen((chunk) {
for (final line in chunk.split('\n')) {
if (line.isNotEmpty) onLine(line);
}
});
return process.exitCode;
}
/// Rolls back to the previously booted deployment and reboots into it
/// immediately. Callers must warn the user before calling this: the
/// screen will go black and the machine will restart as part of this
/// call returning.
static Future<int> rollback(void Function(String line) onLine) async {
final process = await Process.start('pkexec', ['bootc', 'rollback', '--apply']);
process.stdout.transform(const SystemEncoding().decoder).listen((chunk) {
for (final line in chunk.split('\n')) {
if (line.isNotEmpty) onLine(line);
}
});
process.stderr.transform(const SystemEncoding().decoder).listen((chunk) {
for (final line in chunk.split('\n')) {
if (line.isNotEmpty) onLine(line);
}
});
return process.exitCode;
}
/// Reboots immediately via the desktop PolicyKit agent.
static Future<void> rebootNow() async {
await Process.start('pkexec', ['systemctl', 'reboot'], mode: ProcessStartMode.detached);
}
}