Add infra command surface
This commit is contained in:
parent
f7346b1afe
commit
95058f7f04
17 changed files with 1980 additions and 2 deletions
8
.project/kanban/done/DEW-0029.md
Normal file
8
.project/kanban/done/DEW-0029.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -18,6 +18,12 @@ stats board config tui
|
|||
|
||||
Tickets are stored as `.project/kanban/<column>/<ID>.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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
72
docs/features/infra.md
Normal file
72
docs/features/infra.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> main(List<String> args) async {
|
||||
final commandRegistry = CommandRegistry();
|
||||
|
||||
infra.registerCommands(commandRegistry);
|
||||
kanban.registerCommands(commandRegistry);
|
||||
vault.registerCommands(commandRegistry);
|
||||
mcp.registerCommands(commandRegistry);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<void> 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 {
|
||||
|
|
|
|||
20
packages/infra/lib/dew_infra.dart
Normal file
20
packages/infra/lib/dew_infra.dart
Normal file
|
|
@ -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));
|
||||
}
|
||||
692
packages/infra/lib/src/dew_infra_base.dart
Normal file
692
packages/infra/lib/src/dew_infra_base.dart
Normal file
|
|
@ -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<void> 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<void> 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<void> run() async {
|
||||
final service = _requiredServiceArg('Usage: dew infra show <service>.');
|
||||
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<void> 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 = <InfraValidationIssue>[];
|
||||
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<void> run() async {
|
||||
final rest = argResults?.rest ?? const [];
|
||||
if (rest.isEmpty) {
|
||||
usageException(
|
||||
'Usage: dew infra configure <service> [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<void> run() async {
|
||||
final rest = argResults?.rest ?? const [];
|
||||
if (rest.isEmpty) {
|
||||
usageException('Usage: dew infra init <service> [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<void> run() async {
|
||||
final env = await _environment();
|
||||
final manifests = await _targetServices(env, allowAll: true);
|
||||
final results = <Map<String, Object?>>[];
|
||||
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<InfraRuntimeResult> _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 = <InfraRuntimeResult>[];
|
||||
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<void> run() async {
|
||||
final env = await _environment();
|
||||
final manifest = await env.repository.get(
|
||||
_requiredServiceArg('Usage: dew infra logs <service>.'),
|
||||
);
|
||||
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<void> 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 = <Map<String, Object?>>[];
|
||||
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<List<InfraServiceManifest>> _targetServices(
|
||||
_InfraEnvironment env, {
|
||||
required bool allowAll,
|
||||
}) async {
|
||||
final all = (env.options.command?['all'] as bool?) ?? false;
|
||||
final rest = env.options.command?.rest ?? const <String>[];
|
||||
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<void> _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<void> _printPayload(_InfraEnvironment env, String path) async {
|
||||
final file = env.repository.fs.file(path);
|
||||
if (!await file.exists()) {
|
||||
if (env.json) {
|
||||
print(jsonEncode(<String, Object?>{}));
|
||||
} else {
|
||||
print('No active configuration found at $path.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
print(await file.readAsString());
|
||||
}
|
||||
|
||||
Future<void> _applyPayload(
|
||||
_InfraEnvironment env, {
|
||||
required String? schemaPath,
|
||||
required String outputPath,
|
||||
}) async {
|
||||
final payload = <String, dynamic>{};
|
||||
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<String>? ?? 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<String, dynamic> _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<void> _validatePayload(
|
||||
_InfraEnvironment env,
|
||||
String? schemaPath,
|
||||
Map<String, dynamic> 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<String, dynamic> 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, () => <String, dynamic>{})
|
||||
as Map<String, dynamic>;
|
||||
}
|
||||
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<InfraRuntimeResult> results) {
|
||||
final actions = <String>[];
|
||||
final stdout = <String>[];
|
||||
final stderr = <String>[];
|
||||
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'),
|
||||
);
|
||||
}
|
||||
244
packages/infra/lib/src/infra_repository.dart
Normal file
244
packages/infra/lib/src/infra_repository.dart
Normal file
|
|
@ -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<InfraServiceManifest>> list() async {
|
||||
final root = fs.directory(servicesDir);
|
||||
if (!await root.exists()) return const [];
|
||||
|
||||
final manifests = <InfraServiceManifest>[];
|
||||
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<InfraServiceManifest> 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<InfraServiceManifest?> 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<InfraServiceManifest> 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<String, String> 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<List<InfraValidationIssue>> validate(
|
||||
InfraServiceManifest manifest,
|
||||
) async {
|
||||
final issues = <InfraValidationIssue>[];
|
||||
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<void> _requireFile(
|
||||
InfraServiceManifest manifest,
|
||||
String path,
|
||||
List<InfraValidationIssue> issues,
|
||||
) async {
|
||||
if (!await fs.file(path).exists()) {
|
||||
issues.add(
|
||||
InfraValidationIssue(
|
||||
serviceId: manifest.id,
|
||||
path: path,
|
||||
message: 'Referenced file does not exist.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requireDirectoryIfDeclared(
|
||||
InfraServiceManifest manifest,
|
||||
String? path,
|
||||
List<InfraValidationIssue> 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<void> _validateJsonSchema(
|
||||
InfraServiceManifest manifest, {
|
||||
required String label,
|
||||
required String? path,
|
||||
required List<InfraValidationIssue> 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',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
466
packages/infra/lib/src/infra_runtime.dart
Normal file
466
packages/infra/lib/src/infra_runtime.dart
Normal file
|
|
@ -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<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 =>
|
||||
await fs.link(_targetContainerPath(manifest, scope)).exists() ||
|
||||
await fs.file(_targetContainerPath(manifest, scope)).exists();
|
||||
|
||||
@override
|
||||
Future<InfraRuntimeResult> install(
|
||||
InfraServiceManifest manifest, {
|
||||
required InfraScope scope,
|
||||
required bool dryRun,
|
||||
}) async {
|
||||
final actions = <String>[];
|
||||
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<InfraRuntimeResult> uninstall(
|
||||
InfraServiceManifest manifest, {
|
||||
required InfraScope scope,
|
||||
required bool dryRun,
|
||||
}) async {
|
||||
final actions = <String>[];
|
||||
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<InfraRuntimeResult> start(
|
||||
InfraServiceManifest manifest, {
|
||||
required InfraScope scope,
|
||||
required bool dryRun,
|
||||
}) async => _systemctl(scope, ['start', manifest.unit], dryRun: dryRun);
|
||||
|
||||
@override
|
||||
Future<InfraRuntimeResult> stop(
|
||||
InfraServiceManifest manifest, {
|
||||
required InfraScope scope,
|
||||
required bool dryRun,
|
||||
}) async => _systemctl(scope, ['stop', manifest.unit], dryRun: dryRun);
|
||||
|
||||
@override
|
||||
Future<InfraRuntimeResult> restart(
|
||||
InfraServiceManifest manifest, {
|
||||
required InfraScope scope,
|
||||
required bool dryRun,
|
||||
}) async => _systemctl(scope, ['restart', manifest.unit], dryRun: dryRun);
|
||||
|
||||
@override
|
||||
Future<InfraRuntimeResult> status(
|
||||
InfraServiceManifest manifest, {
|
||||
required InfraScope scope,
|
||||
}) async =>
|
||||
_systemctl(scope, ['status', manifest.unit, '--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',
|
||||
'-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<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) {
|
||||
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<InfraRuntimeResult> 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<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');
|
||||
}
|
||||
198
packages/infra/lib/src/service_manifest.dart
Normal file
198
packages/infra/lib/src/service_manifest.dart
Normal file
|
|
@ -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<String, Object?> 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<String, dynamic> _section(Map<String, dynamic> 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<String, dynamic>? _optionalSection(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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.');
|
||||
}
|
||||
23
packages/infra/pubspec.yaml
Normal file
23
packages/infra/pubspec.yaml
Normal file
|
|
@ -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
|
||||
186
packages/infra/test/dew_infra_test.dart
Normal file
186
packages/infra/test/dew_infra_test.dart
Normal file
|
|
@ -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"
|
||||
''');
|
||||
}
|
||||
48
pubspec.lock
48
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:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ environment:
|
|||
workspace:
|
||||
- packages/cli
|
||||
- packages/core
|
||||
- packages/infra
|
||||
- packages/kanban
|
||||
- packages/mcp
|
||||
- packages/vault
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue