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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
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
|
||||||
dropins_dir: dew_postgresql-18.container.d
|
unit: dew_postgresql-18.service
|
||||||
profiles_dir: dew_postgresql-18.profiles.d
|
container_name: dew_postgresql-18
|
||||||
|
dropins_dir: dew_postgresql-18.container.d
|
||||||
|
profiles_dir: dew_postgresql-18.profiles.d
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
configure: schemas/configure.schema.json
|
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
|
## 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
|
||||||
dropins_dir: app_postgres.container.d
|
unit: app_postgres.service
|
||||||
profiles_dir: app_postgres.profiles.d
|
container_name: app_postgres
|
||||||
|
dropins_dir: app_postgres.container.d
|
||||||
|
profiles_dir: app_postgres.profiles.d
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
configure: schemas/configure.schema.json
|
configure: schemas/configure.schema.json
|
||||||
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(',')}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
await _requireDirectoryIfDeclared(
|
issue(manifest.manifestPath, 'quadlets must contain at least one file.');
|
||||||
manifest,
|
}
|
||||||
manifest.dropinsDirPath,
|
final quadletFiles = <String>{};
|
||||||
issues,
|
final quadletUnits = <String>{};
|
||||||
);
|
for (final quadlet in manifest.quadlets) {
|
||||||
await _requireDirectoryIfDeclared(
|
if (!quadletFiles.add(quadlet.file)) {
|
||||||
manifest,
|
issue(
|
||||||
manifest.profilesDirPath,
|
manifest.manifestPath,
|
||||||
issues,
|
'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(
|
await _validateJsonSchema(
|
||||||
manifest,
|
manifest,
|
||||||
label: 'configure schema',
|
label: 'configure schema',
|
||||||
|
|
|
||||||
|
|
@ -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,32 +233,39 @@ 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) {
|
||||||
if (dropinsPath != null && await fs.directory(dropinsPath).exists()) {
|
await _link(
|
||||||
final targetDropins = p.join(targetDir, p.basename(dropinsPath));
|
|
||||||
await _action(
|
|
||||||
actions,
|
actions,
|
||||||
dryRun,
|
dryRun,
|
||||||
'create $targetDropins',
|
quadlet.filePath,
|
||||||
() => fs.directory(targetDropins).create(recursive: true),
|
_targetQuadletPath(quadlet, scope),
|
||||||
);
|
);
|
||||||
await for (final entity in fs.directory(dropinsPath).list()) {
|
|
||||||
if (entity is! File || p.extension(entity.path) != '.conf') continue;
|
final dropinsPath = quadlet.dropinsDirPath;
|
||||||
await _link(
|
if (dropinsPath != null && await fs.directory(dropinsPath).exists()) {
|
||||||
|
final targetDropins = p.join(targetDir, p.basename(dropinsPath));
|
||||||
|
await _action(
|
||||||
actions,
|
actions,
|
||||||
dryRun,
|
dryRun,
|
||||||
entity.path,
|
'create $targetDropins',
|
||||||
p.join(targetDropins, p.basename(entity.path)),
|
() => 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,
|
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) {
|
||||||
if (dropinsPath != null) {
|
await _deletePath(actions, dryRun, _targetQuadletPath(quadlet, scope));
|
||||||
await _deletePath(
|
final dropinsPath = quadlet.dropinsDirPath;
|
||||||
actions,
|
if (dropinsPath != null) {
|
||||||
dryRun,
|
await _deletePath(
|
||||||
p.join(
|
actions,
|
||||||
quadletSearchPath(scope, environment: environment),
|
dryRun,
|
||||||
p.basename(dropinsPath),
|
p.join(targetDir, 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) {
|
||||||
final action = 'podman ${args.join(' ')}';
|
actions.add('no container artifacts declared for ${manifest.id}');
|
||||||
actions.add(action);
|
}
|
||||||
if (!dryRun) {
|
for (final containerName in manifest.containerNames) {
|
||||||
final result = await processRunner.run('podman', args);
|
final args = ['rm', '--ignore', '--force', containerName];
|
||||||
exitCode = result.exitCode;
|
final action = 'podman ${args.join(' ')}';
|
||||||
if (result.stdout.trim().isNotEmpty) outputs.add(result.stdout.trim());
|
actions.add(action);
|
||||||
if (result.stderr.trim().isNotEmpty) errors.add(result.stderr.trim());
|
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) {
|
if (deleteData) {
|
||||||
|
|
@ -378,13 +403,11 @@ 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,
|
quadletSearchPath(scope, environment: environment),
|
||||||
) => p.join(
|
p.basename(quadlet.file),
|
||||||
quadletSearchPath(scope, environment: environment),
|
);
|
||||||
p.basename(manifest.containerFile),
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> _link(
|
Future<void> _link(
|
||||||
List<String> actions,
|
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`.
|
/// 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) {
|
||||||
|
|
|
||||||
|
|
@ -5,32 +5,16 @@
|
||||||
"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": {
|
"properties": {
|
||||||
"service": {
|
"id": {
|
||||||
"type": "object",
|
"type": "string",
|
||||||
"additionalProperties": false,
|
"minLength": 1,
|
||||||
"required": ["id", "name", "unit", "container_name"],
|
"pattern": "^[a-z0-9][a-z0-9_.-]*$"
|
||||||
"properties": {
|
},
|
||||||
"id": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1,
|
"minLength": 1
|
||||||
"pattern": "^[a-z0-9][a-z0-9_.-]*$"
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"unit": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"pattern": "^[^/]+\\.service$"
|
|
||||||
},
|
|
||||||
"container_name": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -44,23 +28,36 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"container": {
|
"quadlets": {
|
||||||
"type": "object",
|
"type": "array",
|
||||||
"additionalProperties": false,
|
"minItems": 1,
|
||||||
"required": ["file", "dropins_dir", "profiles_dir"],
|
"items": {
|
||||||
"properties": {
|
"type": "object",
|
||||||
"file": {
|
"additionalProperties": false,
|
||||||
"type": "string",
|
"required": ["file"],
|
||||||
"minLength": 1,
|
"properties": {
|
||||||
"pattern": "^[^/].*\\.container$"
|
"file": {
|
||||||
},
|
"type": "string",
|
||||||
"dropins_dir": {
|
"minLength": 1,
|
||||||
"type": "string",
|
"pattern": "^[^/].*\\.(artifact|build|container|image|kube|network|pod|volume)$"
|
||||||
"minLength": 1
|
},
|
||||||
},
|
"unit": {
|
||||||
"profiles_dir": {
|
"type": "string",
|
||||||
"type": "string",
|
"minLength": 1,
|
||||||
"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', () {
|
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),
|
||||||
'/project/.project/infrastructure/services/postgres/app_postgres.container',
|
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);
|
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
|
||||||
dropins_dir: app_postgres.container.d
|
unit: $unit
|
||||||
profiles_dir: app_postgres.profiles.d
|
container_name: app_postgres
|
||||||
|
dropins_dir: app_postgres.container.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',
|
|
||||||
'unit': 'app_postgres.service',
|
|
||||||
'container_name': 'app_postgres',
|
|
||||||
},
|
|
||||||
'runtime': {'type': 'podman-quadlet'},
|
'runtime': {'type': 'podman-quadlet'},
|
||||||
'container': {
|
'quadlets': [
|
||||||
'file': 'app_postgres.container',
|
{
|
||||||
'dropins_dir': 'app_postgres.container.d',
|
'file': 'app_postgres.container',
|
||||||
'profiles_dir': 'app_postgres.profiles.d',
|
'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'},
|
'schemas': {'configure': 'configure.schema.json', 'init': 'init.schema.json'},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue