Support multiple quadlets in infra manifests
This commit is contained in:
parent
223aaba888
commit
9bc5779221
10 changed files with 396 additions and 217 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
8
.project/kanban/done/DEW-0034.md
Normal file
8
.project/kanban/done/DEW-0034.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(',')}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = <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,
|
||||
quadlet.dropinsDirPath,
|
||||
issues,
|
||||
);
|
||||
await _requireDirectoryIfDeclared(
|
||||
manifest,
|
||||
quadlet.profilesDirPath,
|
||||
issues,
|
||||
);
|
||||
}
|
||||
await _validateJsonSchema(
|
||||
manifest,
|
||||
label: 'configure schema',
|
||||
|
|
|
|||
|
|
@ -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,32 +233,39 @@ 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;
|
||||
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 = <String>[];
|
||||
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<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];
|
||||
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<void> _link(
|
||||
List<String> actions,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -5,32 +5,16 @@
|
|||
"description": "Schema for .project/infrastructure/services/<service-id>/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
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<String, Object?> _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'},
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue