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`).
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

View file

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

View file

@ -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<InfraServiceManifest>> list() async {
final root = fs.directory(servicesDir);
if (!await root.exists()) return const [];
@ -31,10 +31,10 @@ class InfraRepository {
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;
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<InfraServiceManifest?> 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<InfraServiceManifest> loadFromMetadataPath(
String metadataPath, {
/// Parses the manifest at [manifestPath].
Future<InfraServiceManifest> 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.',
),
);

View file

@ -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<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) {
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<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) {
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) {
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<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.');
throw FormatException('manifest.yaml field "$key" must be a string.');
}

View file

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

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_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<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"
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: