Use YAML infra service manifests

This commit is contained in:
Chris Hendrickson 2026-05-04 23:08:18 -04:00
parent a6a86e6c29
commit 7f5896ec5c
9 changed files with 224 additions and 76 deletions

View file

@ -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.

View file

@ -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`). unless they are absolute (for example, paths under `dew.vault`).
Infrastructure services are not configured in `dew.yaml`; they are discovered 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 ## Full Schema

View file

@ -14,7 +14,7 @@ workflows.
.project/infrastructure/ .project/infrastructure/
└── services/ └── services/
└── postgres/ └── postgres/
├── metadata.toml ├── manifest.yaml
├── app_postgres.container ├── app_postgres.container
├── app_postgres.container.d/ ├── app_postgres.container.d/
├── app_postgres.profiles.d/ ├── app_postgres.profiles.d/
@ -25,26 +25,29 @@ workflows.
## Manifest ## Manifest
```toml ```yaml
[service] service:
id = "postgres" id: postgres
name = "PostgreSQL" name: PostgreSQL
unit = "app_postgres.service" unit: app_postgres.service
container_name = "app_postgres" container_name: app_postgres
[runtime] runtime:
type = "podman-quadlet" type: podman-quadlet
[container] container:
file = "app_postgres.container" file: app_postgres.container
dropins_dir = "app_postgres.container.d" dropins_dir: app_postgres.container.d
profiles_dir = "app_postgres.profiles.d" profiles_dir: app_postgres.profiles.d
[schemas] schemas:
configure = "configure.schema.json" configure: configure.schema.json
init = "init.schema.json" init: init.schema.json
``` ```
The package-level schema for this file is
`packages/infra/schemas/service-manifest.schema.json`.
## Commands ## Commands
```bash ```bash

View file

@ -23,7 +23,7 @@ class InfraRepository {
/// Absolute path to the service directory root. /// Absolute path to the service directory root.
String get servicesDir => p.join(infraDir, 'services'); 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<InfraServiceManifest>> list() async { Future<List<InfraServiceManifest>> list() async {
final root = fs.directory(servicesDir); final root = fs.directory(servicesDir);
if (!await root.exists()) return const []; if (!await root.exists()) return const [];
@ -31,10 +31,10 @@ class InfraRepository {
final manifests = <InfraServiceManifest>[]; final manifests = <InfraServiceManifest>[];
await for (final entity in root.list()) { await for (final entity in root.list()) {
if (entity is! Directory) continue; if (entity is! Directory) continue;
final metadata = fs.file(p.join(entity.path, 'metadata.toml')); final manifest = fs.file(p.join(entity.path, 'manifest.yaml'));
if (!await metadata.exists()) continue; if (!await manifest.exists()) continue;
manifests.add( 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)); 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. /// Loads a single service by command-line [id], returning null if absent.
Future<InfraServiceManifest?> find(String id) async { Future<InfraServiceManifest?> find(String id) async {
final metadataPath = p.join(servicesDir, id, 'metadata.toml'); final manifestPath = p.join(servicesDir, id, 'manifest.yaml');
final file = fs.file(metadataPath); final file = fs.file(manifestPath);
if (!await file.exists()) return null; if (!await file.exists()) return null;
return loadFromMetadataPath( return loadFromManifestPath(
metadataPath, manifestPath,
serviceDir: p.dirname(metadataPath), serviceDir: p.dirname(manifestPath),
); );
} }
/// Parses the manifest at [metadataPath]. /// Parses the manifest at [manifestPath].
Future<InfraServiceManifest> loadFromMetadataPath( Future<InfraServiceManifest> loadFromManifestPath(
String metadataPath, { String manifestPath, {
required String serviceDir, required String serviceDir,
}) async { }) async {
final file = fs.file(metadataPath); final file = fs.file(manifestPath);
return InfraServiceManifest.parse( return InfraServiceManifest.parse(
contents: await file.readAsString(), contents: await file.readAsString(),
serviceDir: p.normalize(serviceDir), serviceDir: p.normalize(serviceDir),
metadataPath: p.normalize(metadataPath), manifestPath: p.normalize(manifestPath),
); );
} }
} }
@ -126,16 +126,16 @@ class InfraValidator {
final dirId = p.basename(manifest.serviceDir); final dirId = p.basename(manifest.serviceDir);
if (manifest.id != dirId) { if (manifest.id != dirId) {
issue( issue(
manifest.metadataPath, manifest.manifestPath,
'service.id "${manifest.id}" must match directory "$dirId".', 'service.id "${manifest.id}" must match directory "$dirId".',
); );
} }
if (!manifest.unit.endsWith('.service')) { 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) { if (manifest.unit != manifest.expectedUnit) {
issue( issue(
manifest.metadataPath, manifest.manifestPath,
'service.unit "${manifest.unit}" must match container file unit ' 'service.unit "${manifest.unit}" must match container file unit '
'"${manifest.expectedUnit}".', '"${manifest.expectedUnit}".',
); );
@ -211,7 +211,7 @@ class InfraValidator {
issues.add( issues.add(
InfraValidationIssue( InfraValidationIssue(
serviceId: manifest.id, serviceId: manifest.id,
path: manifest.metadataPath, path: manifest.manifestPath,
message: 'Missing $label path.', message: 'Missing $label path.',
), ),
); );

View file

@ -1,5 +1,5 @@
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:toml/toml.dart'; import 'package:yaml/yaml.dart';
/// Supported infrastructure runtime backends. /// 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 { class InfraServiceManifest {
const InfraServiceManifest({ const InfraServiceManifest({
required this.id, required this.id,
@ -35,7 +35,7 @@ class InfraServiceManifest {
required this.containerName, required this.containerName,
required this.runtime, required this.runtime,
required this.serviceDir, required this.serviceDir,
required this.metadataPath, required this.manifestPath,
required this.containerFile, required this.containerFile,
this.dropinsDir, this.dropinsDir,
this.profilesDir, this.profilesDir,
@ -61,8 +61,8 @@ class InfraServiceManifest {
/// Absolute path to the service directory. /// Absolute path to the service directory.
final String serviceDir; final String serviceDir;
/// Absolute path to `metadata.toml`. /// Absolute path to `manifest.yaml`.
final String metadataPath; final String manifestPath;
/// Relative path to the primary Quadlet/container definition. /// Relative path to the primary Quadlet/container definition.
final String containerFile; final String containerFile;
@ -111,13 +111,13 @@ class InfraServiceManifest {
String get expectedUnit => String get expectedUnit =>
'${p.basenameWithoutExtension(containerFile)}.service'; '${p.basenameWithoutExtension(containerFile)}.service';
/// Decodes [contents] from TOML. /// Decodes [contents] from YAML.
factory InfraServiceManifest.parse({ factory InfraServiceManifest.parse({
required String contents, required String contents,
required String serviceDir, 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 service = _section(map, 'service');
final container = _section(map, 'container'); final container = _section(map, 'container');
final schemas = _optionalSection(map, 'schemas'); final schemas = _optionalSection(map, 'schemas');
@ -132,7 +132,7 @@ class InfraServiceManifest {
runtimeSection == null ? null : _optionalString(runtimeSection, 'type'), runtimeSection == null ? null : _optionalString(runtimeSection, 'type'),
), ),
serviceDir: serviceDir, serviceDir: serviceDir,
metadataPath: metadataPath, manifestPath: manifestPath,
containerFile: _requiredString(container, 'file'), containerFile: _requiredString(container, 'file'),
dropinsDir: _optionalString(container, 'dropins_dir'), dropinsDir: _optionalString(container, 'dropins_dir'),
profilesDir: _optionalString(container, 'profiles_dir'), profilesDir: _optionalString(container, 'profiles_dir'),
@ -150,7 +150,7 @@ class InfraServiceManifest {
'unit': unit, 'unit': unit,
'container_name': containerName, 'container_name': containerName,
'runtime': runtime.id, 'runtime': runtime.id,
'metadata': metadataPath, 'manifest': manifestPath,
'container_file': containerFilePath, 'container_file': containerFilePath,
'dropins_dir': dropinsDirPath, 'dropins_dir': dropinsDirPath,
'profiles_dir': profilesDirPath, 'profiles_dir': profilesDirPath,
@ -165,12 +165,29 @@ class InfraServiceManifest {
: p.normalize(p.join(serviceDir, value)); : p.normalize(p.join(serviceDir, value));
} }
Map<String, dynamic> _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<String, dynamic> _section(Map<String, dynamic> map, String key) { Map<String, dynamic> _section(Map<String, dynamic> map, String key) {
final value = map[key]; final value = map[key];
if (value is Map) { if (value is Map) {
return value.map((key, value) => MapEntry('$key', value)); return value.map((key, value) => MapEntry('$key', value));
} }
throw FormatException('metadata.toml is missing [$key].'); throw FormatException('manifest.yaml is missing "$key".');
} }
Map<String, dynamic>? _optionalSection(Map<String, dynamic> map, String key) { Map<String, dynamic>? _optionalSection(Map<String, dynamic> map, String key) {
@ -179,13 +196,13 @@ Map<String, dynamic>? _optionalSection(Map<String, dynamic> map, String key) {
if (value is Map) { if (value is Map) {
return value.map((key, value) => MapEntry('$key', value)); 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<String, dynamic> map, String key) { String _requiredString(Map<String, dynamic> map, String key) {
final value = _optionalString(map, key); final value = _optionalString(map, key);
if (value == null || value.isEmpty) { 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; return value;
} }
@ -194,5 +211,5 @@ String? _optionalString(Map<String, dynamic> map, String key) {
final value = map[key]; final value = map[key];
if (value == null) return null; if (value == null) return null;
if (value is String) return value; 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.');
} }

View file

@ -16,7 +16,7 @@ dependencies:
json_schema: ^5.2.2 json_schema: ^5.2.2
path: ^1.9.0 path: ^1.9.0
podman: ^0.1.0 podman: ^0.1.0
toml: ^0.18.0 yaml: ^3.1.0
dev_dependencies: dev_dependencies:
lints: ^6.0.0 lints: ^6.0.0

View file

@ -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/<service-id>/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$"
}
}
}
}
}

View file

@ -1,6 +1,10 @@
import 'dart:convert';
import 'dart:io' as io;
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:dew_infra/dew_infra.dart'; import 'package:dew_infra/dew_infra.dart';
import 'package:file/memory.dart'; import 'package:file/memory.dart';
import 'package:json_schema/json_schema.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
void main() { void main() {
@ -40,7 +44,7 @@ void main() {
}); });
group('InfraRepository', () { group('InfraRepository', () {
test('discovers service metadata', () async { test('discovers service manifests', () async {
final fs = MemoryFileSystem.test(); final fs = MemoryFileSystem.test();
_writeService(fs); _writeService(fs);
@ -81,8 +85,8 @@ void main() {
await InfraRepository( await InfraRepository(
infraDir: '/project/.project/infrastructure', infraDir: '/project/.project/infrastructure',
fs: fs, fs: fs,
).loadFromMetadataPath( ).loadFromManifestPath(
'/project/.project/infrastructure/services/postgres/metadata.toml', '/project/.project/infrastructure/services/postgres/manifest.yaml',
serviceDir: '/project/.project/infrastructure/services/postgres', serviceDir: '/project/.project/infrastructure/services/postgres',
); );
@ -143,6 +147,18 @@ void main() {
expect(quadletSearchPath(InfraScope.system), '/etc/containers/systemd'); 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( void _writeService(
@ -164,23 +180,50 @@ void _writeService(
fs fs
.file('${serviceDir.path}/init.schema.json') .file('${serviceDir.path}/init.schema.json')
.writeAsStringSync('{"type":"object"}'); .writeAsStringSync('{"type":"object"}');
fs.file('${serviceDir.path}/metadata.toml').writeAsStringSync(''' fs.file('${serviceDir.path}/manifest.yaml').writeAsStringSync('''
[service] service:
id = "$serviceId" id: $serviceId
name = "PostgreSQL" name: PostgreSQL
unit = "$unit" unit: $unit
container_name = "app_postgres" container_name: app_postgres
[runtime] runtime:
type = "podman-quadlet" type: podman-quadlet
[container] container:
file = "app_postgres.container" file: app_postgres.container
dropins_dir = "app_postgres.container.d" dropins_dir: app_postgres.container.d
profiles_dir = "app_postgres.profiles.d" profiles_dir: app_postgres.profiles.d
[schemas] schemas:
configure = "configure.schema.json" configure: configure.schema.json
init = "init.schema.json" init: init.schema.json
'''); ''');
} }
Map<String, Object?> _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.');
}

View file

@ -561,14 +561,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.17" version: "0.6.17"
toml:
dependency: transitive
description:
name: toml
sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e"
url: "https://pub.dev"
source: hosted
version: "0.18.0"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description: