dew/packages/infra/lib/src/infra_runtime.dart

489 lines
13 KiB
Dart

import 'dart:io' as io;
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:path/path.dart' as p;
import 'package:podman/podman.dart' show PodmanClient;
import 'service_manifest.dart';
/// systemd scope for Quadlet units.
enum InfraScope {
/// User-level systemd and Podman Quadlet search paths.
user,
/// System-level systemd and Podman Quadlet search paths.
system;
/// Parses the CLI value.
static InfraScope parse(String value) => switch (value) {
'user' => user,
'system' => system,
_ => throw ArgumentError('Unknown infra scope "$value".'),
};
}
/// Process result returned by [InfraProcessRunner].
class InfraProcessResult {
const InfraProcessResult({
required this.exitCode,
this.stdout = '',
this.stderr = '',
});
/// Process exit code.
final int exitCode;
/// Captured stdout.
final String stdout;
/// Captured stderr.
final String stderr;
}
/// Runs local processes for runtime backends.
abstract interface class InfraProcessRunner {
/// Runs [executable] with [arguments].
Future<InfraProcessResult> run(String executable, List<String> arguments);
}
/// Local [io.Process.run] implementation.
class LocalInfraProcessRunner implements InfraProcessRunner {
const LocalInfraProcessRunner();
@override
Future<InfraProcessResult> run(
String executable,
List<String> arguments,
) async {
final result = await io.Process.run(executable, arguments);
return InfraProcessResult(
exitCode: result.exitCode,
stdout: '${result.stdout}',
stderr: '${result.stderr}',
);
}
}
/// Result of a runtime operation.
class InfraRuntimeResult {
const InfraRuntimeResult({
this.actions = const [],
this.exitCode = 0,
this.stdout = '',
this.stderr = '',
});
/// Actions performed or planned.
final List<String> actions;
/// Runtime command exit code.
final int exitCode;
/// Captured stdout.
final String stdout;
/// Captured stderr.
final String stderr;
/// Machine-readable result.
Map<String, Object?> toJson() => {
'actions': actions,
'exit_code': exitCode,
'stdout': stdout,
'stderr': stderr,
};
}
/// Runtime backend boundary used by `dew infra`.
abstract interface class ContainerRuntime {
/// Runtime kind handled by this backend.
InfraRuntimeKind get kind;
/// Returns true when the service's runtime files are installed.
Future<bool> isInstalled(InfraServiceManifest manifest, InfraScope scope);
/// Installs service runtime files.
Future<InfraRuntimeResult> install(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
});
/// Uninstalls service runtime files.
Future<InfraRuntimeResult> uninstall(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
});
/// Reloads runtime service discovery.
Future<InfraRuntimeResult> reload({
required InfraScope scope,
required bool dryRun,
});
/// Starts the service.
Future<InfraRuntimeResult> start(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
});
/// Stops the service.
Future<InfraRuntimeResult> stop(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
});
/// Restarts the service.
Future<InfraRuntimeResult> restart(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
});
/// Reads service status.
Future<InfraRuntimeResult> status(
InfraServiceManifest manifest, {
required InfraScope scope,
});
/// Reads service logs.
Future<InfraRuntimeResult> logs(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool follow,
required int lines,
});
/// Deletes runtime artifacts.
Future<InfraRuntimeResult> delete(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool deleteContainer,
required bool deleteData,
required bool dryRun,
});
}
/// Factory for runtime backends.
class ContainerRuntimeRegistry {
const ContainerRuntimeRegistry(this.runtimes);
/// Registered runtimes.
final List<ContainerRuntime> runtimes;
/// Finds the runtime for [kind].
ContainerRuntime forKind(InfraRuntimeKind kind) => runtimes.firstWhere(
(runtime) => runtime.kind == kind,
orElse: () => throw StateError('No runtime registered for ${kind.id}.'),
);
}
/// Podman Quadlet backend.
class PodmanQuadletRuntime implements ContainerRuntime {
PodmanQuadletRuntime({
this.fs = const LocalFileSystem(),
this.processRunner = const LocalInfraProcessRunner(),
Map<String, String>? environment,
PodmanClient Function()? podmanClientFactory,
}) : environment = environment ?? io.Platform.environment,
podmanClientFactory = podmanClientFactory ?? PodmanClient.new;
/// File system used for Quadlet file operations.
final FileSystem fs;
/// Process runner used for systemd, journalctl, and CLI cleanup commands.
final InfraProcessRunner processRunner;
/// Environment used for Quadlet search path resolution.
final Map<String, String> environment;
/// Podman API client factory reserved for backend operations.
final PodmanClient Function() podmanClientFactory;
@override
InfraRuntimeKind get kind => InfraRuntimeKind.podmanQuadlet;
/// Creates a Podman API client for future backend operations.
PodmanClient createPodmanClient() => podmanClientFactory();
@override
Future<bool> isInstalled(
InfraServiceManifest manifest,
InfraScope scope,
) async {
if (manifest.quadlets.isEmpty) return false;
for (final quadlet in manifest.quadlets) {
final target = _targetQuadletPath(quadlet, scope);
final exists =
await fs.link(target).exists() || await fs.file(target).exists();
if (!exists) return false;
}
return true;
}
@override
Future<InfraRuntimeResult> install(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
}) async {
final actions = <String>[];
final targetDir = quadletSearchPath(scope, environment: environment);
await _action(
actions,
dryRun,
'create $targetDir',
() => fs.directory(targetDir).create(recursive: true),
);
for (final quadlet in manifest.quadlets) {
await _link(
actions,
dryRun,
quadlet.filePath,
_targetQuadletPath(quadlet, scope),
);
final dropinsPath = quadlet.dropinsDirPath;
if (dropinsPath != null && await fs.directory(dropinsPath).exists()) {
final targetDropins = p.join(targetDir, p.basename(dropinsPath));
await _action(
actions,
dryRun,
'create $targetDropins',
() => fs.directory(targetDropins).create(recursive: true),
);
await for (final entity in fs.directory(dropinsPath).list()) {
if (entity is! File || p.extension(entity.path) != '.conf') continue;
await _link(
actions,
dryRun,
entity.path,
p.join(targetDropins, p.basename(entity.path)),
);
}
}
}
return InfraRuntimeResult(actions: actions);
}
@override
Future<InfraRuntimeResult> uninstall(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
}) async {
final actions = <String>[];
final targetDir = quadletSearchPath(scope, environment: environment);
for (final quadlet in manifest.quadlets) {
await _deletePath(actions, dryRun, _targetQuadletPath(quadlet, scope));
final dropinsPath = quadlet.dropinsDirPath;
if (dropinsPath != null) {
await _deletePath(
actions,
dryRun,
p.join(targetDir, p.basename(dropinsPath)),
);
}
}
return InfraRuntimeResult(actions: actions);
}
@override
Future<InfraRuntimeResult> start(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
}) async => _systemctl(scope, ['start', ...manifest.units], dryRun: dryRun);
@override
Future<InfraRuntimeResult> stop(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
}) async => _systemctl(scope, ['stop', ...manifest.units], dryRun: dryRun);
@override
Future<InfraRuntimeResult> restart(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
}) async => _systemctl(scope, ['restart', ...manifest.units], dryRun: dryRun);
@override
Future<InfraRuntimeResult> status(
InfraServiceManifest manifest, {
required InfraScope scope,
}) async => _systemctl(scope, [
'status',
...manifest.units,
'--no-pager',
], dryRun: false);
@override
Future<InfraRuntimeResult> logs(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool follow,
required int lines,
}) async {
final args = [
if (scope == InfraScope.user) '--user',
for (final unit in manifest.units) ...['-u', unit],
'-n',
'$lines',
if (follow) '-f',
];
final action = 'journalctl ${args.join(' ')}';
final result = await processRunner.run('journalctl', args);
return InfraRuntimeResult(
actions: [action],
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
);
}
@override
Future<InfraRuntimeResult> delete(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool deleteContainer,
required bool deleteData,
required bool dryRun,
}) async {
final actions = <String>[];
final outputs = <String>[];
final errors = <String>[];
var exitCode = 0;
if (deleteContainer) {
if (manifest.containerNames.isEmpty) {
actions.add('no container artifacts declared for ${manifest.id}');
}
for (final containerName in manifest.containerNames) {
final args = ['rm', '--ignore', '--force', containerName];
final action = 'podman ${args.join(' ')}';
actions.add(action);
if (!dryRun) {
final result = await processRunner.run('podman', args);
if (exitCode == 0) exitCode = result.exitCode;
if (result.stdout.trim().isNotEmpty) {
outputs.add(result.stdout.trim());
}
if (result.stderr.trim().isNotEmpty) {
errors.add(result.stderr.trim());
}
}
}
}
if (deleteData) {
actions.add('delete data artifacts for ${manifest.id}');
}
if (actions.isEmpty) {
actions.add('no runtime artifacts requested for ${manifest.id}');
}
return InfraRuntimeResult(
actions: actions,
exitCode: exitCode,
stdout: outputs.join('\n'),
stderr: errors.join('\n'),
);
}
@override
Future<InfraRuntimeResult> reload({
required InfraScope scope,
required bool dryRun,
}) => _systemctl(scope, ['daemon-reload'], dryRun: dryRun);
String _targetQuadletPath(InfraQuadletManifest quadlet, InfraScope scope) =>
p.join(
quadletSearchPath(scope, environment: environment),
p.basename(quadlet.file),
);
Future<void> _link(
List<String> actions,
bool dryRun,
String source,
String target,
) async {
await _action(actions, dryRun, 'link $source -> $target', () async {
await _deleteIfExists(target);
await fs.link(target).create(source, recursive: true);
});
}
Future<void> _deletePath(
List<String> actions,
bool dryRun,
String target,
) async {
await _action(
actions,
dryRun,
'delete $target',
() => _deleteIfExists(target),
);
}
Future<void> _deleteIfExists(String target) async {
final type = await fs.type(target, followLinks: false);
if (type == FileSystemEntityType.notFound) return;
if (type == FileSystemEntityType.directory) {
await fs.directory(target).delete(recursive: true);
} else if (type == FileSystemEntityType.link) {
await fs.link(target).delete();
} else {
await fs.file(target).delete();
}
}
Future<void> _action(
List<String> actions,
bool dryRun,
String description,
Future<void> Function() apply,
) async {
actions.add(description);
if (!dryRun) await apply();
}
Future<InfraRuntimeResult> _systemctl(
InfraScope scope,
List<String> arguments, {
required bool dryRun,
}) async {
final args = [if (scope == InfraScope.user) '--user', ...arguments];
final action = 'systemctl ${args.join(' ')}';
if (dryRun) return InfraRuntimeResult(actions: [action]);
final result = await processRunner.run('systemctl', args);
return InfraRuntimeResult(
actions: [action],
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
);
}
}
/// Resolves the Quadlet search path for [scope].
String quadletSearchPath(
InfraScope scope, {
Map<String, String> environment = const {},
}) {
if (scope == InfraScope.system) return '/etc/containers/systemd';
final xdgConfigHome = environment['XDG_CONFIG_HOME'];
if (xdgConfigHome != null && xdgConfigHome.isNotEmpty) {
return p.join(xdgConfigHome, 'containers', 'systemd');
}
final home = environment['HOME'] ?? environment['USERPROFILE'] ?? '';
return p.join(home.isEmpty ? '~' : home, '.config', 'containers', 'systemd');
}