From 9bc5779221660413637540c6373be61142bcbac4 Mon Sep 17 00:00:00 2001 From: Chris Hendrickson Date: Tue, 5 May 2026 00:07:11 -0400 Subject: [PATCH] Support multiple quadlets in infra manifests --- .../services/postgresql-18/README.md | 4 +- .../services/postgresql-18/manifest.yaml | 17 +- .project/kanban/done/DEW-0034.md | 8 + docs/features/infra.md | 25 +- packages/infra/lib/src/dew_infra_base.dart | 2 +- packages/infra/lib/src/infra_repository.dart | 59 +++-- packages/infra/lib/src/infra_runtime.dart | 121 ++++++---- packages/infra/lib/src/service_manifest.dart | 222 +++++++++++++----- .../schemas/service-manifest.schema.json | 81 +++---- packages/infra/test/dew_infra_test.dart | 74 +++--- 10 files changed, 396 insertions(+), 217 deletions(-) create mode 100644 .project/kanban/done/DEW-0034.md diff --git a/.project/infrastructure/services/postgresql-18/README.md b/.project/infrastructure/services/postgresql-18/README.md index c3d09b9..f6723c5 100644 --- a/.project/infrastructure/services/postgresql-18/README.md +++ b/.project/infrastructure/services/postgresql-18/README.md @@ -24,5 +24,5 @@ dew infra down postgresql-18 The named volume is intentionally retained after stopping the service. -Service-specific configure and init schemas live under `schemas/` and are -referenced from `manifest.yaml`. +Service-specific configure and init schemas live under `schemas/`. The manifest +declares the PostgreSQL container under its `quadlets` list. diff --git a/.project/infrastructure/services/postgresql-18/manifest.yaml b/.project/infrastructure/services/postgresql-18/manifest.yaml index 2ec9f0a..169974a 100644 --- a/.project/infrastructure/services/postgresql-18/manifest.yaml +++ b/.project/infrastructure/services/postgresql-18/manifest.yaml @@ -1,16 +1,15 @@ -service: - id: postgresql-18 - name: PostgreSQL 18 - unit: dew_postgresql-18.service - container_name: dew_postgresql-18 +id: postgresql-18 +name: PostgreSQL 18 runtime: type: podman-quadlet -container: - file: dew_postgresql-18.container - dropins_dir: dew_postgresql-18.container.d - profiles_dir: dew_postgresql-18.profiles.d +quadlets: + - file: dew_postgresql-18.container + unit: dew_postgresql-18.service + container_name: dew_postgresql-18 + dropins_dir: dew_postgresql-18.container.d + profiles_dir: dew_postgresql-18.profiles.d schemas: configure: schemas/configure.schema.json diff --git a/.project/kanban/done/DEW-0034.md b/.project/kanban/done/DEW-0034.md new file mode 100644 index 0000000..bdce444 --- /dev/null +++ b/.project/kanban/done/DEW-0034.md @@ -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. diff --git a/docs/features/infra.md b/docs/features/infra.md index 3039c9d..a6280a2 100644 --- a/docs/features/infra.md +++ b/docs/features/infra.md @@ -27,25 +27,30 @@ workflows. ## Manifest ```yaml -service: - id: postgres - name: PostgreSQL - unit: app_postgres.service - container_name: app_postgres +id: postgres +name: PostgreSQL runtime: type: podman-quadlet -container: - file: app_postgres.container - dropins_dir: app_postgres.container.d - profiles_dir: app_postgres.profiles.d +quadlets: + - file: app_postgres.container + unit: app_postgres.service + container_name: app_postgres + dropins_dir: app_postgres.container.d + profiles_dir: app_postgres.profiles.d schemas: configure: schemas/configure.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 `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`. `dew infra up` installs missing Quadlet files, reloads systemd, then starts the -unit. +declared units. ## Samples diff --git a/packages/infra/lib/src/dew_infra_base.dart b/packages/infra/lib/src/dew_infra_base.dart index 7e39f48..39cbf94 100644 --- a/packages/infra/lib/src/dew_infra_base.dart +++ b/packages/infra/lib/src/dew_infra_base.dart @@ -205,7 +205,7 @@ class InfraListCommand extends _InfraSubcommand { return; } for (final manifest in manifests) { - print('${manifest.id}\t${manifest.name}\t${manifest.unit}'); + print('${manifest.id}\t${manifest.name}\t${manifest.units.join(',')}'); } } } diff --git a/packages/infra/lib/src/infra_repository.dart b/packages/infra/lib/src/infra_repository.dart index efdb114..9ffd716 100644 --- a/packages/infra/lib/src/infra_repository.dart +++ b/packages/infra/lib/src/infra_repository.dart @@ -127,31 +127,46 @@ class InfraValidator { if (manifest.id != dirId) { issue( manifest.manifestPath, - 'service.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}".', + 'id "${manifest.id}" must match directory "$dirId".', ); } - await _requireFile(manifest, manifest.containerFilePath, issues); - await _requireDirectoryIfDeclared( - manifest, - manifest.dropinsDirPath, - issues, - ); - await _requireDirectoryIfDeclared( - manifest, - manifest.profilesDirPath, - issues, - ); + if (manifest.quadlets.isEmpty) { + issue(manifest.manifestPath, 'quadlets must contain at least one file.'); + } + final quadletFiles = {}; + final quadletUnits = {}; + 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( + manifest, + quadlet.dropinsDirPath, + issues, + ); + await _requireDirectoryIfDeclared( + manifest, + quadlet.profilesDirPath, + issues, + ); + } await _validateJsonSchema( manifest, label: 'configure schema', diff --git a/packages/infra/lib/src/infra_runtime.dart b/packages/infra/lib/src/infra_runtime.dart index dc60b84..bbb3f18 100644 --- a/packages/infra/lib/src/infra_runtime.dart +++ b/packages/infra/lib/src/infra_runtime.dart @@ -214,9 +214,16 @@ class PodmanQuadletRuntime implements ContainerRuntime { Future isInstalled( InfraServiceManifest manifest, InfraScope scope, - ) async => - await fs.link(_targetContainerPath(manifest, scope)).exists() || - await fs.file(_targetContainerPath(manifest, scope)).exists(); + ) async { + if (manifest.quadlets.isEmpty) return false; + 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 Future install( @@ -226,32 +233,39 @@ class PodmanQuadletRuntime implements ContainerRuntime { }) async { final actions = []; final targetDir = quadletSearchPath(scope, environment: environment); - final targetFile = _targetContainerPath(manifest, scope); await _action( actions, dryRun, 'create $targetDir', () => fs.directory(targetDir).create(recursive: true), ); - await _link(actions, dryRun, manifest.containerFilePath, targetFile); - final dropinsPath = manifest.dropinsDirPath; - if (dropinsPath != null && await fs.directory(dropinsPath).exists()) { - final targetDropins = p.join(targetDir, p.basename(dropinsPath)); - await _action( + for (final quadlet in manifest.quadlets) { + await _link( actions, dryRun, - 'create $targetDropins', - () => fs.directory(targetDropins).create(recursive: true), + quadlet.filePath, + _targetQuadletPath(quadlet, scope), ); - await for (final entity in fs.directory(dropinsPath).list()) { - if (entity is! File || p.extension(entity.path) != '.conf') continue; - await _link( + + final dropinsPath = quadlet.dropinsDirPath; + if (dropinsPath != null && await fs.directory(dropinsPath).exists()) { + final targetDropins = p.join(targetDir, p.basename(dropinsPath)); + await _action( actions, dryRun, - entity.path, - p.join(targetDropins, p.basename(entity.path)), + 'create $targetDropins', + () => fs.directory(targetDropins).create(recursive: true), ); + await for (final entity in fs.directory(dropinsPath).list()) { + if (entity is! File || p.extension(entity.path) != '.conf') continue; + await _link( + actions, + dryRun, + entity.path, + p.join(targetDropins, p.basename(entity.path)), + ); + } } } @@ -265,17 +279,17 @@ class PodmanQuadletRuntime implements ContainerRuntime { required bool dryRun, }) async { final actions = []; - await _deletePath(actions, dryRun, _targetContainerPath(manifest, scope)); - final dropinsPath = manifest.dropinsDirPath; - if (dropinsPath != null) { - await _deletePath( - actions, - dryRun, - p.join( - quadletSearchPath(scope, environment: environment), - p.basename(dropinsPath), - ), - ); + final targetDir = quadletSearchPath(scope, environment: environment); + for (final quadlet in manifest.quadlets) { + await _deletePath(actions, dryRun, _targetQuadletPath(quadlet, scope)); + final dropinsPath = quadlet.dropinsDirPath; + if (dropinsPath != null) { + await _deletePath( + actions, + dryRun, + p.join(targetDir, p.basename(dropinsPath)), + ); + } } return InfraRuntimeResult(actions: actions); } @@ -285,28 +299,31 @@ class PodmanQuadletRuntime implements ContainerRuntime { InfraServiceManifest manifest, { required InfraScope scope, required bool dryRun, - }) async => _systemctl(scope, ['start', manifest.unit], dryRun: dryRun); + }) async => _systemctl(scope, ['start', ...manifest.units], dryRun: dryRun); @override Future stop( InfraServiceManifest manifest, { required InfraScope scope, required bool dryRun, - }) async => _systemctl(scope, ['stop', manifest.unit], dryRun: dryRun); + }) async => _systemctl(scope, ['stop', ...manifest.units], dryRun: dryRun); @override Future restart( InfraServiceManifest manifest, { required InfraScope scope, required bool dryRun, - }) async => _systemctl(scope, ['restart', manifest.unit], dryRun: dryRun); + }) async => _systemctl(scope, ['restart', ...manifest.units], dryRun: dryRun); @override Future status( InfraServiceManifest manifest, { required InfraScope scope, - }) async => - _systemctl(scope, ['status', manifest.unit, '--no-pager'], dryRun: false); + }) async => _systemctl(scope, [ + 'status', + ...manifest.units, + '--no-pager', + ], dryRun: false); @override Future logs( @@ -317,8 +334,7 @@ class PodmanQuadletRuntime implements ContainerRuntime { }) async { final args = [ if (scope == InfraScope.user) '--user', - '-u', - manifest.unit, + for (final unit in manifest.units) ...['-u', unit], '-n', '$lines', if (follow) '-f', @@ -347,14 +363,23 @@ class PodmanQuadletRuntime implements ContainerRuntime { var exitCode = 0; if (deleteContainer) { - final args = ['rm', '--ignore', '--force', manifest.containerName]; - final action = 'podman ${args.join(' ')}'; - actions.add(action); - if (!dryRun) { - final result = await processRunner.run('podman', args); - exitCode = result.exitCode; - if (result.stdout.trim().isNotEmpty) outputs.add(result.stdout.trim()); - if (result.stderr.trim().isNotEmpty) errors.add(result.stderr.trim()); + 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(' ')}'; + actions.add(action); + if (!dryRun) { + final result = await processRunner.run('podman', args); + if (exitCode == 0) exitCode = result.exitCode; + if (result.stdout.trim().isNotEmpty) { + outputs.add(result.stdout.trim()); + } + if (result.stderr.trim().isNotEmpty) { + errors.add(result.stderr.trim()); + } + } } } if (deleteData) { @@ -378,13 +403,11 @@ class PodmanQuadletRuntime implements ContainerRuntime { required bool dryRun, }) => _systemctl(scope, ['daemon-reload'], dryRun: dryRun); - String _targetContainerPath( - InfraServiceManifest manifest, - InfraScope scope, - ) => p.join( - quadletSearchPath(scope, environment: environment), - p.basename(manifest.containerFile), - ); + String _targetQuadletPath(InfraQuadletManifest quadlet, InfraScope scope) => + p.join( + quadletSearchPath(scope, environment: environment), + p.basename(quadlet.file), + ); Future _link( List actions, diff --git a/packages/infra/lib/src/service_manifest.dart b/packages/infra/lib/src/service_manifest.dart index 3fb53cc..461317a 100644 --- a/packages/infra/lib/src/service_manifest.dart +++ b/packages/infra/lib/src/service_manifest.dart @@ -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 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`. class InfraServiceManifest { const InfraServiceManifest({ required this.id, required this.name, - required this.unit, - required this.containerName, required this.runtime, required this.serviceDir, required this.manifestPath, - required this.containerFile, - this.dropinsDir, - this.profilesDir, + required this.quadlets, this.configureSchema, this.initSchema, }); @@ -49,12 +163,6 @@ class InfraServiceManifest { /// Human-friendly display 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. final InfraRuntimeKind runtime; @@ -64,14 +172,8 @@ class InfraServiceManifest { /// Absolute path to `manifest.yaml`. final String manifestPath; - /// Relative path to the primary Quadlet/container definition. - final String containerFile; - - /// Optional relative path to the Quadlet drop-ins directory. - final String? dropinsDir; - - /// Optional relative path to profile drop-ins. - final String? profilesDir; + /// Quadlet files deployed for this service. + final List quadlets; /// Optional relative path to the configure JSON Schema. final String? configureSchema; @@ -79,17 +181,6 @@ class InfraServiceManifest { /// Optional relative path to the init JSON Schema. 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. String? get configureSchemaPath => configureSchema == null ? null : _resolve(configureSchema!); @@ -107,9 +198,15 @@ class InfraServiceManifest { /// Active init payload path. String get activeInitPath => p.join(configDir, 'init.json'); - /// Unit name expected from the primary Quadlet filename. - String get expectedUnit => - '${p.basenameWithoutExtension(containerFile)}.service'; + /// Units generated by the declared Quadlet files. + List get units => + quadlets.map((quadlet) => quadlet.serviceUnit).toList(); + + /// Container names declared for cleanup operations. + List get containerNames => quadlets + .map((quadlet) => quadlet.containerName) + .whereType() + .toList(); /// Decodes [contents] from YAML. factory InfraServiceManifest.parse({ @@ -118,24 +215,22 @@ class InfraServiceManifest { required String manifestPath, }) { final map = _asMap(loadYaml(contents)); - final service = _section(map, 'service'); - final container = _section(map, 'container'); final schemas = _optionalSection(map, 'schemas'); final runtimeSection = _optionalSection(map, 'runtime'); + final quadlets = _requiredList( + map, + 'quadlets', + ).map((value) => _parseQuadlet(value, serviceDir: serviceDir)).toList(); return InfraServiceManifest( - id: _requiredString(service, 'id'), - name: _requiredString(service, 'name'), - unit: _requiredString(service, 'unit'), - containerName: _requiredString(service, 'container_name'), + id: _requiredString(map, 'id'), + name: _requiredString(map, 'name'), runtime: InfraRuntimeKind.fromManifestValue( runtimeSection == null ? null : _optionalString(runtimeSection, 'type'), ), serviceDir: serviceDir, manifestPath: manifestPath, - containerFile: _requiredString(container, 'file'), - dropinsDir: _optionalString(container, 'dropins_dir'), - profilesDir: _optionalString(container, 'profiles_dir'), + quadlets: quadlets, configureSchema: schemas == null ? null : _optionalString(schemas, 'configure'), @@ -147,13 +242,10 @@ class InfraServiceManifest { Map toJson() => { 'id': id, 'name': name, - 'unit': unit, - 'container_name': containerName, 'runtime': runtime.id, 'manifest': manifestPath, - 'container_file': containerFilePath, - 'dropins_dir': dropinsDirPath, - 'profiles_dir': profilesDirPath, + 'units': units, + 'quadlets': quadlets.map((quadlet) => quadlet.toJson()).toList(), 'configure_schema': configureSchemaPath, 'init_schema': initSchemaPath, 'active_config': activeConfigurePath, @@ -165,6 +257,28 @@ class InfraServiceManifest { : 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 _asMap(dynamic value) { if (value is YamlMap) { return value.map((key, value) => MapEntry('$key', _asYamlValue(value))); @@ -182,14 +296,6 @@ dynamic _asYamlValue(dynamic value) { 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('manifest.yaml is missing "$key".'); -} - Map? _optionalSection(Map map, String key) { final value = map[key]; if (value == null) return null; @@ -199,6 +305,12 @@ Map? _optionalSection(Map map, String key) { throw FormatException('manifest.yaml field "$key" must be an object.'); } +List _requiredList(Map map, String key) { + final value = map[key]; + if (value is List) return value; + throw FormatException('manifest.yaml is missing list "$key".'); +} + String _requiredString(Map map, String key) { final value = _optionalString(map, key); if (value == null || value.isEmpty) { diff --git a/packages/infra/schemas/service-manifest.schema.json b/packages/infra/schemas/service-manifest.schema.json index f116f77..85ad2d0 100644 --- a/packages/infra/schemas/service-manifest.schema.json +++ b/packages/infra/schemas/service-manifest.schema.json @@ -5,32 +5,16 @@ "description": "Schema for .project/infrastructure/services//manifest.yaml.", "type": "object", "additionalProperties": false, - "required": ["service", "container", "schemas"], + "required": ["id", "name", "quadlets", "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 - } - } + "id": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z0-9][a-z0-9_.-]*$" + }, + "name": { + "type": "string", + "minLength": 1 }, "runtime": { "type": "object", @@ -44,23 +28,36 @@ } } }, - "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 + "quadlets": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["file"], + "properties": { + "file": { + "type": "string", + "minLength": 1, + "pattern": "^[^/].*\\.(artifact|build|container|image|kube|network|pod|volume)$" + }, + "unit": { + "type": "string", + "minLength": 1, + "pattern": "^[^/]+\\.service$" + }, + "container_name": { + "type": "string", + "minLength": 1 + }, + "dropins_dir": { + "type": "string", + "minLength": 1 + }, + "profiles_dir": { + "type": "string", + "minLength": 1 + } } } }, diff --git a/packages/infra/test/dew_infra_test.dart b/packages/infra/test/dew_infra_test.dart index 8f57a93..9919cd7 100644 --- a/packages/infra/test/dew_infra_test.dart +++ b/packages/infra/test/dew_infra_test.dart @@ -46,7 +46,7 @@ void main() { group('InfraRepository', () { test('discovers service manifests', () async { final fs = MemoryFileSystem.test(); - _writeService(fs); + _writeService(fs, includeNetwork: true); final repository = InfraRepository( infraDir: '/project/.project/infrastructure', @@ -57,9 +57,16 @@ void main() { expect(manifests, hasLength(1)); expect(manifests.single.id, 'postgres'); expect(manifests.single.runtime, InfraRuntimeKind.podmanQuadlet); + expect(manifests.single.units, [ + 'app_postgres.service', + 'app_postgres-network.service', + ]); expect( - manifests.single.containerFilePath, - '/project/.project/infrastructure/services/postgres/app_postgres.container', + manifests.single.quadlets.map((quadlet) => quadlet.filePath), + containsAll([ + '/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); }); - test('reports service id and unit mismatches', () async { + test('reports service id and invalid quadlet units', () async { final fs = MemoryFileSystem.test(); - _writeService(fs, serviceId: 'wrong', unit: 'wrong.service'); + _writeService(fs, serviceId: 'wrong', unit: 'wrong'); final manifest = await InfraRepository( infraDir: '/project/.project/infrastructure', @@ -98,7 +105,7 @@ void main() { ); expect( 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', () async { final fs = MemoryFileSystem.test(); - _writeService(fs); + _writeService(fs, includeNetwork: true); final manifest = await InfraRepository( infraDir: '/project/.project/infrastructure', 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.network')); expect( await fs .link( @@ -165,6 +173,7 @@ void _writeService( MemoryFileSystem fs, { String serviceId = 'postgres', String unit = 'app_postgres.service', + bool includeNetwork = false, }) { final serviceDir = fs.directory( '/project/.project/infrastructure/services/postgres', @@ -174,26 +183,36 @@ void _writeService( fs .file('${serviceDir.path}/app_postgres.container') .writeAsStringSync('[Container]\nImage=postgres:16\n'); + if (includeNetwork) { + fs + .file('${serviceDir.path}/app_postgres.network') + .writeAsStringSync('[Network]\nNetworkName=app_postgres\n'); + } fs .file('${serviceDir.path}/configure.schema.json') .writeAsStringSync('{"type":"object"}'); fs .file('${serviceDir.path}/init.schema.json') .writeAsStringSync('{"type":"object"}'); + final networkQuadlet = includeNetwork + ? ''' + - file: app_postgres.network +''' + : ''; fs.file('${serviceDir.path}/manifest.yaml').writeAsStringSync(''' -service: - id: $serviceId - name: PostgreSQL - unit: $unit - container_name: app_postgres +id: $serviceId +name: PostgreSQL runtime: type: podman-quadlet -container: - file: app_postgres.container - dropins_dir: app_postgres.container.d - profiles_dir: app_postgres.profiles.d +quadlets: + - file: app_postgres.container + unit: $unit + container_name: app_postgres + dropins_dir: app_postgres.container.d + profiles_dir: app_postgres.profiles.d +$networkQuadlet schemas: configure: configure.schema.json @@ -202,18 +221,19 @@ schemas: } Map _manifestObject() => { - 'service': { - 'id': 'postgres', - 'name': 'PostgreSQL', - 'unit': 'app_postgres.service', - 'container_name': 'app_postgres', - }, + 'id': 'postgres', + 'name': 'PostgreSQL', 'runtime': {'type': 'podman-quadlet'}, - 'container': { - 'file': 'app_postgres.container', - 'dropins_dir': 'app_postgres.container.d', - 'profiles_dir': 'app_postgres.profiles.d', - }, + 'quadlets': [ + { + 'file': 'app_postgres.container', + 'unit': 'app_postgres.service', + 'container_name': 'app_postgres', + 'dropins_dir': 'app_postgres.container.d', + 'profiles_dir': 'app_postgres.profiles.d', + }, + {'file': 'app_postgres.network'}, + ], 'schemas': {'configure': 'configure.schema.json', 'init': 'init.schema.json'}, };