From 7f5896ec5cc50ec86626b66532e480ec1238d353 Mon Sep 17 00:00:00 2001 From: Chris Hendrickson Date: Mon, 4 May 2026 23:08:18 -0400 Subject: [PATCH] Use YAML infra service manifests --- .project/kanban/done/DEW-0030.md | 8 ++ docs/config.md | 2 +- docs/features/infra.md | 35 ++++---- packages/infra/lib/src/infra_repository.dart | 36 ++++---- packages/infra/lib/src/service_manifest.dart | 45 +++++++--- packages/infra/pubspec.yaml | 2 +- .../schemas/service-manifest.schema.json | 85 +++++++++++++++++++ packages/infra/test/dew_infra_test.dart | 79 +++++++++++++---- pubspec.lock | 8 -- 9 files changed, 224 insertions(+), 76 deletions(-) create mode 100644 .project/kanban/done/DEW-0030.md create mode 100644 packages/infra/schemas/service-manifest.schema.json diff --git a/.project/kanban/done/DEW-0030.md b/.project/kanban/done/DEW-0030.md new file mode 100644 index 0000000..880158e --- /dev/null +++ b/.project/kanban/done/DEW-0030.md @@ -0,0 +1,8 @@ +--- +id: DEW-0030 +title: Use YAML infra service manifests +type: task +created: 2026-05-05T03:04:56.996343Z +--- + +Switch infra service manifests from metadata.toml to manifest.yaml and add a package-level JSON Schema for the service manifest contract. diff --git a/docs/config.md b/docs/config.md index 3498f61..878e5ab 100644 --- a/docs/config.md +++ b/docs/config.md @@ -14,7 +14,7 @@ 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`. +from `.project/infrastructure/services/*/manifest.yaml`. ## Full Schema diff --git a/docs/features/infra.md b/docs/features/infra.md index 1ac32ce..2386bb7 100644 --- a/docs/features/infra.md +++ b/docs/features/infra.md @@ -14,7 +14,7 @@ workflows. .project/infrastructure/ └── services/ └── postgres/ - ├── metadata.toml + ├── manifest.yaml ├── app_postgres.container ├── app_postgres.container.d/ ├── app_postgres.profiles.d/ @@ -25,26 +25,29 @@ workflows. ## Manifest -```toml -[service] -id = "postgres" -name = "PostgreSQL" -unit = "app_postgres.service" -container_name = "app_postgres" +```yaml +service: + id: postgres + name: PostgreSQL + unit: app_postgres.service + container_name: app_postgres -[runtime] -type = "podman-quadlet" +runtime: + type: podman-quadlet -[container] -file = "app_postgres.container" -dropins_dir = "app_postgres.container.d" -profiles_dir = "app_postgres.profiles.d" +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" +schemas: + configure: configure.schema.json + init: init.schema.json ``` +The package-level schema for this file is +`packages/infra/schemas/service-manifest.schema.json`. + ## Commands ```bash diff --git a/packages/infra/lib/src/infra_repository.dart b/packages/infra/lib/src/infra_repository.dart index 1e6bcc5..efdb114 100644 --- a/packages/infra/lib/src/infra_repository.dart +++ b/packages/infra/lib/src/infra_repository.dart @@ -23,7 +23,7 @@ class InfraRepository { /// Absolute path to the service directory root. String get servicesDir => p.join(infraDir, 'services'); - /// Finds all service manifests below `services/*/metadata.toml`. + /// Finds all service manifests below `services/*/manifest.yaml`. Future> list() async { final root = fs.directory(servicesDir); if (!await root.exists()) return const []; @@ -31,10 +31,10 @@ class InfraRepository { 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; + final manifest = fs.file(p.join(entity.path, 'manifest.yaml')); + if (!await manifest.exists()) continue; manifests.add( - await loadFromMetadataPath(metadata.path, serviceDir: entity.path), + await loadFromManifestPath(manifest.path, serviceDir: entity.path), ); } manifests.sort((a, b) => a.id.compareTo(b.id)); @@ -52,25 +52,25 @@ class InfraRepository { /// 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); + final manifestPath = p.join(servicesDir, id, 'manifest.yaml'); + final file = fs.file(manifestPath); if (!await file.exists()) return null; - return loadFromMetadataPath( - metadataPath, - serviceDir: p.dirname(metadataPath), + return loadFromManifestPath( + manifestPath, + serviceDir: p.dirname(manifestPath), ); } - /// Parses the manifest at [metadataPath]. - Future loadFromMetadataPath( - String metadataPath, { + /// Parses the manifest at [manifestPath]. + Future loadFromManifestPath( + String manifestPath, { required String serviceDir, }) async { - final file = fs.file(metadataPath); + final file = fs.file(manifestPath); return InfraServiceManifest.parse( contents: await file.readAsString(), serviceDir: p.normalize(serviceDir), - metadataPath: p.normalize(metadataPath), + manifestPath: p.normalize(manifestPath), ); } } @@ -126,16 +126,16 @@ class InfraValidator { final dirId = p.basename(manifest.serviceDir); if (manifest.id != dirId) { issue( - manifest.metadataPath, + manifest.manifestPath, 'service.id "${manifest.id}" must match directory "$dirId".', ); } if (!manifest.unit.endsWith('.service')) { - issue(manifest.metadataPath, 'service.unit must end with .service.'); + issue(manifest.manifestPath, 'service.unit must end with .service.'); } if (manifest.unit != manifest.expectedUnit) { issue( - manifest.metadataPath, + manifest.manifestPath, 'service.unit "${manifest.unit}" must match container file unit ' '"${manifest.expectedUnit}".', ); @@ -211,7 +211,7 @@ class InfraValidator { issues.add( InfraValidationIssue( serviceId: manifest.id, - path: manifest.metadataPath, + path: manifest.manifestPath, message: 'Missing $label path.', ), ); diff --git a/packages/infra/lib/src/service_manifest.dart b/packages/infra/lib/src/service_manifest.dart index c284f68..3fb53cc 100644 --- a/packages/infra/lib/src/service_manifest.dart +++ b/packages/infra/lib/src/service_manifest.dart @@ -1,5 +1,5 @@ import 'package:path/path.dart' as p; -import 'package:toml/toml.dart'; +import 'package:yaml/yaml.dart'; /// Supported infrastructure runtime backends. /// @@ -26,7 +26,7 @@ enum InfraRuntimeKind { } } -/// Infrastructure service metadata loaded from `metadata.toml`. +/// Infrastructure service metadata loaded from `manifest.yaml`. class InfraServiceManifest { const InfraServiceManifest({ required this.id, @@ -35,7 +35,7 @@ class InfraServiceManifest { required this.containerName, required this.runtime, required this.serviceDir, - required this.metadataPath, + required this.manifestPath, required this.containerFile, this.dropinsDir, this.profilesDir, @@ -61,8 +61,8 @@ class InfraServiceManifest { /// Absolute path to the service directory. final String serviceDir; - /// Absolute path to `metadata.toml`. - final String metadataPath; + /// Absolute path to `manifest.yaml`. + final String manifestPath; /// Relative path to the primary Quadlet/container definition. final String containerFile; @@ -111,13 +111,13 @@ class InfraServiceManifest { String get expectedUnit => '${p.basenameWithoutExtension(containerFile)}.service'; - /// Decodes [contents] from TOML. + /// Decodes [contents] from YAML. factory InfraServiceManifest.parse({ required String contents, required String serviceDir, - required String metadataPath, + required String manifestPath, }) { - final map = TomlDocument.parse(contents).toMap(); + final map = _asMap(loadYaml(contents)); final service = _section(map, 'service'); final container = _section(map, 'container'); final schemas = _optionalSection(map, 'schemas'); @@ -132,7 +132,7 @@ class InfraServiceManifest { runtimeSection == null ? null : _optionalString(runtimeSection, 'type'), ), serviceDir: serviceDir, - metadataPath: metadataPath, + manifestPath: manifestPath, containerFile: _requiredString(container, 'file'), dropinsDir: _optionalString(container, 'dropins_dir'), profilesDir: _optionalString(container, 'profiles_dir'), @@ -150,7 +150,7 @@ class InfraServiceManifest { 'unit': unit, 'container_name': containerName, 'runtime': runtime.id, - 'metadata': metadataPath, + 'manifest': manifestPath, 'container_file': containerFilePath, 'dropins_dir': dropinsDirPath, 'profiles_dir': profilesDirPath, @@ -165,12 +165,29 @@ class InfraServiceManifest { : p.normalize(p.join(serviceDir, value)); } +Map _asMap(dynamic value) { + if (value is YamlMap) { + return value.map((key, value) => MapEntry('$key', _asYamlValue(value))); + } + if (value is Map) { + return value.map((key, value) => MapEntry('$key', _asYamlValue(value))); + } + throw const FormatException('manifest.yaml must contain a YAML object.'); +} + +dynamic _asYamlValue(dynamic value) { + if (value is YamlMap || value is Map) return _asMap(value); + if (value is YamlList) return value.map(_asYamlValue).toList(); + if (value is List) return value.map(_asYamlValue).toList(); + return 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].'); + throw FormatException('manifest.yaml is missing "$key".'); } Map? _optionalSection(Map map, String key) { @@ -179,13 +196,13 @@ Map? _optionalSection(Map map, String key) { if (value is Map) { return value.map((key, value) => MapEntry('$key', value)); } - throw FormatException('metadata.toml section [$key] must be a table.'); + throw FormatException('manifest.yaml field "$key" must be an object.'); } 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".'); + throw FormatException('manifest.yaml is missing required string "$key".'); } return value; } @@ -194,5 +211,5 @@ 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.'); + throw FormatException('manifest.yaml field "$key" must be a string.'); } diff --git a/packages/infra/pubspec.yaml b/packages/infra/pubspec.yaml index 4e32820..871fb06 100644 --- a/packages/infra/pubspec.yaml +++ b/packages/infra/pubspec.yaml @@ -16,7 +16,7 @@ dependencies: json_schema: ^5.2.2 path: ^1.9.0 podman: ^0.1.0 - toml: ^0.18.0 + yaml: ^3.1.0 dev_dependencies: lints: ^6.0.0 diff --git a/packages/infra/schemas/service-manifest.schema.json b/packages/infra/schemas/service-manifest.schema.json new file mode 100644 index 0000000..f116f77 --- /dev/null +++ b/packages/infra/schemas/service-manifest.schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://artificery.dev/dew/schemas/infra/service-manifest.schema.json", + "title": "Dew Infrastructure Service Manifest", + "description": "Schema for .project/infrastructure/services//manifest.yaml.", + "type": "object", + "additionalProperties": false, + "required": ["service", "container", "schemas"], + "properties": { + "service": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "unit", "container_name"], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z0-9][a-z0-9_.-]*$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "unit": { + "type": "string", + "minLength": 1, + "pattern": "^[^/]+\\.service$" + }, + "container_name": { + "type": "string", + "minLength": 1 + } + } + }, + "runtime": { + "type": "object", + "additionalProperties": false, + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["podman-quadlet"], + "default": "podman-quadlet" + } + } + }, + "container": { + "type": "object", + "additionalProperties": false, + "required": ["file", "dropins_dir", "profiles_dir"], + "properties": { + "file": { + "type": "string", + "minLength": 1, + "pattern": "^[^/].*\\.container$" + }, + "dropins_dir": { + "type": "string", + "minLength": 1 + }, + "profiles_dir": { + "type": "string", + "minLength": 1 + } + } + }, + "schemas": { + "type": "object", + "additionalProperties": false, + "required": ["configure", "init"], + "properties": { + "configure": { + "type": "string", + "minLength": 1, + "pattern": "^[^/].*\\.json$" + }, + "init": { + "type": "string", + "minLength": 1, + "pattern": "^[^/].*\\.json$" + } + } + } + } +} diff --git a/packages/infra/test/dew_infra_test.dart b/packages/infra/test/dew_infra_test.dart index ae43fd5..8f57a93 100644 --- a/packages/infra/test/dew_infra_test.dart +++ b/packages/infra/test/dew_infra_test.dart @@ -1,6 +1,10 @@ +import 'dart:convert'; +import 'dart:io' as io; + import 'package:dew_core/dew_core.dart'; import 'package:dew_infra/dew_infra.dart'; import 'package:file/memory.dart'; +import 'package:json_schema/json_schema.dart'; import 'package:test/test.dart'; void main() { @@ -40,7 +44,7 @@ void main() { }); group('InfraRepository', () { - test('discovers service metadata', () async { + test('discovers service manifests', () async { final fs = MemoryFileSystem.test(); _writeService(fs); @@ -81,8 +85,8 @@ void main() { await InfraRepository( infraDir: '/project/.project/infrastructure', fs: fs, - ).loadFromMetadataPath( - '/project/.project/infrastructure/services/postgres/metadata.toml', + ).loadFromManifestPath( + '/project/.project/infrastructure/services/postgres/manifest.yaml', serviceDir: '/project/.project/infrastructure/services/postgres', ); @@ -143,6 +147,18 @@ void main() { expect(quadletSearchPath(InfraScope.system), '/etc/containers/systemd'); }); }); + + group('service-manifest.schema.json', () { + test('validates the manifest contract shape', () { + final schema = JsonSchema.create( + jsonDecode(_schemaFile().readAsStringSync()), + ); + + final result = schema.validate(_manifestObject()); + + expect(result.isValid, isTrue, reason: result.errors.join('\n')); + }); + }); } void _writeService( @@ -164,23 +180,50 @@ void _writeService( 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" + fs.file('${serviceDir.path}/manifest.yaml').writeAsStringSync(''' +service: + id: $serviceId + name: PostgreSQL + unit: $unit + container_name: app_postgres -[runtime] -type = "podman-quadlet" +runtime: + type: podman-quadlet -[container] -file = "app_postgres.container" -dropins_dir = "app_postgres.container.d" -profiles_dir = "app_postgres.profiles.d" +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" +schemas: + configure: configure.schema.json + init: init.schema.json '''); } + +Map _manifestObject() => { + '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'}, +}; + +io.File _schemaFile() { + for (final path in [ + 'packages/infra/schemas/service-manifest.schema.json', + 'schemas/service-manifest.schema.json', + ]) { + final file = io.File(path); + if (file.existsSync()) return file; + } + throw StateError('Could not find service-manifest.schema.json.'); +} diff --git a/pubspec.lock b/pubspec.lock index 6335dec..1e9b462 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -561,14 +561,6 @@ 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: