Use YAML infra service manifests
This commit is contained in:
parent
a6a86e6c29
commit
7f5896ec5c
9 changed files with 224 additions and 76 deletions
8
.project/kanban/done/DEW-0030.md
Normal file
8
.project/kanban/done/DEW-0030.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
85
packages/infra/schemas/service-manifest.schema.json
Normal file
85
packages/infra/schemas/service-manifest.schema.json
Normal 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$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue