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 run(String executable, List arguments); } /// Local [io.Process.run] implementation. class LocalInfraProcessRunner implements InfraProcessRunner { const LocalInfraProcessRunner(); @override Future run( String executable, List 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 actions; /// Runtime command exit code. final int exitCode; /// Captured stdout. final String stdout; /// Captured stderr. final String stderr; /// Machine-readable result. Map 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 isInstalled(InfraServiceManifest manifest, InfraScope scope); /// Installs service runtime files. Future install( InfraServiceManifest manifest, { required InfraScope scope, required bool dryRun, }); /// Uninstalls service runtime files. Future uninstall( InfraServiceManifest manifest, { required InfraScope scope, required bool dryRun, }); /// Reloads runtime service discovery. Future reload({ required InfraScope scope, required bool dryRun, }); /// Starts the service. Future start( InfraServiceManifest manifest, { required InfraScope scope, required bool dryRun, }); /// Stops the service. Future stop( InfraServiceManifest manifest, { required InfraScope scope, required bool dryRun, }); /// Restarts the service. Future restart( InfraServiceManifest manifest, { required InfraScope scope, required bool dryRun, }); /// Reads service status. Future status( InfraServiceManifest manifest, { required InfraScope scope, }); /// Reads service logs. Future logs( InfraServiceManifest manifest, { required InfraScope scope, required bool follow, required int lines, }); /// Deletes runtime artifacts. Future 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 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? 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 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 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 install( InfraServiceManifest manifest, { required InfraScope scope, required bool dryRun, }) async { final actions = []; 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 uninstall( InfraServiceManifest manifest, { required InfraScope scope, required bool dryRun, }) async { final actions = []; 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 start( InfraServiceManifest manifest, { required InfraScope scope, required bool dryRun, }) async => _systemctl(scope, ['start', ...manifest.units], dryRun: dryRun); @override Future stop( InfraServiceManifest manifest, { required InfraScope scope, required bool dryRun, }) async => _systemctl(scope, ['stop', ...manifest.units], dryRun: dryRun); @override Future restart( InfraServiceManifest manifest, { required InfraScope scope, required bool dryRun, }) async => _systemctl(scope, ['restart', ...manifest.units], dryRun: dryRun); @override Future status( InfraServiceManifest manifest, { required InfraScope scope, }) async => _systemctl(scope, [ 'status', ...manifest.units, '--no-pager', ], dryRun: false); @override Future 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 delete( InfraServiceManifest manifest, { required InfraScope scope, required bool deleteContainer, required bool deleteData, required bool dryRun, }) async { final actions = []; final outputs = []; final errors = []; 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 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 _link( List 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 _deletePath( List actions, bool dryRun, String target, ) async { await _action( actions, dryRun, 'delete $target', () => _deleteIfExists(target), ); } Future _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 _action( List actions, bool dryRun, String description, Future Function() apply, ) async { actions.add(description); if (!dryRun) await apply(); } Future _systemctl( InfraScope scope, List 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 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'); }