From 95058f7f0433bb2133016d246d21c5b4c82e35e2 Mon Sep 17 00:00:00 2001 From: Chris Hendrickson Date: Mon, 4 May 2026 22:14:38 -0400 Subject: [PATCH] Add infra command surface --- .project/kanban/done/DEW-0029.md | 8 + README.md | 7 + docs/config.md | 3 + docs/features/infra.md | 72 ++ docs/index.md | 2 + packages/cli/bin/dew.dart | 2 + packages/cli/pubspec.yaml | 1 + packages/cli/test/cli_test.dart | 9 +- packages/infra/lib/dew_infra.dart | 20 + packages/infra/lib/src/dew_infra_base.dart | 692 +++++++++++++++++++ packages/infra/lib/src/infra_repository.dart | 244 +++++++ packages/infra/lib/src/infra_runtime.dart | 466 +++++++++++++ packages/infra/lib/src/service_manifest.dart | 198 ++++++ packages/infra/pubspec.yaml | 23 + packages/infra/test/dew_infra_test.dart | 186 +++++ pubspec.lock | 48 ++ pubspec.yaml | 1 + 17 files changed, 1980 insertions(+), 2 deletions(-) create mode 100644 .project/kanban/done/DEW-0029.md create mode 100644 docs/features/infra.md create mode 100644 packages/infra/lib/dew_infra.dart create mode 100644 packages/infra/lib/src/dew_infra_base.dart create mode 100644 packages/infra/lib/src/infra_repository.dart create mode 100644 packages/infra/lib/src/infra_runtime.dart create mode 100644 packages/infra/lib/src/service_manifest.dart create mode 100644 packages/infra/pubspec.yaml create mode 100644 packages/infra/test/dew_infra_test.dart diff --git a/.project/kanban/done/DEW-0029.md b/.project/kanban/done/DEW-0029.md new file mode 100644 index 0000000..e6ae614 --- /dev/null +++ b/.project/kanban/done/DEW-0029.md @@ -0,0 +1,8 @@ +--- +id: DEW-0029 +title: Add infra command surface +type: task +created: 2026-05-05T02:02:35.678734Z +--- + +Implement the initial dew infra command surface for project-local infrastructure services. Start with Quadlet/Podman operations while keeping runtime boundaries explicit so additional container runtimes can be added later. diff --git a/README.md b/README.md index 3127a2f..8dfe91b 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,12 @@ stats board config tui Tickets are stored as `.project/kanban//.md` files. Labels, milestones, typed bidirectional links, and inline comments are all first-class citizens. See the [Kanban documentation](./docs/features/kanban.md) for the full command reference. +### Infrastructure + +`dew infra` discovers services under `.project/infrastructure/services`, validates +their manifests and schemas, and manages Podman Quadlets through systemd. The +runtime boundary is explicit so other container backends can be added later. + ### Interactive TUI `dew kanban tui` opens a full Trello-style terminal board with three modes: @@ -60,6 +66,7 @@ Dew reads `.project/dew.yaml` for board columns, ticket types, ID prefix, and MC ## Documentation - [Full documentation index](./docs/index.md) +- [Infrastructure](./docs/features/infra.md) — service manifests, Quadlet install, lifecycle commands - [Kanban board](./docs/features/kanban.md) — CLI commands, TUI keybindings, ticket format - [MCP server](./docs/features/mcp.md) — AI agent integration - [Configuration reference](./docs/config.md) diff --git a/docs/config.md b/docs/config.md index bd8d8be..3498f61 100644 --- a/docs/config.md +++ b/docs/config.md @@ -13,6 +13,9 @@ your-project/ Path-like values in `dew.yaml` are resolved relative to `.project/dew.yaml` unless they are absolute (for example, paths under `dew.vault`). +Infrastructure services are not configured in `dew.yaml`; they are discovered +from `.project/infrastructure/services/*/metadata.toml`. + ## Full Schema ```yaml diff --git a/docs/features/infra.md b/docs/features/infra.md new file mode 100644 index 0000000..1ac32ce --- /dev/null +++ b/docs/features/infra.md @@ -0,0 +1,72 @@ +# Infrastructure + +`dew infra` manages project-local infrastructure services declared under +`.project/infrastructure`. + +The initial runtime backend is Podman Quadlets installed into systemd search +paths. The command surface is runtime-oriented rather than Podman-specific so +future backends can be added without changing project manifests or common CLI +workflows. + +## Layout + +```text +.project/infrastructure/ +└── services/ + └── postgres/ + ├── metadata.toml + ├── app_postgres.container + ├── app_postgres.container.d/ + ├── app_postgres.profiles.d/ + ├── configure.schema.json + ├── init.schema.json + └── config/ +``` + +## Manifest + +```toml +[service] +id = "postgres" +name = "PostgreSQL" +unit = "app_postgres.service" +container_name = "app_postgres" + +[runtime] +type = "podman-quadlet" + +[container] +file = "app_postgres.container" +dropins_dir = "app_postgres.container.d" +profiles_dir = "app_postgres.profiles.d" + +[schemas] +configure = "configure.schema.json" +init = "init.schema.json" +``` + +## Commands + +```bash +dew infra list +dew infra show postgres +dew infra validate --all +dew infra configure postgres schema +dew infra configure postgres show +dew infra configure postgres apply --file config.json --set port=5432 +dew infra init postgres schema +dew infra init postgres run --file init.json +dew infra install postgres +dew infra up postgres +dew infra status postgres +dew infra logs postgres --lines 200 +dew infra down postgres +dew infra delete postgres --container +``` + +Use `--dry-run` on mutating commands to print filesystem, systemctl, journalctl, +and podman actions without applying them. Use `--scope user` for the default +user systemd path or `--scope system` for `/etc/containers/systemd`. + +`dew infra up` installs missing Quadlet files, reloads systemd, then starts the +unit. diff --git a/docs/index.md b/docs/index.md index 487cd1a..ed7a140 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,6 +9,7 @@ Welcome to the documentation for the Dew project management tool! ### Features - [Kanban Board](./features/kanban.md) — Visualize and manage tasks in a column-based workflow +- [Infrastructure](./features/infra.md) — Manage local service manifests and Podman Quadlets - [MCP Server](./features/mcp.md) — AI agent integration via the Model Context Protocol ## Package Architecture @@ -19,6 +20,7 @@ Dew is structured as a Dart workspace with the following packages: | ----------------- | -------------------------------------------------------------------------------------------- | | `packages/cli` | The `dew` command-line tool. Wires all packages together at startup. | | `packages/core` | Shared foundation: `DewCommand`, `DewToolCommand` mixin, `CommandRegistry`, and `DewConfig`. | +| `packages/infra` | Infrastructure service discovery, validation, and runtime lifecycle commands. | | `packages/kanban` | Kanban board logic. Each command automatically registers itself as an MCP tool. | | `packages/mcp` | The MCP server. Collects tools from `CommandRegistry` and serves them over stdio. | diff --git a/packages/cli/bin/dew.dart b/packages/cli/bin/dew.dart index b033a90..90948a9 100644 --- a/packages/cli/bin/dew.dart +++ b/packages/cli/bin/dew.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:args/command_runner.dart'; import 'package:dew_core/dew_core.dart'; +import 'package:dew_infra/dew_infra.dart' as infra; import 'package:dew_kanban/dew_kanban.dart' as kanban; import 'package:dew_mcp/dew_mcp.dart' as mcp; import 'package:dew_vault/dew_vault.dart' as vault; @@ -9,6 +10,7 @@ import 'package:dew_vault/dew_vault.dart' as vault; Future main(List args) async { final commandRegistry = CommandRegistry(); + infra.registerCommands(commandRegistry); kanban.registerCommands(commandRegistry); vault.registerCommands(commandRegistry); mcp.registerCommands(commandRegistry); diff --git a/packages/cli/pubspec.yaml b/packages/cli/pubspec.yaml index 9a3f222..6eb47d1 100644 --- a/packages/cli/pubspec.yaml +++ b/packages/cli/pubspec.yaml @@ -11,6 +11,7 @@ environment: dependencies: args: ^2.7.0 dew_core: ^0.1.0 + dew_infra: ^0.1.0 dew_kanban: ^0.1.0 dew_vault: ^0.3.0 dew_mcp: ^0.1.0 diff --git a/packages/cli/test/cli_test.dart b/packages/cli/test/cli_test.dart index b0417b4..d175f4a 100644 --- a/packages/cli/test/cli_test.dart +++ b/packages/cli/test/cli_test.dart @@ -1,5 +1,6 @@ import 'package:args/command_runner.dart'; import 'package:dew_core/dew_core.dart'; +import 'package:dew_infra/dew_infra.dart' as infra; import 'package:dew_kanban/dew_kanban.dart' as kanban; import 'package:dew_mcp/dew_mcp.dart' as mcp; import 'package:dew_vault/dew_vault.dart' as vault; @@ -8,6 +9,7 @@ import 'package:test/test.dart'; /// Builds the same CommandRunner as bin/dew.dart without actually running it. CommandRunner buildRunner() { final commandRegistry = CommandRegistry(); + infra.registerCommands(commandRegistry); kanban.registerCommands(commandRegistry); vault.registerCommands(commandRegistry); mcp.registerCommands(commandRegistry); @@ -26,9 +28,12 @@ void main() { expect(buildRunner, returnsNormally); }); - test('has kanban, vault, init, and mcp commands registered', () { + test('has core package commands registered', () { final runner = buildRunner(); - expect(runner.commands.keys, containsAll(['kanban', 'vault', 'init', 'mcp'])); + expect( + runner.commands.keys, + containsAll(['infra', 'kanban', 'vault', 'init', 'mcp']), + ); }); test('--help flag does not throw', () async { diff --git a/packages/infra/lib/dew_infra.dart b/packages/infra/lib/dew_infra.dart new file mode 100644 index 0000000..035dd5e --- /dev/null +++ b/packages/infra/lib/dew_infra.dart @@ -0,0 +1,20 @@ +library; + +export 'src/dew_infra_base.dart'; +export 'src/infra_repository.dart'; +export 'src/infra_runtime.dart'; +export 'src/service_manifest.dart'; + +import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; + +import 'src/dew_infra_base.dart'; + +/// Registers all infrastructure commands into [registry]. +void registerCommands( + CommandRegistry registry, { + FileSystem fs = const LocalFileSystem(), +}) { + registry.register(InfraCommand(fs: fs)); +} diff --git a/packages/infra/lib/src/dew_infra_base.dart b/packages/infra/lib/src/dew_infra_base.dart new file mode 100644 index 0000000..7e39f48 --- /dev/null +++ b/packages/infra/lib/src/dew_infra_base.dart @@ -0,0 +1,692 @@ +import 'dart:convert'; +import 'dart:io' as io; + +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:json_schema/json_schema.dart'; +import 'package:path/path.dart' as p; + +import 'infra_repository.dart'; +import 'infra_runtime.dart'; +import 'service_manifest.dart'; + +/// Root `dew infra` command. +class InfraCommand extends DewCommand { + InfraCommand({ + FileSystem fs = const LocalFileSystem(), + ContainerRuntimeRegistry? runtimeRegistry, + }) : runtimeRegistry = + runtimeRegistry ?? + ContainerRuntimeRegistry([PodmanQuadletRuntime(fs: fs)]) { + argParser + ..addOption( + 'project', + help: + 'Project root. Defaults to walking upward until .project/dew.yaml is found.', + ) + ..addOption( + 'infra-dir', + help: 'Infrastructure root. Defaults to .project/infrastructure.', + ) + ..addFlag('json', negatable: false, help: 'Machine-readable output.') + ..addFlag( + 'dry-run', + negatable: false, + help: 'Print intended actions without applying them.', + ) + ..addFlag( + 'yes', + negatable: false, + help: 'Skip confirmation for operations that change state.', + ) + ..addOption( + 'scope', + allowed: ['user', 'system'], + defaultsTo: 'user', + help: 'Quadlet/systemd scope.', + ); + + addSubcommand( + InfraListCommand(fs: fs, runtimeRegistry: this.runtimeRegistry), + ); + addSubcommand( + InfraShowCommand(fs: fs, runtimeRegistry: this.runtimeRegistry), + ); + addSubcommand( + InfraValidateCommand(fs: fs, runtimeRegistry: this.runtimeRegistry), + ); + addSubcommand( + InfraConfigureCommand(fs: fs, runtimeRegistry: this.runtimeRegistry), + ); + addSubcommand( + InfraInitCommand(fs: fs, runtimeRegistry: this.runtimeRegistry), + ); + for (final commandName in [ + 'install', + 'uninstall', + 'up', + 'down', + 'restart', + 'status', + ]) { + addSubcommand( + InfraRuntimeCommand( + commandName, + fs: fs, + runtimeRegistry: this.runtimeRegistry, + ), + ); + } + addSubcommand( + InfraLogsCommand(fs: fs, runtimeRegistry: this.runtimeRegistry), + ); + addSubcommand( + InfraDeleteCommand(fs: fs, runtimeRegistry: this.runtimeRegistry), + ); + } + + final ContainerRuntimeRegistry runtimeRegistry; + + @override + final String name = 'infra'; + + @override + final String description = 'Manage project infrastructure services.'; + + @override + Future run() async => printUsage(); +} + +abstract class _InfraSubcommand extends DewCommand { + _InfraSubcommand({required this.fs, required this.runtimeRegistry}); + + final FileSystem fs; + final ContainerRuntimeRegistry runtimeRegistry; + + Future<_InfraEnvironment> _environment() async { + final options = _infraOptions(); + final projectArg = options['project'] as String?; + final projectDirectory = projectArg == null + ? null + : fs.directory(_resolveFromCwd(projectArg)); + final projectContext = await ProjectContext.find( + fs: fs, + from: projectDirectory, + ); + final infraArg = options['infra-dir'] as String?; + final infraDir = infraArg == null + ? p.join(projectContext.root, '.project', 'infrastructure') + : _resolveProjectPath(projectContext.root, infraArg); + return _InfraEnvironment( + projectContext: projectContext, + options: options, + repository: InfraRepository(infraDir: infraDir, fs: fs), + validator: InfraValidator(fs: fs), + runtimeRegistry: runtimeRegistry, + scope: InfraScope.parse(options['scope'] as String), + json: options['json'] as bool, + dryRun: options['dry-run'] as bool, + yes: options['yes'] as bool, + ); + } + + ArgResults _infraOptions() => (parent as InfraCommand).argResults!; + + String _requiredServiceArg([String? usage]) { + final rest = argResults?.rest ?? const []; + if (rest.isEmpty) { + usageException(usage ?? 'Missing service.'); + } + return rest.first; + } + + String _resolveFromCwd(String value) { + if (p.isAbsolute(value)) return p.normalize(value); + return p.normalize(p.join(fs.currentDirectory.path, value)); + } + + String _resolveProjectPath(String root, String value) { + if (p.isAbsolute(value)) return p.normalize(value); + return p.normalize(p.join(root, value)); + } +} + +class _InfraEnvironment { + const _InfraEnvironment({ + required this.projectContext, + required this.options, + required this.repository, + required this.validator, + required this.runtimeRegistry, + required this.scope, + required this.json, + required this.dryRun, + required this.yes, + }); + + final ProjectContext projectContext; + final ArgResults options; + final InfraRepository repository; + final InfraValidator validator; + final ContainerRuntimeRegistry runtimeRegistry; + final InfraScope scope; + final bool json; + final bool dryRun; + final bool yes; + + ContainerRuntime runtimeFor(InfraServiceManifest manifest) => + runtimeRegistry.forKind(manifest.runtime); +} + +class InfraListCommand extends _InfraSubcommand { + InfraListCommand({required super.fs, required super.runtimeRegistry}); + + @override + final String name = 'list'; + + @override + final String description = 'List infrastructure services.'; + + @override + Future run() async { + final env = await _environment(); + final manifests = await env.repository.list(); + if (env.json) { + print( + jsonEncode(manifests.map((manifest) => manifest.toJson()).toList()), + ); + return; + } + if (manifests.isEmpty) { + print('No infrastructure services found.'); + return; + } + for (final manifest in manifests) { + print('${manifest.id}\t${manifest.name}\t${manifest.unit}'); + } + } +} + +class InfraShowCommand extends _InfraSubcommand { + InfraShowCommand({required super.fs, required super.runtimeRegistry}); + + @override + final String name = 'show'; + + @override + final String description = 'Show service manifest and runtime details.'; + + @override + Future run() async { + final service = _requiredServiceArg('Usage: dew infra show .'); + final env = await _environment(); + final manifest = await env.repository.get(service); + final runtime = env.runtimeFor(manifest); + final installed = await runtime.isInstalled(manifest, env.scope); + final details = { + ...manifest.toJson(), + 'installed': installed, + 'install_target': quadletSearchPath( + env.scope, + environment: io.Platform.environment, + ), + 'scope': env.scope.name, + }; + if (env.json) { + print(jsonEncode(details)); + return; + } + for (final entry in details.entries) { + if (entry.value == null) continue; + print('${entry.key}: ${entry.value}'); + } + } +} + +class InfraValidateCommand extends _InfraSubcommand { + InfraValidateCommand({required super.fs, required super.runtimeRegistry}) { + argParser.addFlag('all', negatable: false, help: 'Validate all services.'); + } + + @override + final String name = 'validate'; + + @override + final String description = 'Validate service manifests and referenced files.'; + + @override + Future run() async { + final env = await _environment(); + final rest = argResults?.rest ?? const []; + final target = rest.isEmpty ? null : rest.first; + final validateAll = + (argResults?['all'] as bool? ?? false) || target == null; + final manifests = validateAll + ? await env.repository.list() + : [await env.repository.get(target)]; + final issues = []; + for (final manifest in manifests) { + issues.addAll(await env.validator.validate(manifest)); + } + + if (env.json) { + print( + jsonEncode({ + 'valid': issues.isEmpty, + 'issues': issues.map((issue) => issue.toJson()).toList(), + }), + ); + } else if (issues.isEmpty) { + print('Infrastructure manifests are valid.'); + } else { + for (final issue in issues) { + print(issue); + } + } + if (issues.isNotEmpty) io.exitCode = 1; + } +} + +class InfraConfigureCommand extends _InfraSubcommand { + InfraConfigureCommand({required super.fs, required super.runtimeRegistry}) { + argParser + ..addOption('file', help: 'JSON configuration file for apply.') + ..addMultiOption('set', help: 'Set a dotted configuration key.'); + } + + @override + final String name = 'configure'; + + @override + final String description = + 'Inspect or apply service configuration schema values.'; + + @override + Future run() async { + final rest = argResults?.rest ?? const []; + if (rest.isEmpty) { + usageException( + 'Usage: dew infra configure [schema|show|apply].', + ); + } + final env = await _environment(); + final manifest = await env.repository.get(rest.first); + final action = rest.length > 1 ? rest[1] : 'tui'; + switch (action) { + case 'schema': + await _printSchema(env, manifest.configureSchemaPath); + case 'show': + await _printPayload(env, manifest.activeConfigurePath); + case 'apply': + await _applyPayload( + env, + schemaPath: manifest.configureSchemaPath, + outputPath: manifest.activeConfigurePath, + ); + case 'tui': + usageException( + 'Interactive schema editor is not implemented yet. ' + 'Use "dew infra configure ${manifest.id} schema|show|apply".', + ); + default: + usageException('Unknown configure action "$action".'); + } + } +} + +class InfraInitCommand extends _InfraSubcommand { + InfraInitCommand({required super.fs, required super.runtimeRegistry}) { + argParser + ..addOption('file', help: 'JSON initialization file for run.') + ..addMultiOption('set', help: 'Set a dotted initialization key.'); + } + + @override + final String name = 'init'; + + @override + final String description = 'Inspect or run service initialization options.'; + + @override + Future run() async { + final rest = argResults?.rest ?? const []; + if (rest.isEmpty) { + usageException('Usage: dew infra init [schema|run].'); + } + final env = await _environment(); + final manifest = await env.repository.get(rest.first); + final action = rest.length > 1 ? rest[1] : 'tui'; + switch (action) { + case 'schema': + await _printSchema(env, manifest.initSchemaPath); + case 'run': + await _applyPayload( + env, + schemaPath: manifest.initSchemaPath, + outputPath: manifest.activeInitPath, + ); + case 'tui': + usageException( + 'Interactive schema editor is not implemented yet. ' + 'Use "dew infra init ${manifest.id} schema|run".', + ); + default: + usageException('Unknown init action "$action".'); + } + } +} + +class InfraRuntimeCommand extends _InfraSubcommand { + InfraRuntimeCommand( + this.name, { + required super.fs, + required super.runtimeRegistry, + }) { + argParser.addFlag('all', negatable: false, help: 'Apply to all services.'); + } + + @override + final String name; + + @override + String get description => switch (name) { + 'install' => 'Install service runtime files.', + 'uninstall' => 'Uninstall service runtime files.', + 'up' => 'Install, reload, and start services.', + 'down' => 'Stop services.', + 'restart' => 'Restart services.', + 'status' => 'Show service status.', + _ => 'Manage infrastructure services.', + }; + + @override + Future run() async { + final env = await _environment(); + final manifests = await _targetServices(env, allowAll: true); + final results = >[]; + for (final manifest in manifests) { + final runtime = env.runtimeFor(manifest); + final result = await _runRuntimeCommand(env, manifest, runtime); + _printRuntimeResult(env, manifest, result); + results.add({'service': manifest.id, ...result.toJson()}); + } + if (env.json) print(jsonEncode(results)); + } + + Future _runRuntimeCommand( + _InfraEnvironment env, + InfraServiceManifest manifest, + ContainerRuntime runtime, + ) async { + switch (name) { + case 'install': + return runtime.install(manifest, scope: env.scope, dryRun: env.dryRun); + case 'uninstall': + return runtime.uninstall( + manifest, + scope: env.scope, + dryRun: env.dryRun, + ); + case 'up': + final results = []; + if (!await runtime.isInstalled(manifest, env.scope)) { + results.add( + await runtime.install( + manifest, + scope: env.scope, + dryRun: env.dryRun, + ), + ); + } + results.add(await runtime.reload(scope: env.scope, dryRun: env.dryRun)); + results.add( + await runtime.start(manifest, scope: env.scope, dryRun: env.dryRun), + ); + return _combineRuntimeResults(results); + case 'down': + return runtime.stop(manifest, scope: env.scope, dryRun: env.dryRun); + case 'restart': + return runtime.restart(manifest, scope: env.scope, dryRun: env.dryRun); + case 'status': + return runtime.status(manifest, scope: env.scope); + default: + throw StateError('Unknown runtime command $name.'); + } + } +} + +class InfraLogsCommand extends _InfraSubcommand { + InfraLogsCommand({required super.fs, required super.runtimeRegistry}) { + argParser + ..addFlag('follow', abbr: 'f', negatable: false, help: 'Follow logs.') + ..addOption('lines', defaultsTo: '200', help: 'Number of log lines.'); + } + + @override + final String name = 'logs'; + + @override + final String description = 'Show service logs.'; + + @override + Future run() async { + final env = await _environment(); + final manifest = await env.repository.get( + _requiredServiceArg('Usage: dew infra logs .'), + ); + final lines = int.tryParse(argResults?['lines'] as String? ?? '') ?? 200; + final result = await env + .runtimeFor(manifest) + .logs( + manifest, + scope: env.scope, + follow: argResults?['follow'] as bool? ?? false, + lines: lines, + ); + _printRuntimeResult(env, manifest, result); + if (env.json) { + print(jsonEncode({'service': manifest.id, ...result.toJson()})); + } + } +} + +class InfraDeleteCommand extends _InfraSubcommand { + InfraDeleteCommand({required super.fs, required super.runtimeRegistry}) { + argParser + ..addFlag( + 'container', + negatable: false, + help: 'Delete the named container runtime artifact.', + ) + ..addFlag( + 'data', + negatable: false, + help: 'Delete service data artifacts. Requires --yes.', + ) + ..addFlag('all', negatable: false, help: 'Apply to all services.'); + } + + @override + final String name = 'delete'; + + @override + final String description = 'Delete service runtime artifacts.'; + + @override + Future run() async { + final env = await _environment(); + final deleteData = argResults?['data'] as bool? ?? false; + if (deleteData && !env.yes) { + usageException('--data requires --yes.'); + } + final manifests = await _targetServices(env, allowAll: true); + final results = >[]; + for (final manifest in manifests) { + final result = await env + .runtimeFor(manifest) + .delete( + manifest, + scope: env.scope, + deleteContainer: argResults?['container'] as bool? ?? false, + deleteData: deleteData, + dryRun: env.dryRun, + ); + _printRuntimeResult(env, manifest, result); + results.add({'service': manifest.id, ...result.toJson()}); + } + if (env.json) print(jsonEncode(results)); + } +} + +Future> _targetServices( + _InfraEnvironment env, { + required bool allowAll, +}) async { + final all = (env.options.command?['all'] as bool?) ?? false; + final rest = env.options.command?.rest ?? const []; + if (all) return env.repository.list(); + if (rest.isEmpty) { + throw UsageException('Missing service. Use a service id or --all.', ''); + } + return [await env.repository.get(rest.first)]; +} + +Future _printSchema(_InfraEnvironment env, String? schemaPath) async { + if (schemaPath == null) throw ArgumentError('No schema path is declared.'); + final file = env.repository.fs.file(schemaPath); + if (!await file.exists()) { + throw ArgumentError('Schema not found: $schemaPath'); + } + print(await file.readAsString()); +} + +Future _printPayload(_InfraEnvironment env, String path) async { + final file = env.repository.fs.file(path); + if (!await file.exists()) { + if (env.json) { + print(jsonEncode({})); + } else { + print('No active configuration found at $path.'); + } + return; + } + print(await file.readAsString()); +} + +Future _applyPayload( + _InfraEnvironment env, { + required String? schemaPath, + required String outputPath, +}) async { + final payload = {}; + final command = env.options.command!; + final filePath = command['file'] as String?; + if (filePath != null) { + payload.addAll(_readJsonObject(env, _resolveAgainstProject(env, filePath))); + } + for (final assignment in command['set'] as List? ?? const []) { + _applyAssignment(payload, assignment); + } + await _validatePayload(env, schemaPath, payload); + if (env.dryRun) { + print('Would write $outputPath'); + return; + } + final file = env.repository.fs.file(outputPath); + await file.parent.create(recursive: true); + await file.writeAsString(const JsonEncoder.withIndent(' ').convert(payload)); + if (env.json) { + print(jsonEncode({'path': outputPath, 'config': payload})); + } else { + print('Wrote $outputPath'); + } +} + +Map _readJsonObject(_InfraEnvironment env, String path) { + final file = env.repository.fs.file(path); + final decoded = jsonDecode(file.readAsStringSync()); + if (decoded is Map) { + return decoded.map((key, value) => MapEntry('$key', value)); + } + throw FormatException('Expected JSON object in $path.'); +} + +Future _validatePayload( + _InfraEnvironment env, + String? schemaPath, + Map payload, +) async { + if (schemaPath == null) return; + final schemaFile = env.repository.fs.file(schemaPath); + if (!await schemaFile.exists()) return; + final decoded = jsonDecode(await schemaFile.readAsString()); + final result = JsonSchema.create(decoded).validate(payload); + if (!result.isValid) { + throw FormatException(result.errors.map((error) => '$error').join('\n')); + } +} + +void _applyAssignment(Map payload, String assignment) { + final index = assignment.indexOf('='); + if (index <= 0) { + throw FormatException('Expected key=value assignment, got "$assignment".'); + } + final key = assignment.substring(0, index); + final value = _parseValue(assignment.substring(index + 1)); + var current = payload; + final parts = key.split('.'); + for (final part in parts.take(parts.length - 1)) { + current = + current.putIfAbsent(part, () => {}) + as Map; + } + current[parts.last] = value; +} + +Object? _parseValue(String value) { + if (value == 'null') return null; + if (value == 'true') return true; + if (value == 'false') return false; + return int.tryParse(value) ?? double.tryParse(value) ?? value; +} + +String _resolveAgainstProject(_InfraEnvironment env, String value) { + if (p.isAbsolute(value)) return p.normalize(value); + return p.normalize(p.join(env.projectContext.root, value)); +} + +void _printRuntimeResult( + _InfraEnvironment env, + InfraServiceManifest manifest, + InfraRuntimeResult result, +) { + if (env.json) return; + for (final action in result.actions) { + print('${manifest.id}: $action'); + } + if (result.stdout.trim().isNotEmpty) print(result.stdout.trim()); + if (result.stderr.trim().isNotEmpty) io.stderr.writeln(result.stderr.trim()); + if (result.exitCode != 0) io.exitCode = result.exitCode; +} + +InfraRuntimeResult _combineRuntimeResults(List results) { + final actions = []; + final stdout = []; + final stderr = []; + var exitCode = 0; + for (final result in results) { + actions.addAll(result.actions); + if (result.stdout.trim().isNotEmpty) stdout.add(result.stdout.trim()); + if (result.stderr.trim().isNotEmpty) stderr.add(result.stderr.trim()); + if (result.exitCode != 0) exitCode = result.exitCode; + } + return InfraRuntimeResult( + actions: actions, + exitCode: exitCode, + stdout: stdout.join('\n'), + stderr: stderr.join('\n'), + ); +} diff --git a/packages/infra/lib/src/infra_repository.dart b/packages/infra/lib/src/infra_repository.dart new file mode 100644 index 0000000..1e6bcc5 --- /dev/null +++ b/packages/infra/lib/src/infra_repository.dart @@ -0,0 +1,244 @@ +import 'dart:convert'; + +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:json_schema/json_schema.dart'; +import 'package:path/path.dart' as p; + +import 'service_manifest.dart'; + +/// Reads infrastructure manifests from `.project/infrastructure`. +class InfraRepository { + const InfraRepository({ + required this.infraDir, + this.fs = const LocalFileSystem(), + }); + + /// Absolute path to the infrastructure root. + final String infraDir; + + /// File system abstraction for tests and non-local callers. + final FileSystem fs; + + /// Absolute path to the service directory root. + String get servicesDir => p.join(infraDir, 'services'); + + /// Finds all service manifests below `services/*/metadata.toml`. + Future> list() async { + final root = fs.directory(servicesDir); + if (!await root.exists()) return const []; + + final manifests = []; + await for (final entity in root.list()) { + if (entity is! Directory) continue; + final metadata = fs.file(p.join(entity.path, 'metadata.toml')); + if (!await metadata.exists()) continue; + manifests.add( + await loadFromMetadataPath(metadata.path, serviceDir: entity.path), + ); + } + manifests.sort((a, b) => a.id.compareTo(b.id)); + return manifests; + } + + /// Loads a single service by command-line [id]. + Future get(String id) async { + final manifest = await find(id); + if (manifest == null) { + throw ArgumentError('Infrastructure service "$id" not found.'); + } + return manifest; + } + + /// Loads a single service by command-line [id], returning null if absent. + Future find(String id) async { + final metadataPath = p.join(servicesDir, id, 'metadata.toml'); + final file = fs.file(metadataPath); + if (!await file.exists()) return null; + return loadFromMetadataPath( + metadataPath, + serviceDir: p.dirname(metadataPath), + ); + } + + /// Parses the manifest at [metadataPath]. + Future loadFromMetadataPath( + String metadataPath, { + required String serviceDir, + }) async { + final file = fs.file(metadataPath); + return InfraServiceManifest.parse( + contents: await file.readAsString(), + serviceDir: p.normalize(serviceDir), + metadataPath: p.normalize(metadataPath), + ); + } +} + +/// A validation issue found in an infrastructure manifest or referenced file. +class InfraValidationIssue { + const InfraValidationIssue({ + required this.serviceId, + required this.path, + required this.message, + }); + + /// Service id, or the best available directory name when parsing failed. + final String serviceId; + + /// Path where the issue was discovered. + final String path; + + /// Human-readable issue. + final String message; + + /// Machine-readable issue. + Map toJson() => { + 'service': serviceId, + 'path': path, + 'message': message, + }; + + @override + String toString() => '$serviceId: $message ($path)'; +} + +/// Validates service manifests and their referenced files. +class InfraValidator { + const InfraValidator({this.fs = const LocalFileSystem()}); + + /// File system abstraction for tests and non-local callers. + final FileSystem fs; + + /// Validates [manifest]. + Future> validate( + InfraServiceManifest manifest, + ) async { + final issues = []; + void issue(String path, String message) => issues.add( + InfraValidationIssue( + serviceId: manifest.id, + path: path, + message: message, + ), + ); + + final dirId = p.basename(manifest.serviceDir); + if (manifest.id != dirId) { + issue( + manifest.metadataPath, + 'service.id "${manifest.id}" must match directory "$dirId".', + ); + } + if (!manifest.unit.endsWith('.service')) { + issue(manifest.metadataPath, 'service.unit must end with .service.'); + } + if (manifest.unit != manifest.expectedUnit) { + issue( + manifest.metadataPath, + 'service.unit "${manifest.unit}" must match container file unit ' + '"${manifest.expectedUnit}".', + ); + } + + await _requireFile(manifest, manifest.containerFilePath, issues); + await _requireDirectoryIfDeclared( + manifest, + manifest.dropinsDirPath, + issues, + ); + await _requireDirectoryIfDeclared( + manifest, + manifest.profilesDirPath, + issues, + ); + await _validateJsonSchema( + manifest, + label: 'configure schema', + path: manifest.configureSchemaPath, + issues: issues, + ); + await _validateJsonSchema( + manifest, + label: 'init schema', + path: manifest.initSchemaPath, + issues: issues, + ); + + return issues; + } + + Future _requireFile( + InfraServiceManifest manifest, + String path, + List issues, + ) async { + if (!await fs.file(path).exists()) { + issues.add( + InfraValidationIssue( + serviceId: manifest.id, + path: path, + message: 'Referenced file does not exist.', + ), + ); + } + } + + Future _requireDirectoryIfDeclared( + InfraServiceManifest manifest, + String? path, + List issues, + ) async { + if (path == null) return; + if (!await fs.directory(path).exists()) { + issues.add( + InfraValidationIssue( + serviceId: manifest.id, + path: path, + message: 'Referenced directory does not exist.', + ), + ); + } + } + + Future _validateJsonSchema( + InfraServiceManifest manifest, { + required String label, + required String? path, + required List issues, + }) async { + if (path == null) { + issues.add( + InfraValidationIssue( + serviceId: manifest.id, + path: manifest.metadataPath, + message: 'Missing $label path.', + ), + ); + return; + } + final file = fs.file(path); + if (!await file.exists()) { + issues.add( + InfraValidationIssue( + serviceId: manifest.id, + path: path, + message: 'Referenced $label does not exist.', + ), + ); + return; + } + try { + final decoded = jsonDecode(await file.readAsString()); + JsonSchema.create(decoded); + } catch (error) { + issues.add( + InfraValidationIssue( + serviceId: manifest.id, + path: path, + message: 'Invalid $label: $error', + ), + ); + } + } +} diff --git a/packages/infra/lib/src/infra_runtime.dart b/packages/infra/lib/src/infra_runtime.dart new file mode 100644 index 0000000..4f3bfcd --- /dev/null +++ b/packages/infra/lib/src/infra_runtime.dart @@ -0,0 +1,466 @@ +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 => + await fs.link(_targetContainerPath(manifest, scope)).exists() || + await fs.file(_targetContainerPath(manifest, scope)).exists(); + + @override + Future install( + InfraServiceManifest manifest, { + required InfraScope scope, + required bool dryRun, + }) async { + final actions = []; + final targetDir = quadletSearchPath(scope, environment: environment); + final targetFile = _targetContainerPath(manifest, scope); + await _action( + actions, + dryRun, + 'create $targetDir', + () => fs.directory(targetDir).create(recursive: true), + ); + await _link(actions, dryRun, manifest.containerFilePath, targetFile); + + final dropinsPath = manifest.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) 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 = []; + await _deletePath(actions, dryRun, _targetContainerPath(manifest, scope)); + final dropinsPath = manifest.dropinsDirPath; + if (dropinsPath != null) { + await _deletePath( + actions, + dryRun, + p.join( + quadletSearchPath(scope, environment: environment), + p.basename(dropinsPath), + ), + ); + } + return InfraRuntimeResult(actions: actions); + } + + @override + Future start( + InfraServiceManifest manifest, { + required InfraScope scope, + required bool dryRun, + }) async => _systemctl(scope, ['start', manifest.unit], dryRun: dryRun); + + @override + Future stop( + InfraServiceManifest manifest, { + required InfraScope scope, + required bool dryRun, + }) async => _systemctl(scope, ['stop', manifest.unit], dryRun: dryRun); + + @override + Future restart( + InfraServiceManifest manifest, { + required InfraScope scope, + required bool dryRun, + }) async => _systemctl(scope, ['restart', manifest.unit], dryRun: dryRun); + + @override + Future status( + InfraServiceManifest manifest, { + required InfraScope scope, + }) async => + _systemctl(scope, ['status', manifest.unit, '--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', + '-u', + manifest.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) { + final args = ['rm', '--ignore', '--force', manifest.containerName]; + final action = 'podman ${args.join(' ')}'; + actions.add(action); + if (!dryRun) { + final result = await processRunner.run('podman', args); + 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 _targetContainerPath( + InfraServiceManifest manifest, + InfraScope scope, + ) => p.join( + quadletSearchPath(scope, environment: environment), + p.basename(manifest.containerFile), + ); + + 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'); +} diff --git a/packages/infra/lib/src/service_manifest.dart b/packages/infra/lib/src/service_manifest.dart new file mode 100644 index 0000000..c284f68 --- /dev/null +++ b/packages/infra/lib/src/service_manifest.dart @@ -0,0 +1,198 @@ +import 'package:path/path.dart' as p; +import 'package:toml/toml.dart'; + +/// Supported infrastructure runtime backends. +/// +/// Dew starts with Podman Quadlets, but commands depend on this enum instead of +/// directly depending on Quadlet paths so additional runtimes can be introduced +/// without changing the command contract. +enum InfraRuntimeKind { + /// Podman Quadlet files consumed by systemd. + podmanQuadlet('podman-quadlet'); + + const InfraRuntimeKind(this.id); + + /// Stable manifest identifier. + final String id; + + static InfraRuntimeKind fromManifestValue(String? value) { + final normalized = value ?? podmanQuadlet.id; + return values.firstWhere( + (kind) => kind.id == normalized, + orElse: () => throw FormatException( + 'Unsupported infrastructure runtime "$normalized".', + ), + ); + } +} + +/// Infrastructure service metadata loaded from `metadata.toml`. +class InfraServiceManifest { + const InfraServiceManifest({ + required this.id, + required this.name, + required this.unit, + required this.containerName, + required this.runtime, + required this.serviceDir, + required this.metadataPath, + required this.containerFile, + this.dropinsDir, + this.profilesDir, + this.configureSchema, + this.initSchema, + }); + + /// Stable service identifier used on the command line. + final String id; + + /// Human-friendly display name. + final String name; + + /// systemd unit name generated by the runtime backend. + final String unit; + + /// Container name used for runtime cleanup/status operations. + final String containerName; + + /// Runtime backend declared by the manifest. + final InfraRuntimeKind runtime; + + /// Absolute path to the service directory. + final String serviceDir; + + /// Absolute path to `metadata.toml`. + final String metadataPath; + + /// Relative path to the primary Quadlet/container definition. + final String containerFile; + + /// Optional relative path to the Quadlet drop-ins directory. + final String? dropinsDir; + + /// Optional relative path to profile drop-ins. + final String? profilesDir; + + /// Optional relative path to the configure JSON Schema. + final String? configureSchema; + + /// Optional relative path to the init JSON Schema. + final String? initSchema; + + /// Absolute path to the primary container file. + String get containerFilePath => _resolve(containerFile); + + /// Absolute path to the declared drop-ins directory, if any. + String? get dropinsDirPath => + dropinsDir == null ? null : _resolve(dropinsDir!); + + /// Absolute path to the declared profiles directory, if any. + String? get profilesDirPath => + profilesDir == null ? null : _resolve(profilesDir!); + + /// Absolute path to the configure schema, if any. + String? get configureSchemaPath => + configureSchema == null ? null : _resolve(configureSchema!); + + /// Absolute path to the init schema, if any. + String? get initSchemaPath => + initSchema == null ? null : _resolve(initSchema!); + + /// Directory for active and generated service configuration. + String get configDir => p.join(serviceDir, 'config'); + + /// Active configure payload path. + String get activeConfigurePath => p.join(configDir, 'configure.json'); + + /// Active init payload path. + String get activeInitPath => p.join(configDir, 'init.json'); + + /// Unit name expected from the primary Quadlet filename. + String get expectedUnit => + '${p.basenameWithoutExtension(containerFile)}.service'; + + /// Decodes [contents] from TOML. + factory InfraServiceManifest.parse({ + required String contents, + required String serviceDir, + required String metadataPath, + }) { + final map = TomlDocument.parse(contents).toMap(); + final service = _section(map, 'service'); + final container = _section(map, 'container'); + final schemas = _optionalSection(map, 'schemas'); + final runtimeSection = _optionalSection(map, 'runtime'); + + return InfraServiceManifest( + id: _requiredString(service, 'id'), + name: _requiredString(service, 'name'), + unit: _requiredString(service, 'unit'), + containerName: _requiredString(service, 'container_name'), + runtime: InfraRuntimeKind.fromManifestValue( + runtimeSection == null ? null : _optionalString(runtimeSection, 'type'), + ), + serviceDir: serviceDir, + metadataPath: metadataPath, + containerFile: _requiredString(container, 'file'), + dropinsDir: _optionalString(container, 'dropins_dir'), + profilesDir: _optionalString(container, 'profiles_dir'), + configureSchema: schemas == null + ? null + : _optionalString(schemas, 'configure'), + initSchema: schemas == null ? null : _optionalString(schemas, 'init'), + ); + } + + /// Machine-readable summary. + Map toJson() => { + 'id': id, + 'name': name, + 'unit': unit, + 'container_name': containerName, + 'runtime': runtime.id, + 'metadata': metadataPath, + 'container_file': containerFilePath, + 'dropins_dir': dropinsDirPath, + 'profiles_dir': profilesDirPath, + 'configure_schema': configureSchemaPath, + 'init_schema': initSchemaPath, + 'active_config': activeConfigurePath, + 'active_init': activeInitPath, + }; + + String _resolve(String value) => p.isAbsolute(value) + ? p.normalize(value) + : p.normalize(p.join(serviceDir, value)); +} + +Map _section(Map map, String key) { + final value = map[key]; + if (value is Map) { + return value.map((key, value) => MapEntry('$key', value)); + } + throw FormatException('metadata.toml is missing [$key].'); +} + +Map? _optionalSection(Map map, String key) { + final value = map[key]; + if (value == null) return null; + if (value is Map) { + return value.map((key, value) => MapEntry('$key', value)); + } + throw FormatException('metadata.toml section [$key] must be a table.'); +} + +String _requiredString(Map map, String key) { + final value = _optionalString(map, key); + if (value == null || value.isEmpty) { + throw FormatException('metadata.toml is missing required string "$key".'); + } + return value; +} + +String? _optionalString(Map map, String key) { + final value = map[key]; + if (value == null) return null; + if (value is String) return value; + throw FormatException('metadata.toml field "$key" must be a string.'); +} diff --git a/packages/infra/pubspec.yaml b/packages/infra/pubspec.yaml new file mode 100644 index 0000000..4e32820 --- /dev/null +++ b/packages/infra/pubspec.yaml @@ -0,0 +1,23 @@ +name: dew_infra +description: Infrastructure service management package for the Dew CLI. +publish_to: none +version: 0.1.0 +repository: https://github.com/artificerchris/dew +issue_tracker: https://github.com/artificerchris/dew/issues +resolution: workspace + +environment: + sdk: ^3.12.0 + +dependencies: + args: ^2.7.0 + dew_core: ^0.1.0 + file: ^7.0.1 + json_schema: ^5.2.2 + path: ^1.9.0 + podman: ^0.1.0 + toml: ^0.18.0 + +dev_dependencies: + lints: ^6.0.0 + test: ^1.25.6 diff --git a/packages/infra/test/dew_infra_test.dart b/packages/infra/test/dew_infra_test.dart new file mode 100644 index 0000000..ae43fd5 --- /dev/null +++ b/packages/infra/test/dew_infra_test.dart @@ -0,0 +1,186 @@ +import 'package:dew_core/dew_core.dart'; +import 'package:dew_infra/dew_infra.dart'; +import 'package:file/memory.dart'; +import 'package:test/test.dart'; + +void main() { + group('Infra command registration', () { + test('registerCommands adds infra command', () { + final registry = CommandRegistry(); + registerCommands(registry); + + expect( + registry.commands.map((command) => command.name), + contains('infra'), + ); + }); + + test('infra command exposes core subcommands', () { + final command = InfraCommand(); + + expect( + command.subcommands.keys, + containsAll([ + 'list', + 'show', + 'validate', + 'configure', + 'init', + 'install', + 'uninstall', + 'up', + 'down', + 'restart', + 'status', + 'logs', + 'delete', + ]), + ); + }); + }); + + group('InfraRepository', () { + test('discovers service metadata', () async { + final fs = MemoryFileSystem.test(); + _writeService(fs); + + final repository = InfraRepository( + infraDir: '/project/.project/infrastructure', + fs: fs, + ); + + final manifests = await repository.list(); + expect(manifests, hasLength(1)); + expect(manifests.single.id, 'postgres'); + expect(manifests.single.runtime, InfraRuntimeKind.podmanQuadlet); + expect( + manifests.single.containerFilePath, + '/project/.project/infrastructure/services/postgres/app_postgres.container', + ); + }); + }); + + group('InfraValidator', () { + test('accepts a complete service manifest', () async { + final fs = MemoryFileSystem.test(); + _writeService(fs); + final manifest = await InfraRepository( + infraDir: '/project/.project/infrastructure', + fs: fs, + ).get('postgres'); + + final issues = await InfraValidator(fs: fs).validate(manifest); + + expect(issues, isEmpty); + }); + + test('reports service id and unit mismatches', () async { + final fs = MemoryFileSystem.test(); + _writeService(fs, serviceId: 'wrong', unit: 'wrong.service'); + final manifest = + await InfraRepository( + infraDir: '/project/.project/infrastructure', + fs: fs, + ).loadFromMetadataPath( + '/project/.project/infrastructure/services/postgres/metadata.toml', + serviceDir: '/project/.project/infrastructure/services/postgres', + ); + + final issues = await InfraValidator(fs: fs).validate(manifest); + + expect( + issues.map((issue) => issue.message).join('\n'), + contains('must match directory'), + ); + expect( + issues.map((issue) => issue.message).join('\n'), + contains('must match container file unit'), + ); + }); + }); + + group('PodmanQuadletRuntime', () { + test( + 'install dry-run reports symlink actions without writing files', + () async { + final fs = MemoryFileSystem.test(); + _writeService(fs); + final manifest = await InfraRepository( + infraDir: '/project/.project/infrastructure', + fs: fs, + ).get('postgres'); + final runtime = PodmanQuadletRuntime( + fs: fs, + environment: const {'HOME': '/home/test'}, + ); + + final result = await runtime.install( + manifest, + scope: InfraScope.user, + dryRun: true, + ); + + expect(result.actions.join('\n'), contains('app_postgres.container')); + expect( + await fs + .link( + '/home/test/.config/containers/systemd/app_postgres.container', + ) + .exists(), + isFalse, + ); + }, + ); + + test('quadletSearchPath respects user and system scope', () { + expect( + quadletSearchPath( + InfraScope.user, + environment: const {'XDG_CONFIG_HOME': '/config'}, + ), + '/config/containers/systemd', + ); + expect(quadletSearchPath(InfraScope.system), '/etc/containers/systemd'); + }); + }); +} + +void _writeService( + MemoryFileSystem fs, { + String serviceId = 'postgres', + String unit = 'app_postgres.service', +}) { + final serviceDir = fs.directory( + '/project/.project/infrastructure/services/postgres', + )..createSync(recursive: true); + fs.directory('${serviceDir.path}/app_postgres.container.d').createSync(); + fs.directory('${serviceDir.path}/app_postgres.profiles.d').createSync(); + fs + .file('${serviceDir.path}/app_postgres.container') + .writeAsStringSync('[Container]\nImage=postgres:16\n'); + fs + .file('${serviceDir.path}/configure.schema.json') + .writeAsStringSync('{"type":"object"}'); + fs + .file('${serviceDir.path}/init.schema.json') + .writeAsStringSync('{"type":"object"}'); + fs.file('${serviceDir.path}/metadata.toml').writeAsStringSync(''' +[service] +id = "$serviceId" +name = "PostgreSQL" +unit = "$unit" +container_name = "app_postgres" + +[runtime] +type = "podman-quadlet" + +[container] +file = "app_postgres.container" +dropins_dir = "app_postgres.container.d" +profiles_dir = "app_postgres.profiles.d" + +[schemas] +configure = "configure.schema.json" +init = "init.schema.json" +'''); +} diff --git a/pubspec.lock b/pubspec.lock index 164b82d..6335dec 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -257,6 +257,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + json_schema: + dependency: transitive + description: + name: json_schema + sha256: f37d9c3fdfe8c9aae55fdfd5af815d24ce63c3a0f6a2c1f0982c30f43643fa1a + url: "https://pub.dev" + source: hosted + version: "5.2.2" lints: dependency: "direct dev" description: @@ -353,6 +361,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.6" + podman: + dependency: transitive + description: + name: podman + sha256: "1be0decc40041c7bed4cc0149695392b7ad6cc1f358fab935c7866a1d6c82eff" + url: "https://pub.dev" + source: hosted + version: "0.1.0" pointycastle: dependency: transitive description: @@ -409,6 +425,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + rfc_6901: + dependency: transitive + description: + name: rfc_6901 + sha256: "6a43b1858dca2febaf93e15639aa6b0c49ccdfd7647775f15a499f872b018154" + url: "https://pub.dev" + source: hosted + version: "0.2.1" shelf: dependency: transitive description: @@ -529,6 +561,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.17" + toml: + dependency: transitive + description: + name: toml + sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e" + url: "https://pub.dev" + source: hosted + version: "0.18.0" typed_data: dependency: transitive description: @@ -537,6 +577,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uri: + dependency: transitive + description: + name: uri + sha256: "889eea21e953187c6099802b7b4cf5219ba8f3518f604a1033064d45b1b8268a" + url: "https://pub.dev" + source: hosted + version: "1.0.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8691b20..3a4d994 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,7 @@ environment: workspace: - packages/cli - packages/core + - packages/infra - packages/kanban - packages/mcp - packages/vault