diff --git a/.project/infrastructure/services/keycloak/dew_keycloak-postgresql.container b/.project/infrastructure/services/keycloak/dew_keycloak-postgresql.container index c51c31a..c9a6dca 100644 --- a/.project/infrastructure/services/keycloak/dew_keycloak-postgresql.container +++ b/.project/infrastructure/services/keycloak/dew_keycloak-postgresql.container @@ -8,7 +8,7 @@ Image=docker.io/library/postgres:18 ContainerName=dew_keycloak-postgresql Network=dew_keycloak.network NetworkAlias=postgres -Volume=dew_keycloak-postgresql.volume:/var/lib/postgresql/data +Volume=dew_keycloak-postgresql.volume:/var/lib/postgresql Environment=POSTGRES_DB=keycloak Environment=POSTGRES_USER=keycloak Environment=POSTGRES_PASSWORD=keycloak_dev_password diff --git a/.project/infrastructure/services/local-api-build/Containerfile b/.project/infrastructure/services/local-api-build/Containerfile index af68d9f..92ff70e 100644 --- a/.project/infrastructure/services/local-api-build/Containerfile +++ b/.project/infrastructure/services/local-api-build/Containerfile @@ -1,8 +1,6 @@ -FROM docker.io/library/alpine:3.23 +FROM docker.io/library/nginx:alpine -RUN mkdir -p /srv/www \ - && printf '{"service":"local-api-build","status":"ok"}\n' > /srv/www/index.html +RUN printf '{"service":"local-api-build","status":"ok"}\n' \ + > /usr/share/nginx/html/index.html -EXPOSE 8080 - -CMD ["busybox", "httpd", "-f", "-p", "8080", "-h", "/srv/www"] +EXPOSE 80 diff --git a/.project/infrastructure/services/local-api-build/dew_local-api-build.build b/.project/infrastructure/services/local-api-build/dew_local-api-build.build index d47528f..5ea85d4 100644 --- a/.project/infrastructure/services/local-api-build/dew_local-api-build.build +++ b/.project/infrastructure/services/local-api-build/dew_local-api-build.build @@ -4,6 +4,7 @@ File=Containerfile SetWorkingDirectory=unit [Service] +RemainAfterExit=yes TimeoutStartSec=900 [Install] diff --git a/.project/infrastructure/services/local-api-build/dew_local-api-build.container b/.project/infrastructure/services/local-api-build/dew_local-api-build.container index e0a73ef..800f73f 100644 --- a/.project/infrastructure/services/local-api-build/dew_local-api-build.container +++ b/.project/infrastructure/services/local-api-build/dew_local-api-build.container @@ -6,7 +6,7 @@ After=dew_local-api-build.build [Container] Image=dew_local-api-build.build ContainerName=dew_local-api-build -PublishPort=127.0.0.1:8090:8080 +PublishPort=127.0.0.1:8090:80 [Service] Restart=always diff --git a/.project/infrastructure/services/local-api-build/manifest.yaml b/.project/infrastructure/services/local-api-build/manifest.yaml index 05a665f..16b8a34 100644 --- a/.project/infrastructure/services/local-api-build/manifest.yaml +++ b/.project/infrastructure/services/local-api-build/manifest.yaml @@ -11,6 +11,9 @@ quadlets: unit: dew_local-api-build.service container_name: dew_local-api-build +files: + - Containerfile + schemas: configure: schemas/configure.schema.json init: schemas/init.schema.json diff --git a/.project/infrastructure/services/postgresql-18/dew_postgresql-18.container b/.project/infrastructure/services/postgresql-18/dew_postgresql-18.container index cfb18e9..3f44e06 100644 --- a/.project/infrastructure/services/postgresql-18/dew_postgresql-18.container +++ b/.project/infrastructure/services/postgresql-18/dew_postgresql-18.container @@ -10,7 +10,7 @@ Environment=POSTGRES_DB=dew Environment=POSTGRES_USER=dew Environment=POSTGRES_PASSWORD=dew_dev_password PublishPort=127.0.0.1:5432:5432 -Volume=dew_postgresql-18_data:/var/lib/postgresql/data:Z +Volume=dew_postgresql-18_data:/var/lib/postgresql:Z HealthCmd=pg_isready -U dew -d dew HealthInterval=10s HealthTimeout=5s diff --git a/.project/kanban/done/DEW-0036.md b/.project/kanban/done/DEW-0036.md new file mode 100644 index 0000000..81bbd7e --- /dev/null +++ b/.project/kanban/done/DEW-0036.md @@ -0,0 +1,8 @@ +--- +id: DEW-0036 +title: Verify infra samples locally +type: task +created: 2026-05-05T04:28:49.175443Z +--- + +Bring up the new sample infrastructure services with dew infra, verify they start and respond as expected, then stop them without deleting retained data volumes. diff --git a/docs/features/infra.md b/docs/features/infra.md index f2e51d8..8e101cd 100644 --- a/docs/features/infra.md +++ b/docs/features/infra.md @@ -51,6 +51,9 @@ The `quadlets` list can contain any supported Podman Quadlet source type: from the Quadlet filename. Declare `unit` when the Quadlet file uses a `ServiceName=` override. +Use `files` for non-Quadlet assets that must be installed beside the Quadlet +files, such as a `Containerfile` used by a `.build` unit. + The package-level schema for this file is `packages/infra/schemas/service-manifest.schema.json`. diff --git a/packages/infra/lib/src/infra_repository.dart b/packages/infra/lib/src/infra_repository.dart index 9ffd716..d6dd32e 100644 --- a/packages/infra/lib/src/infra_repository.dart +++ b/packages/infra/lib/src/infra_repository.dart @@ -167,6 +167,9 @@ class InfraValidator { issues, ); } + for (final path in manifest.filePaths) { + await _requireFile(manifest, path, issues); + } await _validateJsonSchema( manifest, label: 'configure schema', diff --git a/packages/infra/lib/src/infra_runtime.dart b/packages/infra/lib/src/infra_runtime.dart index bbb3f18..0af8118 100644 --- a/packages/infra/lib/src/infra_runtime.dart +++ b/packages/infra/lib/src/infra_runtime.dart @@ -222,6 +222,12 @@ class PodmanQuadletRuntime implements ContainerRuntime { await fs.link(target).exists() || await fs.file(target).exists(); if (!exists) return false; } + for (final file in manifest.files) { + final target = _targetFilePath(file, scope); + final exists = + await fs.link(target).exists() || await fs.file(target).exists(); + if (!exists) return false; + } return true; } @@ -268,6 +274,14 @@ class PodmanQuadletRuntime implements ContainerRuntime { } } } + for (var i = 0; i < manifest.files.length; i++) { + await _link( + actions, + dryRun, + manifest.filePaths[i], + _targetFilePath(manifest.files[i], scope), + ); + } return InfraRuntimeResult(actions: actions); } @@ -291,6 +305,9 @@ class PodmanQuadletRuntime implements ContainerRuntime { ); } } + for (final file in manifest.files) { + await _deletePath(actions, dryRun, _targetFilePath(file, scope)); + } return InfraRuntimeResult(actions: actions); } @@ -409,6 +426,9 @@ class PodmanQuadletRuntime implements ContainerRuntime { p.basename(quadlet.file), ); + String _targetFilePath(String file, InfraScope scope) => + p.join(quadletSearchPath(scope, environment: environment), file); + Future _link( List actions, bool dryRun, @@ -416,6 +436,7 @@ class PodmanQuadletRuntime implements ContainerRuntime { String target, ) async { await _action(actions, dryRun, 'link $source -> $target', () async { + await fs.directory(p.dirname(target)).create(recursive: true); await _deleteIfExists(target); await fs.link(target).create(source, recursive: true); }); diff --git a/packages/infra/lib/src/service_manifest.dart b/packages/infra/lib/src/service_manifest.dart index 461317a..1f5397c 100644 --- a/packages/infra/lib/src/service_manifest.dart +++ b/packages/infra/lib/src/service_manifest.dart @@ -153,6 +153,7 @@ class InfraServiceManifest { required this.serviceDir, required this.manifestPath, required this.quadlets, + this.files = const [], this.configureSchema, this.initSchema, }); @@ -175,6 +176,9 @@ class InfraServiceManifest { /// Quadlet files deployed for this service. final List quadlets; + /// Additional files installed beside Quadlets for build contexts or assets. + final List files; + /// Optional relative path to the configure JSON Schema. final String? configureSchema; @@ -198,6 +202,9 @@ class InfraServiceManifest { /// Active init payload path. String get activeInitPath => p.join(configDir, 'init.json'); + /// Absolute paths to additional installed files. + List get filePaths => files.map(_resolve).toList(); + /// Units generated by the declared Quadlet files. List get units => quadlets.map((quadlet) => quadlet.serviceUnit).toList(); @@ -231,6 +238,7 @@ class InfraServiceManifest { serviceDir: serviceDir, manifestPath: manifestPath, quadlets: quadlets, + files: _optionalStringList(map, 'files'), configureSchema: schemas == null ? null : _optionalString(schemas, 'configure'), @@ -246,6 +254,7 @@ class InfraServiceManifest { 'manifest': manifestPath, 'units': units, 'quadlets': quadlets.map((quadlet) => quadlet.toJson()).toList(), + 'files': filePaths, 'configure_schema': configureSchemaPath, 'init_schema': initSchemaPath, 'active_config': activeConfigurePath, @@ -311,6 +320,20 @@ List _requiredList(Map map, String key) { throw FormatException('manifest.yaml is missing list "$key".'); } +List _optionalStringList(Map map, String key) { + final value = map[key]; + if (value == null) return const []; + if (value is! List) { + throw FormatException('manifest.yaml field "$key" must be a list.'); + } + return value.map((item) { + if (item is String && item.isNotEmpty) return item; + throw FormatException( + 'manifest.yaml field "$key" must contain non-empty strings.', + ); + }).toList(); +} + String _requiredString(Map map, String key) { final value = _optionalString(map, key); if (value == null || value.isEmpty) { diff --git a/packages/infra/schemas/service-manifest.schema.json b/packages/infra/schemas/service-manifest.schema.json index 85ad2d0..9754bf7 100644 --- a/packages/infra/schemas/service-manifest.schema.json +++ b/packages/infra/schemas/service-manifest.schema.json @@ -61,6 +61,15 @@ } } }, + "files": { + "type": "array", + "description": "Additional files installed beside Quadlets, such as Containerfiles for build contexts.", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[^/].*$" + } + }, "schemas": { "type": "object", "additionalProperties": false, diff --git a/packages/infra/test/dew_infra_test.dart b/packages/infra/test/dew_infra_test.dart index 9919cd7..defa4c8 100644 --- a/packages/infra/test/dew_infra_test.dart +++ b/packages/infra/test/dew_infra_test.dart @@ -115,7 +115,7 @@ void main() { 'install dry-run reports symlink actions without writing files', () async { final fs = MemoryFileSystem.test(); - _writeService(fs, includeNetwork: true); + _writeService(fs, includeNetwork: true, includeFile: true); final manifest = await InfraRepository( infraDir: '/project/.project/infrastructure', fs: fs, @@ -133,6 +133,7 @@ void main() { expect(result.actions.join('\n'), contains('app_postgres.container')); expect(result.actions.join('\n'), contains('app_postgres.network')); + expect(result.actions.join('\n'), contains('Containerfile')); expect( await fs .link( @@ -174,6 +175,7 @@ void _writeService( String serviceId = 'postgres', String unit = 'app_postgres.service', bool includeNetwork = false, + bool includeFile = false, }) { final serviceDir = fs.directory( '/project/.project/infrastructure/services/postgres', @@ -188,6 +190,11 @@ void _writeService( .file('${serviceDir.path}/app_postgres.network') .writeAsStringSync('[Network]\nNetworkName=app_postgres\n'); } + if (includeFile) { + fs + .file('${serviceDir.path}/Containerfile') + .writeAsStringSync('FROM scratch\n'); + } fs .file('${serviceDir.path}/configure.schema.json') .writeAsStringSync('{"type":"object"}'); @@ -197,6 +204,13 @@ void _writeService( final networkQuadlet = includeNetwork ? ''' - file: app_postgres.network +''' + : ''; + final files = includeFile + ? ''' +files: + - Containerfile + ''' : ''; fs.file('${serviceDir.path}/manifest.yaml').writeAsStringSync(''' @@ -214,6 +228,7 @@ quadlets: profiles_dir: app_postgres.profiles.d $networkQuadlet +$files schemas: configure: configure.schema.json init: init.schema.json @@ -234,6 +249,7 @@ Map _manifestObject() => { }, {'file': 'app_postgres.network'}, ], + 'files': ['Containerfile'], 'schemas': {'configure': 'configure.schema.json', 'init': 'init.schema.json'}, };