Support multiple quadlets in infra manifests

This commit is contained in:
Chris Hendrickson 2026-05-05 00:07:11 -04:00
parent 223aaba888
commit 9bc5779221
10 changed files with 396 additions and 217 deletions

View file

@ -24,5 +24,5 @@ dew infra down postgresql-18
The named volume is intentionally retained after stopping the service. The named volume is intentionally retained after stopping the service.
Service-specific configure and init schemas live under `schemas/` and are Service-specific configure and init schemas live under `schemas/`. The manifest
referenced from `manifest.yaml`. declares the PostgreSQL container under its `quadlets` list.

View file

@ -1,14 +1,13 @@
service:
id: postgresql-18 id: postgresql-18
name: PostgreSQL 18 name: PostgreSQL 18
unit: dew_postgresql-18.service
container_name: dew_postgresql-18
runtime: runtime:
type: podman-quadlet type: podman-quadlet
container: quadlets:
file: dew_postgresql-18.container - file: dew_postgresql-18.container
unit: dew_postgresql-18.service
container_name: dew_postgresql-18
dropins_dir: dew_postgresql-18.container.d dropins_dir: dew_postgresql-18.container.d
profiles_dir: dew_postgresql-18.profiles.d profiles_dir: dew_postgresql-18.profiles.d

View file

@ -0,0 +1,8 @@
---
id: DEW-0034
title: Support multiple quadlets per infra service
type: task
created: 2026-05-05T03:57:12.644828Z
---
Replace the single service/container manifest contract with a quadlets list so one Dew infra service can deploy multiple Podman Quadlet files across supported Quadlet types.

View file

@ -27,17 +27,16 @@ workflows.
## Manifest ## Manifest
```yaml ```yaml
service:
id: postgres id: postgres
name: PostgreSQL name: PostgreSQL
unit: app_postgres.service
container_name: app_postgres
runtime: runtime:
type: podman-quadlet type: podman-quadlet
container: quadlets:
file: app_postgres.container - file: app_postgres.container
unit: app_postgres.service
container_name: app_postgres
dropins_dir: app_postgres.container.d dropins_dir: app_postgres.container.d
profiles_dir: app_postgres.profiles.d profiles_dir: app_postgres.profiles.d
@ -46,6 +45,12 @@ schemas:
init: schemas/init.schema.json init: schemas/init.schema.json
``` ```
The `quadlets` list can contain any supported Podman Quadlet source type:
`.artifact`, `.build`, `.container`, `.image`, `.kube`, `.network`, `.pod`, and
`.volume`. If `unit` is omitted, Dew derives the default generated systemd unit
from the Quadlet filename. Declare `unit` when the Quadlet file uses a
`ServiceName=` override.
The package-level schema for this file is The package-level schema for this file is
`packages/infra/schemas/service-manifest.schema.json`. `packages/infra/schemas/service-manifest.schema.json`.
@ -73,7 +78,7 @@ and podman actions without applying them. Use `--scope user` for the default
user systemd path or `--scope system` for `/etc/containers/systemd`. user systemd path or `--scope system` for `/etc/containers/systemd`.
`dew infra up` installs missing Quadlet files, reloads systemd, then starts the `dew infra up` installs missing Quadlet files, reloads systemd, then starts the
unit. declared units.
## Samples ## Samples

View file

@ -205,7 +205,7 @@ class InfraListCommand extends _InfraSubcommand {
return; return;
} }
for (final manifest in manifests) { for (final manifest in manifests) {
print('${manifest.id}\t${manifest.name}\t${manifest.unit}'); print('${manifest.id}\t${manifest.name}\t${manifest.units.join(',')}');
} }
} }
} }

View file

