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

View file

@ -1,14 +1,13 @@
service:
id: postgresql-18
name: PostgreSQL 18
unit: dew_postgresql-18.service
container_name: dew_postgresql-18
runtime:
type: podman-quadlet
container:
file: dew_postgresql-18.container
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

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
```yaml
service:
id: postgres
name: PostgreSQL
unit: app_postgres.service
container_name: app_postgres
runtime:
type: podman-quadlet
container:
file: app_postgres.container
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
@ -46,6 +45,12 @@ schemas:
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

View file

@ -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(',')}');
}
}
}

View file

@ -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);
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(
manifest,
manifest.dropinsDirPath,
quadlet.dropinsDirPath,
issues,
);
await _requireDirectoryIfDeclared(
manifest,
manifest.profilesDirPath,
quadlet.profilesDirPath,
issues,
);
}
await _validateJsonSchema(
manifest,
label: 'configure schema',

View file

@ -214,9 +214,16 @@ class PodmanQuadletRuntime implements ContainerRuntime {
Future<bool> 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<InfraRuntimeResult> install(
@ -226,16 +233,22 @@ class PodmanQuadletRuntime implements ContainerRuntime {
}) async {
final actions = <String>[];
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;
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()) {
final targetDropins = p.join(targetDir, p.basename(dropinsPath));
await _action(
@ -254,6 +267,7 @@ class PodmanQuadletRuntime implements ContainerRuntime {
);
}
}
}
return InfraRuntimeResult(actions: actions);
}
@ -265,18 +279,18 @@ class PodmanQuadletRuntime implements ContainerRuntime {
required bool dryRun,
}) async {
final actions = <String>[];
await _deletePath(actions, dryRun, _targetContainerPath(manifest, scope));
final dropinsPath = manifest.dropinsDirPath;
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(
quadletSearchPath(scope, environment: environment),
p.basename(dropinsPath),
),
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<InfraRuntimeResult> 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<InfraRuntimeResult> 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<InfraRuntimeResult> 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<InfraRuntimeResult> 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];
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);
exitCode = result.exitCode;
if (result.stdout.trim().isNotEmpty) outputs.add(result.stdout.trim());
if (result.stderr.trim().isNotEmpty) errors.add(result.stderr.trim());
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,12 +403,10 @@ class PodmanQuadletRuntime implements ContainerRuntime {
required bool dryRun,
}) => _systemctl(scope, ['daemon-reload'], dryRun: dryRun);
String _targetContainerPath(
InfraServiceManifest manifest,
InfraScope scope,
) => p.join(
String _targetQuadletPath(InfraQuadletManifest quadlet, InfraScope scope) =>
p.join(
quadletSearchPath(scope, environment: environment),
p.basename(manifest.containerFile),
p.basename(quadlet.file),
);
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`.
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<InfraQuadletManifest> 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<String> get units =>
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.
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<String, Object?> 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<String, dynamic> _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<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) {
final value = map[key];
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.');
}
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) {
final value = _optionalString(map, key);
if (value == null || value.isEmpty) {

View file

@ -5,12 +5,7 @@
"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"],
"required": ["id", "name", "quadlets", "schemas"],
"properties": {
"id": {
"type": "string",
@ -21,17 +16,6 @@
"type": "string",
"minLength": 1
},
"unit": {
"type": "string",
"minLength": 1,
"pattern": "^[^/]+\\.service$"
},
"container_name": {
"type": "string",
"minLength": 1
}
}
},
"runtime": {
"type": "object",
"additionalProperties": false,
@ -44,15 +28,27 @@
}
}
},
"container": {
"quadlets": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["file", "dropins_dir", "profiles_dir"],
"required": ["file"],
"properties": {
"file": {
"type": "string",
"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": {
"type": "string",
@ -63,6 +59,7 @@
"minLength": 1
}
}
}
},
"schemas": {
"type": "object",

View file

@ -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,
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
runtime:
type: podman-quadlet
container:
file: app_postgres.container
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<String, Object?> _manifestObject() => {
'service': {
'id': 'postgres',
'name': 'PostgreSQL',
'runtime': {'type': 'podman-quadlet'},
'quadlets': [
{
'file': 'app_postgres.container',
'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',
},
{'file': 'app_postgres.network'},
],
'schemas': {'configure': 'configure.schema.json', 'init': 'init.schema.json'},
};