@ -127,31 +127,46 @@ class InfraValidator {
if (manifest.id != dirId) { if (manifest.id != dirId) {
issue( issue(
manifest.manifestPath, manifest.manifestPath,
'service.id "${manifest.id}" must match directory "$dirId".', 'id "${manifest.id}" must match directory "$dirId".',
);
}
if (!manifest.unit.endsWith('.service')) {
issue(manifest.manifestPath, 'service.unit must end with .service.');
}
if (manifest.unit != manifest.expectedUnit) {
issue(
manifest.manifestPath,
'service.unit "${manifest.unit}" must match container file unit '
'"${manifest.expectedUnit}".',
); );
} }
await _requireFile(manifest, manifest.containerFilePath, issues); if (manifest.quadlets.isEmpty) {
issue(manifest.manifestPath, 'quadlets must contain at least one file.');
}
final quadletFiles = <String>{};
final quadletUnits = <String>{};
for (final quadlet in manifest.quadlets) {
if (!quadletFiles.add(quadlet.file)) {
issue(
manifest.manifestPath,
'quadlet file "${quadlet.file}" is declared more than once.',
);
}
if (!quadletUnits.add(quadlet.serviceUnit)) {
issue(
manifest.manifestPath,
'quadlet unit "${quadlet.serviceUnit}" is declared more than once.',
);
}
if (!quadlet.serviceUnit.endsWith('.service')) {
issue(
manifest.manifestPath,
'quadlet unit "${quadlet.serviceUnit}" must end with .service.',
);
}
await _requireFile(manifest, quadlet.filePath, issues);
await _requireDirectoryIfDeclared( await _requireDirectoryIfDeclared(
manifest, manifest,
manifest.dropinsDirPath, quadlet.dropinsDirPath,
issues, issues,
); );
await _requireDirectoryIfDeclared( await _requireDirectoryIfDeclared(
manifest, manifest,
manifest.profilesDirPath, quadlet.profilesDirPath,
issues, issues,
); );
}
await _validateJsonSchema( await _validateJsonSchema(
manifest, manifest,
label: 'configure schema', label: 'configure schema',

View file

@ -214,9 +214,16 @@ class PodmanQuadletRuntime implements ContainerRuntime {
Future<bool> isInstalled( Future<bool> isInstalled(
InfraServiceManifest manifest, InfraServiceManifest manifest,
InfraScope scope, InfraScope scope,
) async => ) async {
await fs.link(_targetContainerPath(manifest, scope)).exists() || if (manifest.quadlets.isEmpty) return false;
await fs.file(_targetContainerPath(manifest, scope)).exists(); for (final quadlet in manifest.quadlets) {
final target = _targetQuadletPath(quadlet, scope);
final exists =
await fs.link(target).exists() || await fs.file(target).exists();
if (!exists) return false;
}
return true;
}
@override @override
Future<InfraRuntimeResult> install( Future<InfraRuntimeResult> install(
@ -226,16 +233,22 @@ class PodmanQuadletRuntime implements ContainerRuntime {
}) async { }) async {
final actions = <String>[]; final actions = <String>[];
final targetDir = quadletSearchPath(scope, environment: environment); final targetDir = quadletSearchPath(scope, environment: environment);
final targetFile = _targetContainerPath(manifest, scope);
await _action( await _action(
actions, actions,
dryRun, dryRun,
'create $targetDir', 'create $targetDir',
() => fs.directory(targetDir).create(recursive: true), () => fs.directory(targetDir).create(recursive: true),
); );
await _link(actions, dryRun, manifest.containerFilePath, targetFile);
final dropinsPath = manifest.dropinsDirPath; for (final quadlet in manifest.quadlets) {
await _link(
actions,
dryRun,
quadlet.filePath,
_targetQuadletPath(quadlet, scope),
);
final dropinsPath = quadlet.dropinsDirPath;
if (dropinsPath != null && await fs.directory(dropinsPath).exists()) { if (dropinsPath != null && await fs.directory(dropinsPath).exists()) {
final targetDropins = p.join(targetDir, p.basename(dropinsPath)); final targetDropins = p.join(targetDir, p.basename(dropinsPath));
await _action( await _action(
@ -254,6 +267,7 @@ class PodmanQuadletRuntime implements ContainerRuntime {
); );
} }
} }
}
return InfraRuntimeResult(actions: actions); return InfraRuntimeResult(actions: actions);
} }
@ -265,18 +279,18 @@ class PodmanQuadletRuntime implements ContainerRuntime {
required bool dryRun, required bool dryRun,
}) async { }) async {
final actions = <String>[]; final actions = <String>[];
await _deletePath(actions, dryRun, _targetContainerPath(manifest, scope)); final targetDir = quadletSearchPath(scope, environment: environment);
final dropinsPath = manifest.dropinsDirPath; for (final quadlet in manifest.quadlets) {
await _deletePath(actions, dryRun, _targetQuadletPath(quadlet, scope));
final dropinsPath = quadlet.dropinsDirPath;
if (dropinsPath != null) { if (dropinsPath != null) {
await _deletePath( await _deletePath(
actions, actions,
dryRun, dryRun,
p.join( p.join(targetDir, p.basename(dropinsPath)),
quadletSearchPath(scope, environment: environment),
p.basename(dropinsPath),
),
); );
} }
}
return InfraRuntimeResult(actions: actions); return InfraRuntimeResult(actions: actions);
} }
@ -285,28 +299,31 @@ class PodmanQuadletRuntime implements ContainerRuntime {
InfraServiceManifest manifest, { InfraServiceManifest manifest, {
required InfraScope scope, required InfraScope scope,
required bool dryRun, required bool dryRun,
}) async => _systemctl(scope, ['start', manifest.unit], dryRun: dryRun); }) async => _systemctl(scope, ['start', ...manifest.units], dryRun: dryRun);
@override @override
Future<InfraRuntimeResult> stop( Future<InfraRuntimeResult> stop(
InfraServiceManifest manifest, { InfraServiceManifest manifest, {
required InfraScope scope, required InfraScope scope,
required bool dryRun, required bool dryRun,
}) async => _systemctl(scope, ['stop', manifest.unit], dryRun: dryRun); }) async => _systemctl(scope, ['stop', ...manifest.units], dryRun: dryRun);
@override @override
Future<InfraRuntimeResult> restart( Future<InfraRuntimeResult> restart(
InfraServiceManifest manifest, { InfraServiceManifest manifest, {
required InfraScope scope, required InfraScope scope,
required bool dryRun, required bool dryRun,
}) async => _systemctl(scope, ['restart', manifest.unit], dryRun: dryRun); }) async => _systemctl(scope, ['restart', ...manifest.units], dryRun: dryRun);
@override @override
Future<InfraRuntimeResult> status( Future<InfraRuntimeResult> status(
InfraServiceManifest manifest, { InfraServiceManifest manifest, {
required InfraScope scope, required InfraScope scope,
}) async => }) async => _systemctl(scope, [
_systemctl(scope, ['status', manifest.unit, '--no-pager'], dryRun: false); 'status',
...manifest.units,
'--no-pager',
], dryRun: false);
@override @override
Future<InfraRuntimeResult> logs( Future<InfraRuntimeResult> logs(
@ -317,8 +334,7 @@ class PodmanQuadletRuntime implements ContainerRuntime {
}) async { }) async {
final args = [ final args = [
if (scope == InfraScope.user) '--user', if (scope == InfraScope.user) '--user',
'-u', for (final unit in manifest.units) ...['-u', unit],
manifest.unit,
'-n', '-n',
'$lines', '$lines',
if (follow) '-f', if (follow) '-f',
@ -347,14 +363,23 @@ class PodmanQuadletRuntime implements ContainerRuntime {
var exitCode = 0; var exitCode = 0;
if (deleteContainer) { if (deleteContainer) {
final args = ['rm', '--ignore', '--force', manifest.containerName]; if (manifest.containerNames.isEmpty) {
actions.add('no container artifacts declared for ${manifest.id}');
}
for (final containerName in manifest.containerNames) {
final args = ['rm', '--ignore', '--force', containerName];
final action = 'podman ${args.join(' ')}'; final action = 'podman ${args.join(' ')}';
actions.add(action); actions.add(action);
if (!dryRun) { if (!dryRun) {
final result = await processRunner.run('podman', args); final result = await processRunner.run('podman', args);
exitCode = result.exitCode; if (exitCode == 0) exitCode = result.exitCode;
if (result.stdout.trim().isNotEmpty) outputs.add(result.stdout.trim()); if (result.stdout.trim().isNotEmpty) {
if (result.stderr.trim().isNotEmpty) errors.add(result.stderr.trim()); outputs.add(result.stdout.trim());
}
if (result.stderr.trim().isNotEmpty) {
errors.add(result.stderr.trim());
}
}
} }
} }
if (deleteData) { if (deleteData) {
@ -378,12 +403,10 @@ class PodmanQuadletRuntime implements ContainerRuntime {
required bool dryRun, required bool dryRun,
}) => _systemctl(scope, ['daemon-reload'], dryRun: dryRun); }) => _systemctl(scope, ['daemon-reload'], dryRun: dryRun);
String _targetContainerPath( String _targetQuadletPath(InfraQuadletManifest quadlet, InfraScope scope) =>
InfraServiceManifest manifest, p.join(
InfraScope scope,
) => p.join(
quadletSearchPath(scope, environment: environment), quadletSearchPath(scope, environment: environment),
p.basename(manifest.containerFile), p.basename(quadlet.file),
); );
Future<void> _link( Future<void> _link(

View file

@ -26,19 +26,133 @@ enum InfraRuntimeKind {
} }
} }
/// Supported Podman Quadlet source file kinds.
enum InfraQuadletKind {
/// Pulls an OCI artifact.
artifact('.artifact', '-artifact'),
/// Builds a container image from a Containerfile.
build('.build', '-build'),
/// Defines and manages a single container.
container('.container', ''),
/// Pulls and caches a container image.
image('.image', '-image'),
/// Deploys containers from Kubernetes YAML.
kube('.kube', ''),
/// Creates a Podman network.
network('.network', '-network'),
/// Creates a Podman pod.
pod('.pod', '-pod'),
/// Ensures a named Podman volume exists.
volume('.volume', '-volume');
const InfraQuadletKind(this.extension, this.defaultServiceSuffix);
/// Quadlet file extension, including the leading dot.
final String extension;
/// Suffix Podman adds to default generated service units.
final String defaultServiceSuffix;
/// Stable manifest identifier.
String get id => extension.substring(1);
/// Finds the kind represented by [file].
static InfraQuadletKind fromFile(String file) {
final extension = p.extension(file);
return values.firstWhere(
(kind) => kind.extension == extension,
orElse: () => throw FormatException(
'Unsupported Quadlet file extension "$extension".',
),
);
}
}
/// A single Podman Quadlet file declared by an infrastructure service.
class InfraQuadletManifest {
const InfraQuadletManifest({
required this.file,
required this.kind,
required this.serviceDir,
this.unit,
this.containerName,
this.dropinsDir,
this.profilesDir,
});
/// Relative path to the Quadlet source file.
final String file;
/// Quadlet type inferred from [file].
final InfraQuadletKind kind;
/// Absolute path to the service directory.
final String serviceDir;
/// Optional generated systemd unit override.
final String? unit;
/// Optional container name for cleanup operations.
final String? containerName;
/// Optional relative path to the Quadlet drop-ins directory.
final String? dropinsDir;
/// Optional relative path to profile drop-ins.
final String? profilesDir;
/// Absolute path to the Quadlet source file.
String get filePath => _resolve(file);
/// 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!);
/// Unit name Dew should manage for this Quadlet.
String get serviceUnit => unit ?? defaultServiceUnit;
/// Unit name generated by Podman when the Quadlet does not override it.
String get defaultServiceUnit {
final baseName = p.basenameWithoutExtension(file);
return '$baseName${kind.defaultServiceSuffix}.service';
}
/// Machine-readable summary.
Map<String, Object?> toJson() => {
'file': filePath,
'kind': kind.id,
'unit': serviceUnit,
'default_unit': defaultServiceUnit,
'container_name': containerName,
'dropins_dir': dropinsDirPath,
'profiles_dir': profilesDirPath,
};
String _resolve(String value) => p.isAbsolute(value)
? p.normalize(value)
: p.normalize(p.join(serviceDir, value));
}
/// Infrastructure service metadata loaded from `manifest.yaml`. /// Infrastructure service metadata loaded from `manifest.yaml`.
class InfraServiceManifest { class InfraServiceManifest {
const InfraServiceManifest({ const InfraServiceManifest({
required this.id, required this.id,
required this.name, required this.name,
required this.unit,
required this.containerName,
required this.runtime, required this.runtime,
required this.serviceDir, required this.serviceDir,
required this.manifestPath, required this.manifestPath,
required this.containerFile, required this.quadlets,
this.dropinsDir,
this.profilesDir,
this.configureSchema, this.configureSchema,
this.initSchema, this.initSchema,
}); });
@ -49,12 +163,6 @@ class InfraServiceManifest {
/// Human-friendly display name. /// Human-friendly display name.
final String 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. /// Runtime backend declared by the manifest.
final InfraRuntimeKind runtime; final InfraRuntimeKind runtime;
@ -64,14 +172,8 @@ class InfraServiceManifest {
/// Absolute path to `manifest.yaml`. /// Absolute path to `manifest.yaml`.
final String manifestPath; final String manifestPath;
/// Relative path to the primary Quadlet/container definition. /// Quadlet files deployed for this service.
final String containerFile; final List<InfraQuadletManifest> quadlets;
/// 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. /// Optional relative path to the configure JSON Schema.
final String? configureSchema; final String? configureSchema;
@ -79,17 +181,6 @@ class InfraServiceManifest {
/// Optional relative path to the init JSON Schema. /// Optional relative path to the init JSON Schema.
final String? initSchema; 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. /// Absolute path to the configure schema, if any.
String? get configureSchemaPath => String? get configureSchemaPath =>
configureSchema == null ? null : _resolve(configureSchema!); configureSchema == null ? null : _resolve(configureSchema!);
@ -107,9 +198,15 @@ class InfraServiceManifest {
/// Active init payload path. /// Active init payload path.
String get activeInitPath => p.join(configDir, 'init.json'); String get activeInitPath => p.join(configDir, 'init.json');
/// Unit name expected from the primary Quadlet filename. /// Units generated by the declared Quadlet files.
String get expectedUnit => List<String> get units =>
'${p.basenameWithoutExtension(containerFile)}.service'; quadlets.map((quadlet) => quadlet.serviceUnit).toList();
/// Container names declared for cleanup operations.
List<String> get containerNames => quadlets
.map((quadlet) => quadlet.containerName)
.whereType<String>()
.toList();
/// Decodes [contents] from YAML. /// Decodes [contents] from YAML.
factory InfraServiceManifest.parse({ factory InfraServiceManifest.parse({
@ -118,24 +215,22 @@ class InfraServiceManifest {
required String manifestPath, required String manifestPath,
}) { }) {
final map = _asMap(loadYaml(contents)); final map = _asMap(loadYaml(contents));
final service = _section(map, 'service');
final container = _section(map, 'container');
final schemas = _optionalSection(map, 'schemas'); final schemas = _optionalSection(map, 'schemas');
final runtimeSection = _optionalSection(map, 'runtime'); final runtimeSection = _optionalSection(map, 'runtime');
final quadlets = _requiredList(
map,
'quadlets',
).map((value) => _parseQuadlet(value, serviceDir: serviceDir)).toList();
return InfraServiceManifest( return InfraServiceManifest(
id: _requiredString(service, 'id'), id: _requiredString(map, 'id'),
name: _requiredString(service, 'name'), name: _requiredString(map, 'name'),
unit: _requiredString(service, 'unit'),
containerName: _requiredString(service, 'container_name'),
runtime: InfraRuntimeKind.fromManifestValue( runtime: InfraRuntimeKind.fromManifestValue(
runtimeSection == null ? null : _optionalString(runtimeSection, 'type'), runtimeSection == null ? null : _optionalString(runtimeSection, 'type'),
), ),
serviceDir: serviceDir, serviceDir: serviceDir,
manifestPath: manifestPath, manifestPath: manifestPath,
containerFile: _requiredString(container, 'file'), quadlets: quadlets,
dropinsDir: _optionalString(container, 'dropins_dir'),
profilesDir: _optionalString(container, 'profiles_dir'),
configureSchema: schemas == null configureSchema: schemas == null
? null ? null
: _optionalString(schemas, 'configure'), : _optionalString(schemas, 'configure'),
@ -147,13 +242,10 @@ class InfraServiceManifest {
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {
'id': id, 'id': id,
'name': name, 'name': name,
'unit': unit,
'container_name': containerName,
'runtime': runtime.id, 'runtime': runtime.id,
'manifest': manifestPath, 'manifest': manifestPath,
'container_file': containerFilePath, 'units': units,
'dropins_dir': dropinsDirPath, 'quadlets': quadlets.map((quadlet) => quadlet.toJson()).toList(),
'profiles_dir': profilesDirPath,
'configure_schema': configureSchemaPath, 'configure_schema': configureSchemaPath,
'init_schema': initSchemaPath, 'init_schema': initSchemaPath,
'active_config': activeConfigurePath, 'active_config': activeConfigurePath,
@ -165,6 +257,28 @@ class InfraServiceManifest {
: p.normalize(p.join(serviceDir, value)); : p.normalize(p.join(serviceDir, value));
} }
InfraQuadletManifest _parseQuadlet(
dynamic value, {
required String serviceDir,
}) {
if (value is! Map) {
throw const FormatException(
'manifest.yaml field "quadlets" must list objects.',
);
}
final map = value.map((key, value) => MapEntry('$key', value));
final file = _requiredString(map, 'file');
return InfraQuadletManifest(
file: file,
kind: InfraQuadletKind.fromFile(file),
serviceDir: serviceDir,
unit: _optionalString(map, 'unit'),
containerName: _optionalString(map, 'container_name'),
dropinsDir: _optionalString(map, 'dropins_dir'),
profilesDir: _optionalString(map, 'profiles_dir'),
);
}
Map<String, dynamic> _asMap(dynamic value) { Map<String, dynamic> _asMap(dynamic value) {
if (value is YamlMap) { if (value is YamlMap) {
return value.map((key, value) => MapEntry('$key', _asYamlValue(value))); return value.map((key, value) => MapEntry('$key', _asYamlValue(value)));
@ -182,14 +296,6 @@ dynamic _asYamlValue(dynamic value) {
return value; 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('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) {
final value = map[key]; final value = map[key];
if (value == null) return null; if (value == null) return null;
@ -199,6 +305,12 @@ Map<String, dynamic>? _optionalSection(Map<String, dynamic> map, String key) {
throw FormatException('manifest.yaml field "$key" must be an object.'); throw FormatException('manifest.yaml field "$key" must be an object.');
} }
List<dynamic> _requiredList(Map<String, dynamic> map, String key) {
final value = map[key];
if (value is List) return value;
throw FormatException('manifest.yaml is missing list "$key".');
}
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) {

View file

@ -5,12 +5,7 @@
"description": "Schema for .project/infrastructure/services/<service-id>/manifest.yaml.", "description": "Schema for .project/infrastructure/services/<service-id>/manifest.yaml.",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["service", "container", "schemas"], "required": ["id", "name", "quadlets", "schemas"],
"properties": {
"service": {
"type": "object",
"additionalProperties": false,
"required": ["id", "name", "unit", "container_name"],
"properties": { "properties": {
"id": { "id": {
"type": "string", "type": "string",
@ -21,17 +16,6 @@
"type": "string", "type": "string",
"minLength": 1 "minLength": 1
}, },
"unit": {
"type": "string",
"minLength": 1,
"pattern": "^[^/]+\\.service$"
},
"container_name": {
"type": "string",
"minLength": 1
}
}
},
"runtime": { "runtime": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@ -44,15 +28,27 @@
} }
} }
}, },
"container": { "quadlets": {
"type": "array",
"minItems": 1,
"items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["file", "dropins_dir", "profiles_dir"], "required": ["file"],
"properties": { "properties": {
"file": { "file": {
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"pattern": "^[^/].*\\.container$" "pattern": "^[^/].*\\.(artifact|build|container|image|kube|network|pod|volume)$"
},
"unit": {
"type": "string",
"minLength": 1,
"pattern": "^[^/]+\\.service$"
},
"container_name": {
"type": "string",
"minLength": 1
}, },
"dropins_dir": { "dropins_dir": {
"type": "string", "type": "string",
@ -63,6 +59,7 @@
"minLength": 1 "minLength": 1
} }
} }
}
}, },
"schemas": { "schemas": {
"type": "object", "type": "object",

View file

@ -46,7 +46,7 @@ void main() {
group('InfraRepository', () { group('InfraRepository', () {
test('discovers service manifests', () async { test('discovers service manifests', () async {
final fs = MemoryFileSystem.test(); final fs = MemoryFileSystem.test();
_writeService(fs); _writeService(fs, includeNetwork: true);
final repository = InfraRepository( final repository = InfraRepository(
infraDir: '/project/.project/infrastructure', infraDir: '/project/.project/infrastructure',
@ -57,9 +57,16 @@ void main() {
expect(manifests, hasLength(1)); expect(manifests, hasLength(1));
expect(manifests.single.id, 'postgres'); expect(manifests.single.id, 'postgres');
expect(manifests.single.runtime, InfraRuntimeKind.podmanQuadlet); expect(manifests.single.runtime, InfraRuntimeKind.podmanQuadlet);
expect(manifests.single.units, [
'app_postgres.service',
'app_postgres-network.service',
]);
expect( expect(
manifests.single.containerFilePath, manifests.single.quadlets.map((quadlet) => quadlet.filePath),
containsAll([
'/project/.project/infrastructure/services/postgres/app_postgres.container', '/project/.project/infrastructure/services/postgres/app_postgres.container',
'/project/.project/infrastructure/services/postgres/app_postgres.network',
]),
); );
}); });
}); });
@ -78,9 +85,9 @@ void main() {
expect(issues, isEmpty); expect(issues, isEmpty);
}); });
test('reports service id and unit mismatches', () async { test('reports service id and invalid quadlet units', () async {
final fs = MemoryFileSystem.test(); final fs = MemoryFileSystem.test();
_writeService(fs, serviceId: 'wrong', unit: 'wrong.service'); _writeService(fs, serviceId: 'wrong', unit: 'wrong');
final manifest = final manifest =
await InfraRepository( await InfraRepository(
infraDir: '/project/.project/infrastructure', infraDir: '/project/.project/infrastructure',
@ -98,7 +105,7 @@ void main() {
); );
expect( expect(
issues.map((issue) => issue.message).join('\n'), issues.map((issue) => issue.message).join('\n'),
contains('must match container file unit'), contains('must end with .service'),
); );
}); });
}); });
@ -108,7 +115,7 @@ void main() {
'install dry-run reports symlink actions without writing files', 'install dry-run reports symlink actions without writing files',
() async { () async {
final fs = MemoryFileSystem.test(); final fs = MemoryFileSystem.test();
_writeService(fs); _writeService(fs, includeNetwork: true);
final manifest = await InfraRepository( final manifest = await InfraRepository(
infraDir: '/project/.project/infrastructure', infraDir: '/project/.project/infrastructure',
fs: fs, fs: fs,
@ -125,6 +132,7 @@ void main() {
); );
expect(result.actions.join('\n'), contains('app_postgres.container')); expect(result.actions.join('\n'), contains('app_postgres.container'));
expect(result.actions.join('\n'), contains('app_postgres.network'));
expect( expect(
await fs await fs
.link( .link(
@ -165,6 +173,7 @@ void _writeService(
MemoryFileSystem fs, { MemoryFileSystem fs, {
String serviceId = 'postgres', String serviceId = 'postgres',
String unit = 'app_postgres.service', String unit = 'app_postgres.service',
bool includeNetwork = false,
}) { }) {
final serviceDir = fs.directory( final serviceDir = fs.directory(
'/project/.project/infrastructure/services/postgres', '/project/.project/infrastructure/services/postgres',
@ -174,26 +183,36 @@ void _writeService(
fs fs
.file('${serviceDir.path}/app_postgres.container') .file('${serviceDir.path}/app_postgres.container')
.writeAsStringSync('[Container]\nImage=postgres:16\n'); .writeAsStringSync('[Container]\nImage=postgres:16\n');
if (includeNetwork) {
fs
.file('${serviceDir.path}/app_postgres.network')
.writeAsStringSync('[Network]\nNetworkName=app_postgres\n');
}
fs fs
.file('${serviceDir.path}/configure.schema.json') .file('${serviceDir.path}/configure.schema.json')
.writeAsStringSync('{"type":"object"}'); .writeAsStringSync('{"type":"object"}');
fs fs
.file('${serviceDir.path}/init.schema.json') .file('${serviceDir.path}/init.schema.json')
.writeAsStringSync('{"type":"object"}'); .writeAsStringSync('{"type":"object"}');
final networkQuadlet = includeNetwork
? '''
- file: app_postgres.network
'''
: '';
fs.file('${serviceDir.path}/manifest.yaml').writeAsStringSync(''' fs.file('${serviceDir.path}/manifest.yaml').writeAsStringSync('''
service:
id: $serviceId id: $serviceId
name: PostgreSQL name: PostgreSQL
unit: $unit
container_name: app_postgres
runtime: runtime:
type: podman-quadlet type: podman-quadlet
container: quadlets:
file: app_postgres.container - file: app_postgres.container
unit: $unit
container_name: app_postgres
dropins_dir: app_postgres.container.d dropins_dir: app_postgres.container.d
profiles_dir: app_postgres.profiles.d profiles_dir: app_postgres.profiles.d
$networkQuadlet
schemas: schemas:
configure: configure.schema.json configure: configure.schema.json
@ -202,18 +221,19 @@ schemas:
} }
Map<String, Object?> _manifestObject() => { Map<String, Object?> _manifestObject() => {
'service': {
'id': 'postgres', 'id': 'postgres',
'name': 'PostgreSQL', 'name': 'PostgreSQL',
'runtime': {'type': 'podman-quadlet'},
'quadlets': [
{
'file': 'app_postgres.container',
'unit': 'app_postgres.service', 'unit': 'app_postgres.service',
'container_name': 'app_postgres', 'container_name': 'app_postgres',
},
'runtime': {'type': 'podman-quadlet'},
'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',
}, },
{'file': 'app_postgres.network'},
],
'schemas': {'configure': 'configure.schema.json', 'init': 'init.schema.json'}, 'schemas': {'configure': 'configure.schema.json', 'init': 'init.schema.json'},
}; };