commit 500914cf108f955587287a4babc56b9281dbd3f9 Author: Chris Hendrickson Date: Fri May 1 18:14:53 2026 -0400 Initial podman package release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1d7ee9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.dart_tool/ +.packages +build/ +coverage/ +*.iml diff --git a/.pubignore b/.pubignore new file mode 100644 index 0000000..bff2d76 --- /dev/null +++ b/.pubignore @@ -0,0 +1 @@ +*.iml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..71f8491 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +## Unreleased + +- No changes yet. + +## 0.1.0 + +- Initial release scaffold for a libpod API-first Dart package. +- Added Unix-socket HTTP transport. +- Added typed client APIs for system, containers, and images. +- Added focused option/model types and unit tests. +- Added runnable examples. +- Added typed Secrets, Manifests, Artifacts, and Kube/Generate APIs. +- Added system maintenance APIs for disk usage, storage checks, and prune. +- Added container checkpoint/restore APIs with archive export/import helpers. +- Added expanded unit test coverage and local/integration test backend wiring. +- Added practical examples for secrets, manifests, artifacts, kube, maintenance, and checkpoint/restore workflows. +- Added `doc/examples.md` with copy/paste local commands for all examples. +- Added pod/network parity APIs for exists, pod admin controls, top/stats, and prune/update flows. +- Added container admin and archive APIs for kill/pause/unpause/top/init/rename/update/mount/unmount and archive HEAD/GET/PUT. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8905e63 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Chris Hendrickson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f25206b --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# podman + +A Dart client for the Podman **libpod API** over the Podman socket. + +This package uses HTTP over Unix sockets and targets libpod endpoints directly, +without shelling out to the `podman` CLI. + +## Features + +- Typed `PodmanClient` API for system, container, and image operations +- Network lifecycle and connect/disconnect APIs +- Network exists/update/prune APIs +- Volume lifecycle APIs +- Pod lifecycle APIs +- Pod exists/admin/top/stats/prune APIs +- Container runtime helpers (`wait`, `healthStatus`, `exec`, `stats`) +- Container admin APIs (kill/pause/unpause/top/init/rename/update/mount/archive) +- Container checkpoint/restore APIs (including archive export/import helpers) +- System maintenance APIs (`system/df`, `system/check`, `system/prune`) +- Typed events API with reconnecting watch stream +- Unix-socket transport (`UnixSocketPodmanTransport`) +- Libpod API-first implementation (no Docker-compat fallback path) +- Focused option and model types +- Testable transport abstraction for deterministic unit tests + +## Installation + +```bash +dart pub add podman +``` + +## Quick Start + +```dart +import 'package:podman/podman.dart'; + +Future main() async { + final client = PodmanClient(); + final version = await client.version(); + print('Podman server: ${version.serverVersion}'); + await client.close(); +} +``` + +## Socket Resolution + +By default, the transport resolves socket path in this order: + +1. `PODMAN_SOCKET` +2. `$XDG_RUNTIME_DIR/podman/podman.sock` +3. `/run/user/$UID/podman/podman.sock` +4. `/run/podman/podman.sock` + +You can override via: + +```dart +final client = PodmanClient(socketPath: '/custom/path/podman.sock'); +``` + +## Examples + +- `example/version_info_example.dart` +- `example/list_containers_example.dart` +- `example/pull_and_run_example.dart` +- `example/inspect_container_example.dart` +- `example/secrets_workflow_example.dart` +- `example/manifest_workflow_example.dart` +- `example/artifact_workflow_example.dart` +- `example/generate_assets_example.dart` +- `example/play_kube_file_example.dart` +- `example/system_maintenance_example.dart` +- `example/checkpoint_restore_example.dart` + +Copy/paste command guide: + +- `doc/examples.md` + +## Testing Backends + +Default tests use a fake transport for deterministic unit coverage: + +```bash +melos run test +``` + +Local integration tests can run against your local Podman socket: + +```bash +melos run test:integration +``` + +Optional env vars: + +- `PODMAN_TEST_SOCKET`: override socket path for local tests +- `PODMAN_LOCAL_TESTS=1`: alternate way to enable local tests diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 0000000..6561670 --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1,3 @@ +tags: + local: + skip: false diff --git a/doc/examples.md b/doc/examples.md new file mode 100644 index 0000000..0a65e63 --- /dev/null +++ b/doc/examples.md @@ -0,0 +1,60 @@ +# podman examples quickstart + +This guide provides copy/paste commands for each example in +`packages/podman/example`. + +## Prerequisites + +Run from package directory: + +```bash +cd /home/artificer/Projects/groupware/packages/podman +``` + +Ensure Podman API socket is available (optional override): + +```bash +export PODMAN_SOCKET="${PODMAN_SOCKET:-$XDG_RUNTIME_DIR/podman/podman.sock}" +``` + +## Command Matrix + +| Example | Purpose | Command | +| --------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `version_info_example.dart` | Show Podman version and host info | `dart run example/version_info_example.dart` | +| `list_containers_example.dart` | List local containers | `dart run example/list_containers_example.dart` | +| `pull_and_run_example.dart` | Pull and run `hello-world` | `dart run example/pull_and_run_example.dart` | +| `inspect_container_example.dart` | Inspect one container | `dart run example/inspect_container_example.dart ` | +| `secrets_workflow_example.dart` | Create/inspect/list/remove a secret | `PODMAN_EXAMPLE_SECRET='change-me' dart run example/secrets_workflow_example.dart --name=gw-jwt --replace --show-secret --cleanup` | +| `manifest_workflow_example.dart` | Create/inspect/push/delete a manifest list | `dart run example/manifest_workflow_example.dart groupware-stack quay.io/groupware/api:amd64 quay.io/groupware/api:arm64 --push=quay.io/groupware/api:latest --cleanup` | +| `artifact_workflow_example.dart` | Pull/inspect/list/push/remove OCI artifact | `dart run example/artifact_workflow_example.dart quay.io/groupware/policy:latest --push --cleanup=batch` | +| `generate_assets_example.dart` | Generate kube YAML and systemd units | `dart run example/generate_assets_example.dart groupware-orchestrator --kube-out=./generated/stack.yaml --systemd-dir=./generated/systemd` | +| `play_kube_file_example.dart` | Apply or tear down kube YAML through Podman | `dart run example/play_kube_file_example.dart ./generated/stack.yaml --replace` | +| `system_maintenance_example.dart` | Disk usage + optional check/prune | `dart run example/system_maintenance_example.dart --check --quick` | +| `checkpoint_restore_example.dart` | Checkpoint/export/restore flows | `dart run example/checkpoint_restore_example.dart checkpoint groupware-orchestrator --print-stats` | + +## Additional Checkpoint/Restore Commands + +```bash +# Export checkpoint archive +dart run example/checkpoint_restore_example.dart export groupware-orchestrator ./generated/orchestrator-checkpoint.tar + +# Restore from container checkpoint state +dart run example/checkpoint_restore_example.dart restore groupware-orchestrator --print-stats + +# Restore from archive +dart run example/checkpoint_restore_example.dart restore-archive ./generated/orchestrator-checkpoint.tar --name=orchestrator-restored +``` + +## Safer Maintenance Commands + +```bash +# Read-only usage snapshot +dart run example/system_maintenance_example.dart + +# Consistency checks only +dart run example/system_maintenance_example.dart --check --quick --max-age=24h + +# Destructive prune (requires --yes) +dart run example/system_maintenance_example.dart --prune --all --volumes --yes +``` diff --git a/example/artifact_workflow_example.dart b/example/artifact_workflow_example.dart new file mode 100644 index 0000000..eabbfb5 --- /dev/null +++ b/example/artifact_workflow_example.dart @@ -0,0 +1,129 @@ +import 'dart:io'; + +import 'package:podman/podman.dart'; + +Future main(List args) async { + if (args.isEmpty || args.contains('--help') || args.contains('-h')) { + _printUsage(); + return; + } + + final flags = args + .where((arg) => arg.startsWith('--')) + .toList(growable: false); + final positional = args + .where((arg) => !arg.startsWith('--')) + .toList(growable: false); + + if (positional.isEmpty) { + stderr.writeln('Provide an OCI artifact reference to pull.'); + _printUsage(); + exitCode = 64; + return; + } + + final artifactRef = positional.first; + final runPush = flags.contains('--push'); + final cleanupMode = _readOption(flags, 'cleanup'); + + final retry = int.tryParse(_readOption(flags, 'retry') ?? ''); + final retryDelay = _readOption(flags, 'retry-delay'); + final tlsVerify = _parseOptionalBool(_readOption(flags, 'tls-verify')); + + final client = PodmanClient(); + try { + final pulled = await client.pullArtifact( + artifactRef, + options: ArtifactPullOptions( + retry: retry, + retryDelay: retryDelay, + tlsVerify: tlsVerify, + ), + ); + + print('Pulled artifact: ${pulled.artifactDigest}'); + + final details = await client.inspectArtifact(artifactRef); + print('Name: ${details.name}'); + print('Digest: ${details.digest}'); + print('Manifest keys: ${details.manifest.keys.toList()..sort()}'); + + final allArtifacts = await client.listArtifacts(); + print('Local artifact count: ${allArtifacts.length}'); + + if (runPush) { + final pushed = await client.pushArtifact( + artifactRef, + options: ArtifactPushOptions( + retry: retry, + retryDelay: retryDelay, + tlsVerify: tlsVerify, + ), + ); + print('Pushed artifact digest: ${pushed.artifactDigest}'); + } + + if (cleanupMode == 'single') { + final removed = await client.removeArtifact( + artifactRef, + ignoreMissing: true, + ); + print('Removed (single): ${removed.artifactDigests}'); + } + + if (cleanupMode == 'batch') { + final removed = await client.removeArtifacts( + ArtifactRemoveOptions(artifacts: [artifactRef], ignore: true), + ); + print('Removed (batch): ${removed.artifactDigests}'); + } + } finally { + await client.close(); + } +} + +String? _readOption(List args, String name) { + final prefix = '--$name='; + for (final arg in args) { + if (arg.startsWith(prefix)) { + return arg.substring(prefix.length); + } + } + return null; +} + +bool? _parseOptionalBool(String? input) { + if (input == null || input.isEmpty) { + return null; + } + + final normalized = input.toLowerCase(); + if (normalized == 'true' || normalized == '1' || normalized == 'yes') { + return true; + } + if (normalized == 'false' || normalized == '0' || normalized == 'no') { + return false; + } + return null; +} + +void _printUsage() { + print(''' +Artifact workflow example + +Usage: + dart run example/artifact_workflow_example.dart [options] + +Examples: + dart run example/artifact_workflow_example.dart quay.io/groupware/policy:latest + dart run example/artifact_workflow_example.dart quay.io/groupware/policy:latest --cleanup=batch + +Options: + --retry= Retry count for pull/push + --retry-delay= Retry delay (for example: 2s) + --tls-verify= TLS verification for registry operations + --push Push artifact using its current reference + --cleanup=single|batch Remove pulled artifact after example run + --help, -h Show this help +'''); +} diff --git a/example/checkpoint_restore_example.dart b/example/checkpoint_restore_example.dart new file mode 100644 index 0000000..5abf056 --- /dev/null +++ b/example/checkpoint_restore_example.dart @@ -0,0 +1,274 @@ +import 'dart:io'; + +import 'package:podman/podman.dart'; + +Future main(List args) async { + if (args.isEmpty || args.contains('--help') || args.contains('-h')) { + _printUsage(); + return; + } + + final command = args.first; + final remaining = args.sublist(1); + + switch (command) { + case 'checkpoint': + await _runCheckpoint(remaining); + return; + case 'export': + await _runExport(remaining); + return; + case 'restore': + await _runRestore(remaining); + return; + case 'restore-archive': + await _runRestoreArchive(remaining); + return; + default: + stderr.writeln('Unknown command: $command'); + _printUsage(); + exitCode = 64; + return; + } +} + +Future _runCheckpoint(List args) async { + final positional = args + .where((arg) => !arg.startsWith('--')) + .toList(growable: false); + if (positional.length != 1) { + stderr.writeln('checkpoint requires exactly one argument.'); + _printUsage(); + exitCode = 64; + return; + } + + final container = positional.first; + final options = _checkpointOptionsFromArgs(args); + + final client = PodmanClient(); + try { + final report = await client.checkpointContainer( + container, + options: options, + ); + print('Checkpoint completed for "$container"'); + print(' id: ${report.id}'); + print(' runtime duration: ${report.runtimeDuration}'); + print(' criu stats keys: ${report.criuStatistics.keys.toList()..sort()}'); + } finally { + await client.close(); + } +} + +Future _runExport(List args) async { + final positional = args + .where((arg) => !arg.startsWith('--')) + .toList(growable: false); + if (positional.length != 2) { + stderr.writeln('export requires .'); + _printUsage(); + exitCode = 64; + return; + } + + final container = positional.first; + final archivePath = positional[1]; + final options = _checkpointOptionsFromArgs(args); + + final client = PodmanClient(); + try { + final archive = await client.exportContainerCheckpoint( + container, + options: options, + ); + await File(archivePath).writeAsBytes(archive); + print( + 'Exported checkpoint for "$container" to $archivePath (${archive.length} bytes).', + ); + } finally { + await client.close(); + } +} + +Future _runRestore(List args) async { + final positional = args + .where((arg) => !arg.startsWith('--')) + .toList(growable: false); + if (positional.length != 1) { + stderr.writeln('restore requires exactly one argument.'); + _printUsage(); + exitCode = 64; + return; + } + + final container = positional.first; + final options = _restoreOptionsFromArgs(args); + + final client = PodmanClient(); + try { + final report = await client.restoreContainer(container, options: options); + print('Restore completed for "$container"'); + print(' id: ${report.id}'); + print(' runtime duration: ${report.runtimeDuration}'); + print(' criu stats keys: ${report.criuStatistics.keys.toList()..sort()}'); + } finally { + await client.close(); + } +} + +Future _runRestoreArchive(List args) async { + final positional = args + .where((arg) => !arg.startsWith('--')) + .toList(growable: false); + if (positional.length != 1) { + stderr.writeln( + 'restore-archive requires exactly one argument.', + ); + _printUsage(); + exitCode = 64; + return; + } + + final archivePath = positional.first; + final archiveFile = File(archivePath); + if (!await archiveFile.exists()) { + stderr.writeln('Archive file not found: $archivePath'); + exitCode = 66; + return; + } + + final importName = _readOption(args, 'import-name') ?? 'import'; + final options = _restoreOptionsFromArgs(args); + final bytes = await archiveFile.readAsBytes(); + + final client = PodmanClient(); + try { + final report = await client.restoreContainerFromArchive( + bytes, + importName: importName, + options: options, + ); + + print('Archive restore completed from $archivePath'); + print(' id: ${report.id}'); + print(' runtime duration: ${report.runtimeDuration}'); + print(' criu stats keys: ${report.criuStatistics.keys.toList()..sort()}'); + } finally { + await client.close(); + } +} + +ContainerCheckpointOptions _checkpointOptionsFromArgs(List args) { + return ContainerCheckpointOptions( + keep: args.contains('--keep'), + leaveRunning: args.contains('--leave-running'), + tcpEstablished: args.contains('--tcp-established'), + ignoreRootFs: args.contains('--ignore-rootfs'), + ignoreVolumes: args.contains('--ignore-volumes'), + printStats: args.contains('--print-stats'), + preCheckpoint: args.contains('--pre-checkpoint'), + withPrevious: args.contains('--with-previous'), + fileLocks: args.contains('--file-locks'), + createImage: _readOption(args, 'create-image'), + ); +} + +ContainerRestoreOptions _restoreOptionsFromArgs(List args) { + final publishPorts = _readMultiOptions(args, 'publish-port'); + + return ContainerRestoreOptions( + name: _readOption(args, 'name'), + keep: args.contains('--keep'), + tcpEstablished: args.contains('--tcp-established'), + tcpClose: args.contains('--tcp-close'), + ignoreRootFs: args.contains('--ignore-rootfs'), + ignoreVolumes: args.contains('--ignore-volumes'), + ignoreStaticIp: args.contains('--ignore-static-ip'), + ignoreStaticMac: args.contains('--ignore-static-mac'), + printStats: args.contains('--print-stats'), + fileLocks: args.contains('--file-locks'), + publishPorts: publishPorts, + pod: _readOption(args, 'pod'), + ); +} + +String? _readOption(List args, String name) { + final prefix = '--$name='; + for (final arg in args) { + if (arg.startsWith(prefix)) { + return arg.substring(prefix.length); + } + } + return null; +} + +List _readMultiOptions(List args, String name) { + final prefix = '--$name='; + final values = []; + + for (final arg in args) { + if (arg.startsWith(prefix)) { + final value = arg.substring(prefix.length); + if (value.isNotEmpty) { + values.add(value); + } + } + } + + return values; +} + +void _printUsage() { + print(''' +Checkpoint/restore example + +Usage: + dart run example/checkpoint_restore_example.dart [args] [options] + +Commands: + checkpoint + Create an in-place checkpoint and return report metadata. + + export + Export checkpoint archive bytes to the provided file path. + + restore + Restore a container from an existing checkpoint state/image. + + restore-archive + Restore from a checkpoint archive tarball. + +Shared options: + --keep + --tcp-established + --print-stats + --file-locks + +Checkpoint/export options: + --leave-running + --ignore-rootfs + --ignore-volumes + --pre-checkpoint + --with-previous + --create-image= + +Restore/restore-archive options: + --name= + --tcp-close + --ignore-rootfs + --ignore-volumes + --ignore-static-ip + --ignore-static-mac + --pod= + --publish-port= (repeatable) + +restore-archive-only options: + --import-name= Defaults to "import" + +Examples: + dart run example/checkpoint_restore_example.dart checkpoint gw-service --print-stats + dart run example/checkpoint_restore_example.dart export gw-service ./gw-service-checkpoint.tar + dart run example/checkpoint_restore_example.dart restore-archive ./gw-service-checkpoint.tar --name=gw-restored +'''); +} diff --git a/example/generate_assets_example.dart b/example/generate_assets_example.dart new file mode 100644 index 0000000..ab64131 --- /dev/null +++ b/example/generate_assets_example.dart @@ -0,0 +1,115 @@ +import 'dart:io'; + +import 'package:podman/podman.dart'; + +Future main(List args) async { + if (args.isEmpty || args.contains('--help') || args.contains('-h')) { + _printUsage(); + return; + } + + final flags = args + .where((arg) => arg.startsWith('--')) + .toList(growable: false); + final positional = args + .where((arg) => !arg.startsWith('--')) + .toList(growable: false); + + if (positional.isEmpty) { + stderr.writeln('Provide at least one container or pod name.'); + _printUsage(); + exitCode = 64; + return; + } + + final kubeOutPath = _readOption(flags, 'kube-out'); + final systemdDirPath = _readOption(flags, 'systemd-dir'); + final systemdTarget = + _readOption(flags, 'systemd-target') ?? positional.first; + + final includeService = !flags.contains('--no-service'); + final podmanOnly = !flags.contains('--no-podman-only'); + final noTrunc = flags.contains('--no-trunc'); + + final client = PodmanClient(); + try { + final kubeYaml = await client.generateKube( + GenerateKubeOptions( + names: positional, + service: includeService, + podmanOnly: podmanOnly, + noTrunc: noTrunc, + ), + ); + + if (kubeOutPath != null && kubeOutPath.isNotEmpty) { + await File(kubeOutPath).writeAsString(kubeYaml); + print('Wrote generated Kubernetes YAML to $kubeOutPath'); + } else { + print('--- begin generated kube yaml ---'); + print(kubeYaml.trim()); + print('--- end generated kube yaml ---'); + } + + final systemd = await client.generateSystemd( + systemdTarget, + options: const GenerateSystemdOptions( + useName: true, + noHeader: true, + restartPolicy: 'always', + ), + ); + + if (systemd.units.isEmpty) { + print('No systemd units were generated for "$systemdTarget".'); + return; + } + + if (systemdDirPath != null && systemdDirPath.isNotEmpty) { + final outputDir = Directory(systemdDirPath); + await outputDir.create(recursive: true); + + for (final entry in systemd.units.entries) { + final file = File('${outputDir.path}/${entry.key}'); + await file.writeAsString(entry.value); + print('Wrote ${entry.key}'); + } + } else { + print('Generated systemd units: ${systemd.units.keys.toList()..sort()}'); + } + } finally { + await client.close(); + } +} + +String? _readOption(List args, String name) { + final prefix = '--$name='; + for (final arg in args) { + if (arg.startsWith(prefix)) { + return arg.substring(prefix.length); + } + } + return null; +} + +void _printUsage() { + print(''' +Generate assets example + +Usage: + dart run example/generate_assets_example.dart [name ...] [options] + +Examples: + dart run example/generate_assets_example.dart groupware-orchestrator + dart run example/generate_assets_example.dart groupware-pod --kube-out=./stack.yaml --systemd-dir=./units + +Options: + --kube-out= Write generated kube YAML to file (default: stdout) + --systemd-dir= Write generated systemd units to directory + --systemd-target= Specific name for systemd generation (defaults to first positional) + --no-service Do not include generated Service objects in kube YAML + --no-podman-only Do not include Podman-only annotations in kube YAML + --no-trunc Use non-truncated annotations + --help, -h Show this help +'''); +} diff --git a/example/inspect_container_example.dart b/example/inspect_container_example.dart new file mode 100644 index 0000000..079ed71 --- /dev/null +++ b/example/inspect_container_example.dart @@ -0,0 +1,21 @@ +import 'package:podman/podman.dart'; + +Future main(List args) async { + if (args.isEmpty) { + print( + 'Usage: dart run inspect_container_example.dart ', + ); + return; + } + + final client = PodmanClient(); + final details = await client.inspectContainer(args.first); + + print('ID: ${details.id}'); + print('Name: ${details.name}'); + print('Image: ${details.image}'); + print('State: ${details.state}'); + print('Status: ${details.status}'); + + await client.close(); +} diff --git a/example/list_containers_example.dart b/example/list_containers_example.dart new file mode 100644 index 0000000..43456cb --- /dev/null +++ b/example/list_containers_example.dart @@ -0,0 +1,21 @@ +import 'package:podman/podman.dart'; + +Future main() async { + final client = PodmanClient(); + final containers = await client.listContainers(all: true); + + if (containers.isEmpty) { + print('No containers found.'); + await client.close(); + return; + } + + for (final container in containers) { + final shortId = container.id.length > 12 + ? container.id.substring(0, 12) + : container.id; + print('$shortId ${container.name} ${container.status}'); + } + + await client.close(); +} diff --git a/example/manifest_workflow_example.dart b/example/manifest_workflow_example.dart new file mode 100644 index 0000000..427d7b7 --- /dev/null +++ b/example/manifest_workflow_example.dart @@ -0,0 +1,127 @@ +import 'dart:io'; + +import 'package:podman/podman.dart'; + +Future main(List args) async { + if (args.isEmpty || args.contains('--help') || args.contains('-h')) { + _printUsage(); + return; + } + + final flags = args + .where((arg) => arg.startsWith('--')) + .toList(growable: false); + final positional = args + .where((arg) => !arg.startsWith('--')) + .toList(growable: false); + + if (positional.length < 2) { + stderr.writeln('Provide a manifest name and at least one image reference.'); + _printUsage(); + exitCode = 64; + return; + } + + final manifestName = positional.first; + final images = positional.sublist(1); + final pushDestination = _readOption(flags, 'push'); + final cleanup = flags.contains('--cleanup'); + final amend = flags.contains('--amend'); + + final annotations = _readKeyValueOptions(flags, 'annotation'); + if (annotations.isEmpty) { + annotations['org.opencontainers.image.title'] = manifestName; + } + + final client = PodmanClient(); + try { + final created = await client.createManifest( + manifestName, + options: ManifestCreateOptions( + images: images, + all: true, + amend: amend, + annotations: annotations, + ), + ); + print('Created/updated manifest "$manifestName" (${created.id}).'); + + final exists = await client.manifestExists(manifestName); + print('Manifest exists: $exists'); + + final details = await client.inspectManifest(manifestName); + print('Schema version: ${details.schemaVersion}'); + print('Media type: ${details.mediaType}'); + print('Embedded manifests: ${details.manifests.length}'); + + if (pushDestination != null && pushDestination.isNotEmpty) { + final pushed = await client.pushManifest(manifestName, pushDestination); + print('Pushed to $pushDestination (${pushed.id}).'); + } + + if (cleanup) { + final result = await client.deleteManifest( + manifestName, + ignoreMissing: true, + ); + print( + 'Deleted entries: ${result.deleted.length}; untagged: ${result.untagged.length}', + ); + } + } finally { + await client.close(); + } +} + +String? _readOption(List args, String name) { + final prefix = '--$name='; + for (final arg in args) { + if (arg.startsWith(prefix)) { + return arg.substring(prefix.length); + } + } + return null; +} + +Map _readKeyValueOptions(List args, String name) { + final prefix = '--$name='; + final output = {}; + + for (final arg in args) { + if (!arg.startsWith(prefix)) { + continue; + } + + final raw = arg.substring(prefix.length); + final index = raw.indexOf('='); + if (index <= 0 || index == raw.length - 1) { + continue; + } + + final key = raw.substring(0, index); + final value = raw.substring(index + 1); + output[key] = value; + } + + return output; +} + +void _printUsage() { + print(''' +Manifest workflow example + +Usage: + dart run example/manifest_workflow_example.dart [image ...] [options] + +Examples: + dart run example/manifest_workflow_example.dart groupware-stack + quay.io/groupware/api:amd64 quay.io/groupware/api:arm64 --push=quay.io/groupware/api:latest + +Options: + --annotation= Add top-level annotation (repeatable) + --push= Push manifest to destination reference + --amend Amend if manifest already exists + --cleanup Delete manifest at end of run + --help, -h Show this help +'''); +} diff --git a/example/play_kube_file_example.dart b/example/play_kube_file_example.dart new file mode 100644 index 0000000..9304c0c --- /dev/null +++ b/example/play_kube_file_example.dart @@ -0,0 +1,101 @@ +import 'dart:io'; + +import 'package:podman/podman.dart'; + +Future main(List args) async { + if (args.isEmpty || args.contains('--help') || args.contains('-h')) { + _printUsage(); + return; + } + + final flags = args + .where((arg) => arg.startsWith('--')) + .toList(growable: false); + final positional = args + .where((arg) => !arg.startsWith('--')) + .toList(growable: false); + + if (positional.isEmpty) { + stderr.writeln('Provide a kube YAML file path.'); + _printUsage(); + exitCode = 64; + return; + } + + final yamlPath = positional.first; + final yamlFile = File(yamlPath); + if (!await yamlFile.exists()) { + stderr.writeln('YAML file not found: $yamlPath'); + exitCode = 66; + return; + } + + final yaml = await yamlFile.readAsString(); + final down = flags.contains('--down'); + final force = flags.contains('--force'); + + final replace = flags.contains('--replace'); + final serviceContainer = !flags.contains('--no-service-container'); + final networkValues = _readMultiOptions(flags, 'network'); + + final client = PodmanClient(); + try { + final report = down + ? await client.playKubeDown(yaml, force: force) + : await client.playKube( + yaml, + options: PlayKubeOptions( + replace: replace, + serviceContainer: serviceContainer, + networks: networkValues, + ), + ); + + print('Operation: ${down ? 'down' : 'up'}'); + print('Pods: ${report.pods.isEmpty ? '(none)' : report.pods}'); + print('Volumes: ${report.volumes.isEmpty ? '(none)' : report.volumes}'); + print('Secrets: ${report.secrets.isEmpty ? '(none)' : report.secrets}'); + print( + 'Service container: ${report.serviceContainerId.isEmpty ? '(none)' : report.serviceContainerId}', + ); + } finally { + await client.close(); + } +} + +List _readMultiOptions(List args, String name) { + final prefix = '--$name='; + final values = []; + + for (final arg in args) { + if (arg.startsWith(prefix)) { + final value = arg.substring(prefix.length); + if (value.isNotEmpty) { + values.add(value); + } + } + } + + return values; +} + +void _printUsage() { + print(''' +Play kube example + +Usage: + dart run example/play_kube_file_example.dart [options] + +Examples: + dart run example/play_kube_file_example.dart ./stack.yaml --replace --network=groupware + dart run example/play_kube_file_example.dart ./stack.yaml --down --force + +Options: + --down Tear down resources instead of creating them + --force Force delete on down operation + --replace Replace existing resources on up operation + --network= Attach created pods to network (repeatable) + --no-service-container Disable service container on up operation + --help, -h Show this help +'''); +} diff --git a/example/pull_and_run_example.dart b/example/pull_and_run_example.dart new file mode 100644 index 0000000..45c22e8 --- /dev/null +++ b/example/pull_and_run_example.dart @@ -0,0 +1,19 @@ +import 'package:podman/podman.dart'; + +Future main() async { + final client = PodmanClient(); + + await client.pull('docker.io/library/hello-world:latest', quiet: true); + + final containerId = await client.run( + const RunOptions( + image: 'docker.io/library/hello-world:latest', + name: 'podman_package_example_hello_world', + labels: {'example': 'podman-package'}, + removeWhenStopped: true, + ), + ); + + print('Started container: $containerId'); + await client.close(); +} diff --git a/example/secrets_workflow_example.dart b/example/secrets_workflow_example.dart new file mode 100644 index 0000000..f1e6421 --- /dev/null +++ b/example/secrets_workflow_example.dart @@ -0,0 +1,159 @@ +import 'dart:io'; + +import 'package:podman/podman.dart'; + +Future main(List args) async { + if (args.contains('--help') || args.contains('-h')) { + _printUsage(); + return; + } + + final name = + _readOption(args, 'name') ?? (args.isNotEmpty ? args.first : null); + final rawValue = _readOption(args, 'value'); + final filePath = _readOption(args, 'file'); + final driver = _readOption(args, 'driver'); + final showSecret = args.contains('--show-secret'); + final cleanup = args.contains('--cleanup'); + final replace = args.contains('--replace'); + final ignore = args.contains('--ignore'); + + if (name == null || name.isEmpty) { + stderr.writeln('Missing secret name.'); + _printUsage(); + exitCode = 64; + return; + } + + final value = await _resolveSecretValue( + rawValue: rawValue, + filePath: filePath, + ); + if (value == null || value.isEmpty) { + stderr.writeln( + 'Provide a secret value via --value, --file, or PODMAN_EXAMPLE_SECRET.', + ); + _printUsage(); + exitCode = 64; + return; + } + + final labels = _readKeyValueOptions(args, 'label'); + final driverOptions = _readKeyValueOptions(args, 'driver-opt'); + + final client = PodmanClient(); + try { + final created = await client.createSecret( + SecretCreateOptions( + name: name, + data: value, + driver: driver, + labels: labels, + driverOptions: driverOptions, + replace: replace, + ignore: ignore, + ), + ); + + print('Created/updated secret "$name" with id ${created.id}.'); + + final exists = await client.secretExists(name); + print('Secret exists: $exists'); + + final details = await client.inspectSecret(name, showSecret: showSecret); + print('Driver: ${details.driver.isEmpty ? 'unknown' : details.driver}'); + print('Labels: ${details.labels.isEmpty ? '(none)' : details.labels}'); + print('Updated: ${details.updatedAt?.toIso8601String() ?? 'unknown'}'); + + if (showSecret) { + print('Secret payload length: ${details.secretData.length}'); + } + + final allSecrets = await client.listSecrets(); + print('Total secrets visible to this user: ${allSecrets.length}'); + + if (cleanup) { + await client.removeSecret(name, ignoreMissing: true); + print('Removed secret "$name" (--cleanup).'); + } + } finally { + await client.close(); + } +} + +Future _resolveSecretValue({ + required String? rawValue, + required String? filePath, +}) async { + if (rawValue != null) { + return rawValue; + } + + if (filePath != null && filePath.isNotEmpty) { + final file = File(filePath); + if (!await file.exists()) { + stderr.writeln('Secret file not found: $filePath'); + return null; + } + return file.readAsString(); + } + + return Platform.environment['PODMAN_EXAMPLE_SECRET']; +} + +String? _readOption(List args, String name) { + final prefix = '--$name='; + for (final arg in args) { + if (arg.startsWith(prefix)) { + return arg.substring(prefix.length); + } + } + return null; +} + +Map _readKeyValueOptions(List args, String name) { + final prefix = '--$name='; + final output = {}; + + for (final arg in args) { + if (!arg.startsWith(prefix)) { + continue; + } + final raw = arg.substring(prefix.length); + final index = raw.indexOf('='); + if (index <= 0 || index == raw.length - 1) { + continue; + } + + final key = raw.substring(0, index); + final value = raw.substring(index + 1); + output[key] = value; + } + + return output; +} + +void _printUsage() { + print(''' +Secrets workflow example + +Usage: + dart run example/secrets_workflow_example.dart --name= [options] + +Options: + --name= Secret name (required) + --value= Secret payload value + --file=<path> Read secret payload from file + --driver=<driver> Optional driver (for example: file, pass) + --label=<k=v> Add label (repeatable) + --driver-opt=<k=v> Add driver option (repeatable) + --replace Replace existing secret with same name + --ignore Ignore creation conflict errors + --show-secret Request and display secret payload length + --cleanup Remove secret at the end of the run + --help, -h Show this help + +Environment fallback: + PODMAN_EXAMPLE_SECRET Used when --value/--file are not set +'''); +} diff --git a/example/system_maintenance_example.dart b/example/system_maintenance_example.dart new file mode 100644 index 0000000..7a02a77 --- /dev/null +++ b/example/system_maintenance_example.dart @@ -0,0 +1,136 @@ +import 'dart:io'; + +import 'package:podman/podman.dart'; + +Future<void> main(List<String> args) async { + if (args.contains('--help') || args.contains('-h')) { + _printUsage(); + return; + } + + final runCheck = args.contains('--check'); + final runPrune = args.contains('--prune'); + final confirmPrune = args.contains('--yes'); + + final checkOptions = SystemCheckOptions( + quick: args.contains('--quick'), + repair: args.contains('--repair'), + repairLossy: args.contains('--repair-lossy'), + unreferencedLayerMaxAge: _readOption(args, 'max-age'), + ); + + final pruneOptions = SystemPruneOptions( + all: args.contains('--all'), + volumes: args.contains('--volumes'), + external: args.contains('--external'), + build: args.contains('--build'), + filters: _readFilters(args), + ); + + final client = PodmanClient(); + try { + final df = await client.systemDf(); + print('Disk usage snapshot'); + print(' Images size: ${df.imagesSize} bytes'); + print(' Images: ${df.images.length}'); + print(' Containers: ${df.containers.length}'); + print(' Volumes: ${df.volumes.length}'); + + if (runCheck) { + final check = await client.systemCheck(options: checkOptions); + print('System check completed'); + print(' Errors detected: ${check.errors}'); + print(' Layer findings: ${check.layers.length}'); + print(' Image findings: ${check.images.length}'); + print(' Container findings: ${check.containers.length}'); + print(' Removed images: ${check.removedImages.length}'); + print(' Removed containers: ${check.removedContainers.length}'); + } + + if (runPrune && !confirmPrune) { + stderr.writeln( + 'Refusing to run prune without --yes. Add --yes to confirm destructive cleanup.', + ); + exitCode = 64; + return; + } + + if (runPrune && confirmPrune) { + final prune = await client.systemPrune(options: pruneOptions); + print('System prune completed'); + print(' Reclaimed space: ${prune.reclaimedSpace} bytes'); + print(' Pruned pods: ${prune.podIds.length}'); + print(' Pruned containers: ${prune.containerIds.length}'); + print(' Pruned images: ${prune.imageIds.length}'); + print(' Pruned networks: ${prune.networkNames.length}'); + print(' Pruned volumes: ${prune.volumeIds.length}'); + } + } finally { + await client.close(); + } +} + +String? _readOption(List<String> args, String name) { + final prefix = '--$name='; + for (final arg in args) { + if (arg.startsWith(prefix)) { + return arg.substring(prefix.length); + } + } + return null; +} + +Map<String, List<String>> _readFilters(List<String> args) { + final prefix = '--filter='; + final output = <String, List<String>>{}; + + for (final arg in args) { + if (!arg.startsWith(prefix)) { + continue; + } + + final raw = arg.substring(prefix.length); + final index = raw.indexOf('='); + if (index <= 0 || index == raw.length - 1) { + continue; + } + + final key = raw.substring(0, index); + final value = raw.substring(index + 1); + output.putIfAbsent(key, () => <String>[]).add(value); + } + + return output; +} + +void _printUsage() { + print(''' +System maintenance example + +Usage: + dart run example/system_maintenance_example.dart [options] + +Behavior: + - Always prints a system df snapshot + - Optionally runs consistency check and/or prune + +Examples: + dart run example/system_maintenance_example.dart --check --quick + dart run example/system_maintenance_example.dart --prune --all --volumes --yes + +Options: + --check Run system consistency checks + --quick With --check, skip slower checks + --repair With --check, remove inconsistent images + --repair-lossy With --check, remove inconsistent containers/images + --max-age=<duration> With --check, max age for unreferenced layers + --prune Run system prune (destructive) + --yes Required with --prune to confirm cleanup + --all With --prune, include all unused images + --volumes With --prune, include volumes + --external With --prune, include external data + --build With --prune, include build cache + --filter=<k=v> With --prune, add filter (repeatable) + --help, -h Show this help +'''); +} diff --git a/example/version_info_example.dart b/example/version_info_example.dart new file mode 100644 index 0000000..b8fd6bb --- /dev/null +++ b/example/version_info_example.dart @@ -0,0 +1,15 @@ +import 'package:podman/podman.dart'; + +Future<void> main() async { + final client = PodmanClient(); + + final version = await client.version(); + final info = await client.info(); + + print('Podman client: ${version.clientVersion ?? 'unknown'}'); + print('Podman server: ${version.serverVersion ?? 'unknown'}'); + print('Host OS: ${info.hostOs ?? 'unknown'}'); + print('Host arch: ${info.hostArch ?? 'unknown'}'); + + await client.close(); +} diff --git a/lib/podman.dart b/lib/podman.dart new file mode 100644 index 0000000..424229d --- /dev/null +++ b/lib/podman.dart @@ -0,0 +1,103 @@ +/// Dart client library for interacting with the Podman libpod API. +library; + +export 'src/client/podman_client.dart'; +export 'src/core/http_method.dart'; +export 'src/core/podman_api_exception.dart'; +export 'src/core/podman_exception.dart'; +export 'src/core/podman_parse_exception.dart'; +export 'src/core/podman_transport.dart'; +export 'src/core/podman_transport_request.dart'; +export 'src/core/podman_transport_response.dart'; +export 'src/core/unix_socket_podman_transport.dart'; +export 'src/models/artifacts/artifact_details.dart'; +export 'src/models/artifacts/artifact_add_result.dart'; +export 'src/models/artifacts/artifact_pull_result.dart'; +export 'src/models/artifacts/artifact_push_result.dart'; +export 'src/models/artifacts/artifact_remove_result.dart'; +export 'src/models/artifacts/artifact_summary.dart'; +export 'src/models/containers/container_archive_get_result.dart'; +export 'src/models/containers/container_checkpoint_report.dart'; +export 'src/models/containers/container_create_result.dart'; +export 'src/models/containers/container_details.dart'; +export 'src/models/containers/container_exec_create_result.dart'; +export 'src/models/containers/container_exec_inspect_result.dart'; +export 'src/models/containers/container_exec_start_result.dart'; +export 'src/models/containers/container_health_status.dart'; +export 'src/models/containers/container_restore_report.dart'; +export 'src/models/containers/container_stats.dart'; +export 'src/models/containers/container_summary.dart'; +export 'src/models/containers/container_top_report.dart'; +export 'src/models/containers/container_wait_result.dart'; +export 'src/models/generate_systemd_result.dart'; +export 'src/models/image_details.dart'; +export 'src/models/image_history_entry.dart'; +export 'src/models/image_import_report.dart'; +export 'src/models/image_load_report.dart'; +export 'src/models/image_push_event.dart'; +export 'src/models/image_remove_result.dart'; +export 'src/models/image_summary.dart'; +export 'src/models/image_tree_report.dart'; +export 'src/models/manifests/manifest_create_result.dart'; +export 'src/models/manifests/manifest_delete_result.dart'; +export 'src/models/manifests/manifest_details.dart'; +export 'src/models/manifests/manifest_push_result.dart'; +export 'src/models/networks/network_details.dart'; +export 'src/models/networks/network_prune_report.dart'; +export 'src/models/networks/network_subnet.dart'; +export 'src/models/networks/network_summary.dart'; +export 'src/models/play_kube_report.dart'; +export 'src/models/pods/pod_details.dart'; +export 'src/models/pods/pod_prune_report.dart'; +export 'src/models/pods/pod_stats_report.dart'; +export 'src/models/pods/pod_summary.dart'; +export 'src/models/pods/pod_top_report.dart'; +export 'src/models/podman_event.dart'; +export 'src/models/podman_event_filter.dart'; +export 'src/models/podman_info.dart'; +export 'src/models/podman_version.dart'; +export 'src/models/secrets/secret_create_result.dart'; +export 'src/models/secrets/secret_details.dart'; +export 'src/models/secrets/secret_summary.dart'; +export 'src/models/system/system_check_report.dart'; +export 'src/models/system/system_df_container.dart'; +export 'src/models/system/system_df_image.dart'; +export 'src/models/system/system_df_report.dart'; +export 'src/models/system/system_df_volume.dart'; +export 'src/models/system/system_prune_report.dart'; +export 'src/models/volume_details.dart'; +export 'src/models/volume_prune_report.dart'; +export 'src/models/volume_summary.dart'; +export 'src/options/artifacts/artifact_add_options.dart'; +export 'src/options/artifacts/artifact_pull_options.dart'; +export 'src/options/artifacts/artifact_push_options.dart'; +export 'src/options/artifacts/artifact_remove_options.dart'; +export 'src/options/containers/container_checkpoint_options.dart'; +export 'src/options/containers/container_restore_options.dart'; +export 'src/options/containers/container_top_options.dart'; +export 'src/options/containers/container_update_options.dart'; +export 'src/options/containers/exec_create_options.dart'; +export 'src/options/containers/mount_binding.dart'; +export 'src/options/containers/port_binding.dart'; +export 'src/options/containers/run_options.dart'; +export 'src/options/images/image_export_options.dart'; +export 'src/options/images/image_import_options.dart'; +export 'src/options/images/image_push_options.dart'; +export 'src/options/images/image_remove_options.dart'; +export 'src/options/orchestration/generate_kube_options.dart'; +export 'src/options/orchestration/generate_systemd_options.dart'; +export 'src/options/manifest_create_options.dart'; +export 'src/options/networks/network_connect_options.dart'; +export 'src/options/networks/network_create_options.dart'; +export 'src/options/networks/network_prune_options.dart'; +export 'src/options/networks/network_update_options.dart'; +export 'src/options/orchestration/play_kube_options.dart'; +export 'src/options/pods/pod_create_options.dart'; +export 'src/options/pods/pod_stats_options.dart'; +export 'src/options/pods/pod_top_options.dart'; +export 'src/options/secret_create_options.dart'; +export 'src/options/system_check_options.dart'; +export 'src/options/system_prune_options.dart'; +export 'src/options/volume_create_options.dart'; +export 'src/options/volume_list_options.dart'; +export 'src/options/volume_prune_options.dart'; diff --git a/lib/src/client/artifacts.dart b/lib/src/client/artifacts.dart new file mode 100644 index 0000000..536348c --- /dev/null +++ b/lib/src/client/artifacts.dart @@ -0,0 +1,191 @@ +part of 'podman_client.dart'; + +extension PodmanClientArtifactsApi on PodmanClient { + /// Lists local OCI artifacts. + Future<List<ArtifactSummary>> listArtifacts({Duration? timeout}) async { + final payload = await _getList('/artifacts/json', timeout: timeout); + return payload.map(ArtifactSummary.fromJson).toList(growable: false); + } + + /// Inspects a local OCI artifact by name or digest. + Future<ArtifactDetails> inspectArtifact( + String name, { + Duration? timeout, + }) async { + final payload = await _getObject( + '/artifacts/${_encodePath(name)}/json', + timeout: timeout, + ); + return ArtifactDetails.fromJson(payload); + } + + /// Pulls an OCI artifact from a registry. + Future<ArtifactPullResult> pullArtifact( + String name, { + ArtifactPullOptions options = const ArtifactPullOptions(), + Duration? timeout, + }) async { + final query = <String, List<String>>{ + 'name': <String>[name], + ...options.toQueryParameters(), + }; + + final response = await _send( + method: HttpMethod.post, + path: '/artifacts/pull', + queryParameters: query, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return ArtifactPullResult.fromJson( + _decodeObject(response.bodyText, '/artifacts/pull'), + ); + } + + /// Adds a binary blob as a new or existing OCI artifact entry. + Future<ArtifactAddResult> addArtifact( + String name, { + required String fileName, + required List<int> fileBytes, + ArtifactAddOptions options = const ArtifactAddOptions(), + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/artifacts/add', + queryParameters: options.toQueryParameters( + name: name, + fileName: fileName, + ), + body: fileBytes, + expectedStatusCodes: const <int>{201}, + timeout: timeout, + ); + + return ArtifactAddResult.fromJson( + _decodeObject(response.bodyText, '/artifacts/add'), + ); + } + + /// Adds a server-local file as an OCI artifact entry. + Future<ArtifactAddResult> addLocalArtifact( + String name, { + required String path, + required String fileName, + ArtifactAddOptions options = const ArtifactAddOptions(), + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/artifacts/local/add', + queryParameters: options.toQueryParameters( + name: name, + fileName: fileName, + path: path, + ), + expectedStatusCodes: const <int>{201}, + timeout: timeout, + ); + + return ArtifactAddResult.fromJson( + _decodeObject(response.bodyText, '/artifacts/local/add'), + ); + } + + /// Pushes an OCI artifact to a registry. + Future<ArtifactPushResult> pushArtifact( + String name, { + ArtifactPushOptions options = const ArtifactPushOptions(), + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/artifacts/${_encodePath(name)}/push', + queryParameters: options.toQueryParameters(), + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return ArtifactPushResult.fromJson( + _decodeObject(response.bodyText, '/artifacts/$name/push'), + ); + } + + /// Removes a single OCI artifact. + Future<ArtifactRemoveResult> removeArtifact( + String name, { + bool ignoreMissing = false, + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.delete, + path: '/artifacts/${_encodePath(name)}', + expectedStatusCodes: ignoreMissing + ? const <int>{200, 202, 204, 404} + : const <int>{200, 202, 204}, + timeout: timeout, + ); + + if (response.bodyText.trim().isEmpty) { + return const ArtifactRemoveResult( + artifactDigests: <String>[], + raw: <String, Object?>{}, + ); + } + + return ArtifactRemoveResult.fromJson( + _decodeObject(response.bodyText, '/artifacts/$name'), + ); + } + + /// Removes one or more artifacts, or all artifacts. + Future<ArtifactRemoveResult> removeArtifacts( + ArtifactRemoveOptions options, { + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.delete, + path: '/artifacts/remove', + queryParameters: options.toQueryParameters(), + expectedStatusCodes: const <int>{200, 202, 204}, + timeout: timeout, + ); + + if (response.bodyText.trim().isEmpty) { + return const ArtifactRemoveResult( + artifactDigests: <String>[], + raw: <String, Object?>{}, + ); + } + + return ArtifactRemoveResult.fromJson( + _decodeObject(response.bodyText, '/artifacts/remove'), + ); + } + + /// Extracts artifact content as a tar stream. + Future<Uint8List> extractArtifact( + String name, { + String? title, + String? digest, + bool excludeTitle = false, + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.get, + path: '/artifacts/${_encodePath(name)}/extract', + queryParameters: <String, List<String>>{ + if (digest != null && digest.trim().isNotEmpty) + 'digest': <String>[digest.trim()], + if (title != null && title.trim().isNotEmpty) + 'title': <String>[title.trim()], + if (excludeTitle) 'excludetitle': const <String>['true'], + }, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return Uint8List.fromList(response.bodyBytes); + } +} diff --git a/lib/src/client/containers/container_admin.dart b/lib/src/client/containers/container_admin.dart new file mode 100644 index 0000000..8f5c9c2 --- /dev/null +++ b/lib/src/client/containers/container_admin.dart @@ -0,0 +1,142 @@ +part of '../podman_client.dart'; + +extension PodmanClientContainerAdminApi on PodmanClient { + /// Sends a signal to a running container. + Future<void> killContainer( + String container, { + String signal = 'KILL', + Duration? timeout, + }) async { + final trimmedSignal = signal.trim(); + await _send( + method: HttpMethod.post, + path: '/containers/${_encodePath(container)}/kill', + queryParameters: <String, List<String>>{ + if (trimmedSignal.isNotEmpty && trimmedSignal != 'KILL') + 'signal': <String>[trimmedSignal], + }, + expectedStatusCodes: const <int>{204}, + timeout: timeout, + ); + } + + /// Pauses all processes in a container. + Future<void> pauseContainer(String container, {Duration? timeout}) async { + await _send( + method: HttpMethod.post, + path: '/containers/${_encodePath(container)}/pause', + expectedStatusCodes: const <int>{204}, + timeout: timeout, + ); + } + + /// Unpauses a paused container. + Future<void> unpauseContainer(String container, {Duration? timeout}) async { + await _send( + method: HttpMethod.post, + path: '/containers/${_encodePath(container)}/unpause', + expectedStatusCodes: const <int>{204}, + timeout: timeout, + ); + } + + /// Lists processes running in a container. + /// + /// Streaming top output is intentionally unsupported in this high-level API + /// because this transport currently captures full HTTP bodies. + Future<ContainerTopReport> topContainer( + String container, { + ContainerTopOptions options = const ContainerTopOptions(), + Duration? timeout, + }) async { + final payload = await _getObject( + '/containers/${_encodePath(container)}/top', + queryParameters: options.toQueryParameters(), + timeout: timeout, + ); + return ContainerTopReport.fromJson(payload); + } + + /// Performs container initialization tasks without starting it. + Future<void> initContainer(String container, {Duration? timeout}) async { + await _send( + method: HttpMethod.post, + path: '/containers/${_encodePath(container)}/init', + expectedStatusCodes: const <int>{204, 304}, + timeout: timeout, + ); + } + + /// Renames a container. + Future<void> renameContainer( + String container, { + required String name, + Duration? timeout, + }) async { + await _send( + method: HttpMethod.post, + path: '/containers/${_encodePath(container)}/rename', + queryParameters: <String, List<String>>{ + 'name': <String>[name], + }, + expectedStatusCodes: const <int>{204}, + timeout: timeout, + ); + } + + /// Updates mutable container configuration values. + /// + /// Returns the container ID from Podman's update response when present. + Future<String?> updateContainer( + String container, + ContainerUpdateOptions options, { + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/containers/${_encodePath(container)}/update', + queryParameters: options.toQueryParameters(), + body: options.toApiBody(), + expectedStatusCodes: const <int>{200, 201}, + timeout: timeout, + ); + + final body = response.bodyText.trim(); + if (body.isEmpty) { + return null; + } + + return body; + } + + /// Mounts a container filesystem and returns its host mount path. + Future<String> mountContainer(String container, {Duration? timeout}) async { + final response = await _send( + method: HttpMethod.post, + path: '/containers/${_encodePath(container)}/mount', + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return response.bodyText.trim(); + } + + /// Unmounts a mounted container filesystem. + Future<void> unmountContainer(String container, {Duration? timeout}) async { + await _send( + method: HttpMethod.post, + path: '/containers/${_encodePath(container)}/unmount', + expectedStatusCodes: const <int>{204}, + timeout: timeout, + ); + } + + /// Lists mounted containers and their mount paths keyed by container ID. + Future<Map<String, String>> showMountedContainers({Duration? timeout}) async { + final payload = await _getObject( + '/containers/showmounted', + timeout: timeout, + ); + return payload.map((key, value) => MapEntry(key, asString(value) ?? '')); + } +} diff --git a/lib/src/client/containers/container_archive.dart b/lib/src/client/containers/container_archive.dart new file mode 100644 index 0000000..d153c9b --- /dev/null +++ b/lib/src/client/containers/container_archive.dart @@ -0,0 +1,77 @@ +part of '../podman_client.dart'; + +extension PodmanClientContainerArchiveApi on PodmanClient { + /// Reads archive metadata headers for a path in a container. + Future<String?> headContainerArchive( + String container, { + required String path, + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.head, + path: '/containers/${_encodePath(container)}/archive', + queryParameters: <String, List<String>>{ + 'path': <String>[path], + }, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return _firstHeaderValue(response.headers, 'X-Docker-Container-Path-Stat'); + } + + /// Copies a container path as a tar archive. + Future<ContainerArchiveGetResult> getContainerArchive( + String container, { + required String path, + Map<String, String> rename = const <String, String>{}, + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.get, + path: '/containers/${_encodePath(container)}/archive', + queryParameters: <String, List<String>>{ + 'path': <String>[path], + if (rename.isNotEmpty) 'rename': <String>[jsonEncode(rename)], + }, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return ContainerArchiveGetResult( + archiveBytes: response.bodyBytes, + pathStatHeader: _firstHeaderValue( + response.headers, + 'X-Docker-Container-Path-Stat', + ), + headers: response.headers, + ); + } + + /// Extracts a tar archive into a container path. + Future<void> putContainerArchive( + String container, { + required String path, + required List<int> archiveBytes, + bool copyUidGid = true, + bool noOverwriteDirNonDir = false, + Map<String, String> rename = const <String, String>{}, + Duration? timeout, + }) async { + await _send( + method: HttpMethod.put, + path: '/containers/${_encodePath(container)}/archive', + queryParameters: <String, List<String>>{ + 'path': <String>[path], + if (!copyUidGid) 'copyUIDGID': const <String>['false'], + if (noOverwriteDirNonDir) + 'noOverwriteDirNonDir': const <String>['true'], + if (rename.isNotEmpty) 'rename': <String>[jsonEncode(rename)], + }, + headers: const <String, String>{'content-type': 'application/x-tar'}, + body: archiveBytes, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + } +} diff --git a/lib/src/client/containers/container_checkpoint.dart b/lib/src/client/containers/container_checkpoint.dart new file mode 100644 index 0000000..92e0a69 --- /dev/null +++ b/lib/src/client/containers/container_checkpoint.dart @@ -0,0 +1,87 @@ +part of '../podman_client.dart'; + +extension PodmanClientContainerCheckpointApi on PodmanClient { + /// Checkpoints a container and returns the report. + Future<ContainerCheckpointReport> checkpointContainer( + String container, { + ContainerCheckpointOptions options = const ContainerCheckpointOptions(), + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/containers/${_encodePath(container)}/checkpoint', + queryParameters: options.toQueryParameters(), + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return ContainerCheckpointReport.fromJson( + _decodeObject(response.bodyText, '/containers/$container/checkpoint'), + ); + } + + /// Exports a container checkpoint archive as tar bytes. + Future<List<int>> exportContainerCheckpoint( + String container, { + ContainerCheckpointOptions options = const ContainerCheckpointOptions(), + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/containers/${_encodePath(container)}/checkpoint', + queryParameters: options.toQueryParameters(exportArchive: true), + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return response.bodyBytes; + } + + /// Restores a previously checkpointed container. + Future<ContainerRestoreReport> restoreContainer( + String container, { + ContainerRestoreOptions options = const ContainerRestoreOptions(), + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/containers/${_encodePath(container)}/restore', + queryParameters: options.toQueryParameters(), + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return ContainerRestoreReport.fromJson( + _decodeObject(response.bodyText, '/containers/$container/restore'), + ); + } + + /// Restores a container from checkpoint archive bytes. + Future<ContainerRestoreReport> restoreContainerFromArchive( + List<int> archiveBytes, { + String importName = 'import', + ContainerRestoreOptions options = const ContainerRestoreOptions(), + Duration? timeout, + }) async { + if (archiveBytes.isEmpty) { + throw ArgumentError.value( + archiveBytes, + 'archiveBytes', + 'archiveBytes cannot be empty.', + ); + } + + final response = await _send( + method: HttpMethod.post, + path: '/containers/${_encodePath(importName)}/restore', + queryParameters: options.toQueryParameters(importArchive: true), + body: archiveBytes, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return ContainerRestoreReport.fromJson( + _decodeObject(response.bodyText, '/containers/$importName/restore'), + ); + } +} diff --git a/lib/src/client/containers/container_runtime.dart b/lib/src/client/containers/container_runtime.dart new file mode 100644 index 0000000..2d24320 --- /dev/null +++ b/lib/src/client/containers/container_runtime.dart @@ -0,0 +1,242 @@ +part of '../podman_client.dart'; + +extension PodmanClientContainerRuntimeApi on PodmanClient { + /// Waits for container state changes and returns wait status code. + Future<ContainerWaitResult> wait( + String container, { + Iterable<String> conditions = const <String>['stopped'], + Duration? timeout, + }) async { + final conditionValues = conditions + .map((value) => value.trim()) + .where((value) => value.isNotEmpty) + .toList(growable: false); + + final response = await _send( + method: HttpMethod.post, + path: '/containers/${_encodePath(container)}/wait', + queryParameters: <String, List<String>>{ + if (conditionValues.isNotEmpty) 'condition': conditionValues, + }, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return ContainerWaitResult.fromBody(response.bodyText); + } + + /// Returns container health status (or `none` when no healthcheck exists). + Future<ContainerHealthStatus> healthStatus( + String container, { + Duration? timeout, + }) async { + final details = await inspectContainer(container, timeout: timeout); + return ContainerHealthStatus.fromInspect(details.raw); + } + + /// Creates an exec process in a running container. + Future<ContainerExecCreateResult> createExec( + String container, + ExecCreateOptions options, { + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/containers/${_encodePath(container)}/exec', + body: options.toApiBody(), + expectedStatusCodes: const <int>{201}, + timeout: timeout, + ); + + return ContainerExecCreateResult.fromJson( + _decodeObject(response.bodyText, '/containers/$container/exec'), + ); + } + + /// Starts an exec process and captures output. + Future<ContainerExecStartResult> startExec( + String execId, { + bool detach = false, + bool tty = false, + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/exec/${_encodePath(execId)}/start', + body: <String, Object?>{'Detach': detach, 'Tty': tty}, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + if (detach) { + return const ContainerExecStartResult(); + } + + return _decodeExecOutput(response.bodyBytes); + } + + /// Inspects an exec process. + Future<ContainerExecInspectResult> inspectExec( + String execId, { + Duration? timeout, + }) async { + final payload = await _getObject( + '/exec/${_encodePath(execId)}/json', + timeout: timeout, + ); + return ContainerExecInspectResult.fromJson(payload); + } + + /// Resizes a container TTY. + Future<void> resizeContainerTty( + String container, { + required int width, + required int height, + bool ignoreNotRunning = false, + Duration? timeout, + }) async { + await _send( + method: HttpMethod.post, + path: '/containers/${_encodePath(container)}/resize', + queryParameters: <String, List<String>>{ + 'w': <String>['$width'], + 'h': <String>['$height'], + if (ignoreNotRunning) 'running': const <String>['true'], + }, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + } + + /// Resizes an exec session TTY. + Future<void> resizeExecTty( + String execId, { + required int width, + required int height, + bool ignoreNotRunning = false, + Duration? timeout, + }) async { + await _send( + method: HttpMethod.post, + path: '/exec/${_encodePath(execId)}/resize', + queryParameters: <String, List<String>>{ + 'w': <String>['$width'], + 'h': <String>['$height'], + if (ignoreNotRunning) 'running': const <String>['true'], + }, + expectedStatusCodes: const <int>{200, 201}, + timeout: timeout, + ); + } + + /// Returns a single stats snapshot. + /// + /// `stream=true` is intentionally unsupported in this high-level API because + /// this transport currently captures full HTTP bodies. + Future<ContainerStats> stats( + String container, { + bool stream = false, + Duration? timeout, + }) async { + if (stream) { + throw ArgumentError.value( + stream, + 'stream', + 'stream=true is not supported by this client yet.', + ); + } + + final payload = await _getObject( + '/containers/${_encodePath(container)}/stats', + queryParameters: <String, List<String>>{ + 'stream': <String>['false'], + }, + timeout: timeout, + ); + + return ContainerStats.fromJson(payload); + } + + /// Polls container stats continuously. + Stream<ContainerStats> watchStats( + String container, { + Duration pollInterval = const Duration(seconds: 2), + bool reconnect = true, + Duration reconnectDelay = const Duration(seconds: 1), + Duration? timeout, + }) { + final controller = StreamController<ContainerStats>(); + var closed = false; + + Future<void> loop() async { + while (!closed) { + try { + final snapshot = await stats(container, timeout: timeout); + if (closed) { + break; + } + controller.add(snapshot); + await Future<void>.delayed(pollInterval); + } catch (error, stackTrace) { + if (!reconnect || closed) { + controller.addError(error, stackTrace); + await controller.close(); + return; + } + await Future<void>.delayed(reconnectDelay); + } + } + } + + controller.onListen = loop; + controller.onCancel = () async { + closed = true; + }; + + return controller.stream; + } + + /// Polls container `top` output continuously. + Stream<ContainerTopReport> watchContainerTop( + String container, { + ContainerTopOptions options = const ContainerTopOptions(), + Duration pollInterval = const Duration(seconds: 2), + bool reconnect = true, + Duration reconnectDelay = const Duration(seconds: 1), + Duration? timeout, + }) { + final controller = StreamController<ContainerTopReport>(); + var closed = false; + + Future<void> loop() async { + while (!closed) { + try { + final report = await topContainer( + container, + options: options, + timeout: timeout, + ); + if (closed) { + break; + } + controller.add(report); + await Future<void>.delayed(pollInterval); + } catch (error, stackTrace) { + if (!reconnect || closed) { + controller.addError(error, stackTrace); + await controller.close(); + return; + } + await Future<void>.delayed(reconnectDelay); + } + } + } + + controller.onListen = loop; + controller.onCancel = () async { + closed = true; + }; + + return controller.stream; + } +} diff --git a/lib/src/client/containers/containers.dart b/lib/src/client/containers/containers.dart new file mode 100644 index 0000000..5eb90d1 --- /dev/null +++ b/lib/src/client/containers/containers.dart @@ -0,0 +1,241 @@ +part of '../podman_client.dart'; + +extension PodmanClientContainerApi on PodmanClient { + /// Lists containers. + Future<List<ContainerSummary>> listContainers({ + bool all = true, + Duration? timeout, + }) async { + final payload = await _getList( + '/containers/json', + queryParameters: <String, List<String>>{ + 'all': <String>['$all'], + }, + timeout: timeout, + ); + + return payload.map(ContainerSummary.fromJson).toList(growable: false); + } + + /// Creates a container without starting it. + Future<ContainerCreateResult> createContainer( + RunOptions options, { + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/containers/create', + queryParameters: <String, List<String>>{ + if (options.name != null && options.name!.isNotEmpty) + 'name': <String>[options.name!], + }, + body: options.toCreateBody(), + expectedStatusCodes: const <int>{201}, + timeout: timeout, + ); + + return ContainerCreateResult.fromJson( + _decodeObject(response.bodyText, '/containers/create'), + ); + } + + /// Creates and starts a container, returning the container ID. + Future<String> run(RunOptions options, {Duration? timeout}) async { + final created = await createContainer(options, timeout: timeout); + await start(created.id, timeout: timeout); + return created.id; + } + + /// Inspects a single container. + Future<ContainerDetails> inspectContainer( + String container, { + Duration? timeout, + }) async { + final payload = await _getObject( + '/containers/${_encodePath(container)}/json', + timeout: timeout, + ); + return ContainerDetails.fromJson(payload); + } + + /// Whether a container exists. + Future<bool> containerExists(String container, {Duration? timeout}) async { + final response = await _send( + method: HttpMethod.get, + path: '/containers/${_encodePath(container)}/exists', + expectedStatusCodes: const <int>{204, 404}, + timeout: timeout, + ); + return response.statusCode == 204; + } + + /// Starts a container. + Future<void> start(String container, {Duration? timeout}) async { + await _send( + method: HttpMethod.post, + path: '/containers/${_encodePath(container)}/start', + expectedStatusCodes: const <int>{204, 304}, + timeout: timeout, + ); + } + + /// Stops a container. + Future<void> stop( + String container, { + int? timeoutSeconds, + Duration? timeout, + }) async { + await _send( + method: HttpMethod.post, + path: '/containers/${_encodePath(container)}/stop', + queryParameters: <String, List<String>>{ + if (timeoutSeconds != null) 't': <String>['$timeoutSeconds'], + }, + expectedStatusCodes: const <int>{204, 304}, + timeout: timeout, + ); + } + + /// Restarts a container. + Future<void> restart( + String container, { + int? timeoutSeconds, + Duration? timeout, + }) async { + await _send( + method: HttpMethod.post, + path: '/containers/${_encodePath(container)}/restart', + queryParameters: <String, List<String>>{ + if (timeoutSeconds != null) 't': <String>['$timeoutSeconds'], + }, + expectedStatusCodes: const <int>{204, 304}, + timeout: timeout, + ); + } + + /// Removes a container. + Future<void> removeContainer( + String container, { + bool force = false, + bool removeVolumes = false, + bool ignoreMissing = false, + Duration? timeout, + }) async { + await _send( + method: HttpMethod.delete, + path: '/containers/${_encodePath(container)}', + queryParameters: <String, List<String>>{ + 'force': <String>['$force'], + 'v': <String>['$removeVolumes'], + }, + expectedStatusCodes: ignoreMissing + ? const <int>{200, 202, 204, 404} + : const <int>{200, 202, 204}, + timeout: timeout, + ); + } + + /// Fetches container logs. + Future<String> logs( + String container, { + int? tail, + DateTime? since, + DateTime? until, + bool follow = false, + bool timestamps = false, + bool stdout = true, + bool stderr = true, + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.get, + path: '/containers/${_encodePath(container)}/logs', + queryParameters: <String, List<String>>{ + 'stdout': <String>['$stdout'], + 'stderr': <String>['$stderr'], + if (follow) 'follow': const <String>['true'], + 'timestamps': <String>['$timestamps'], + if (since != null) 'since': <String>[since.toUtc().toIso8601String()], + if (until != null) 'until': <String>[until.toUtc().toIso8601String()], + if (tail != null) 'tail': <String>['$tail'], + }, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return response.bodyText; + } + + /// Polls container logs with `since` cursors. + Stream<String> watchLogs( + String container, { + int? tail, + bool timestamps = true, + bool stdout = true, + bool stderr = true, + DateTime? since, + Duration pollInterval = const Duration(seconds: 2), + bool reconnect = true, + Duration reconnectDelay = const Duration(seconds: 1), + Duration? timeout, + }) { + final controller = StreamController<String>(); + var closed = false; + var firstRequest = true; + var cursorSince = since?.toUtc() ?? DateTime.now().toUtc(); + + Future<void> loop() async { + while (!closed) { + try { + final nextCursor = DateTime.now().toUtc(); + final chunk = await logs( + container, + tail: firstRequest ? tail : null, + since: cursorSince, + timestamps: timestamps, + stdout: stdout, + stderr: stderr, + timeout: timeout, + ); + firstRequest = false; + cursorSince = nextCursor; + + if (closed) { + break; + } + + if (chunk.trim().isNotEmpty) { + controller.add(chunk); + } + + await Future<void>.delayed(pollInterval); + } catch (error, stackTrace) { + if (!reconnect || closed) { + controller.addError(error, stackTrace); + await controller.close(); + return; + } + + await Future<void>.delayed(reconnectDelay); + } + } + } + + controller.onListen = loop; + controller.onCancel = () async { + closed = true; + }; + + return controller.stream; + } + + /// Removes stopped containers. + Future<void> pruneContainers({Duration? timeout}) async { + await _send( + method: HttpMethod.post, + path: '/containers/prune', + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + } +} diff --git a/lib/src/client/events.dart b/lib/src/client/events.dart new file mode 100644 index 0000000..c96c701 --- /dev/null +++ b/lib/src/client/events.dart @@ -0,0 +1,95 @@ +part of 'podman_client.dart'; + +extension PodmanClientEventsApi on PodmanClient { + /// Fetches events from `/libpod/events` in non-streaming mode. + Future<List<PodmanEvent>> listEvents({ + Iterable<PodmanEventFilter> filters = const <PodmanEventFilter>[], + DateTime? since, + DateTime? until, + bool fromStart = false, + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.get, + path: '/events', + queryParameters: <String, List<String>>{ + 'stream': const <String>['false'], + if (fromStart) 'fromStart': const <String>['true'], + if (since != null) + 'since': <String>['${since.millisecondsSinceEpoch ~/ 1000}'], + if (until != null) + 'until': <String>['${until.millisecondsSinceEpoch ~/ 1000}'], + if (filters.isNotEmpty) + 'filters': filters + .map((item) => item.asQueryValue()) + .toList(growable: false), + }, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + final items = _decodeNdjsonObjects(response.bodyText, '/events'); + return items.map(PodmanEvent.fromJson).toList(growable: false); + } + + /// Watches events continuously with reconnect support. + Stream<PodmanEvent> watchEvents({ + Iterable<PodmanEventFilter> filters = const <PodmanEventFilter>[], + DateTime? since, + bool fromStart = false, + Duration pollInterval = const Duration(seconds: 1), + bool reconnect = true, + Duration reconnectDelay = const Duration(seconds: 1), + }) { + final controller = StreamController<PodmanEvent>(); + var closed = false; + var cursorSince = since; + var firstRequest = true; + + Future<void> loop() async { + while (!closed) { + try { + final events = await listEvents( + filters: filters, + since: cursorSince, + fromStart: firstRequest && fromStart, + ); + firstRequest = false; + + for (final event in events) { + if (closed) { + break; + } + + controller.add(event); + final eventTime = event.timestamp; + if (eventTime != null) { + cursorSince = eventTime.toUtc(); + } + } + + if (closed) { + break; + } + + await Future<void>.delayed(pollInterval); + } catch (error, stackTrace) { + if (!reconnect || closed) { + controller.addError(error, stackTrace); + await controller.close(); + return; + } + + await Future<void>.delayed(reconnectDelay); + } + } + } + + controller.onListen = loop; + controller.onCancel = () async { + closed = true; + }; + + return controller.stream; + } +} diff --git a/lib/src/client/images.dart b/lib/src/client/images.dart new file mode 100644 index 0000000..018cc1a --- /dev/null +++ b/lib/src/client/images.dart @@ -0,0 +1,359 @@ +part of 'podman_client.dart'; + +extension PodmanClientImageApi on PodmanClient { + /// Lists available images. + Future<List<ImageSummary>> listImages({ + bool all = false, + Map<String, List<String>> filters = const <String, List<String>>{}, + Duration? timeout, + }) async { + final normalizedFilters = _normalizeFilters(filters); + final payload = await _getList( + '/images/json', + queryParameters: <String, List<String>>{ + 'all': <String>['$all'], + if (normalizedFilters.isNotEmpty) + 'filters': <String>[jsonEncode(normalizedFilters)], + }, + timeout: timeout, + ); + + return payload.map(ImageSummary.fromJson).toList(growable: false); + } + + /// Inspects a local image by name or ID. + Future<ImageDetails> inspectImage(String image, {Duration? timeout}) async { + final payload = await _getObject( + '/images/${_encodePath(image)}/json', + timeout: timeout, + ); + return ImageDetails.fromJson(payload); + } + + /// Returns image history entries. + Future<List<ImageHistoryEntry>> imageHistory( + String image, { + Duration? timeout, + }) async { + final payload = await _getList( + '/images/${_encodePath(image)}/history', + timeout: timeout, + ); + return payload.map(ImageHistoryEntry.fromJson).toList(growable: false); + } + + /// Returns a printable image tree report. + Future<ImageTreeReport> imageTree( + String image, { + bool whatRequires = false, + Duration? timeout, + }) async { + final payload = await _getObject( + '/images/${_encodePath(image)}/tree', + queryParameters: <String, List<String>>{ + if (whatRequires) 'whatrequires': const <String>['true'], + }, + timeout: timeout, + ); + return ImageTreeReport.fromJson(payload); + } + + /// Pulls an image and returns the raw API response body. + Future<String> pull( + String image, { + bool quiet = false, + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/images/pull', + queryParameters: <String, List<String>>{ + 'reference': <String>[image], + 'quiet': <String>['$quiet'], + }, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return response.bodyText; + } + + /// Pushes an image to a registry. + /// + /// In quiet mode, Podman may return an empty body. + Future<List<ImagePushEvent>> pushImage( + String image, { + ImagePushOptions options = const ImagePushOptions(), + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/images/${_encodePath(image)}/push', + queryParameters: options.toQueryParameters(), + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + final events = _decodeNdjsonObjects( + response.bodyText, + '/images/$image/push', + ); + return events.map(ImagePushEvent.fromJson).toList(growable: false); + } + + /// Loads images from a tar archive payload. + Future<ImageLoadReport> loadImages( + List<int> archiveBytes, { + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/images/load', + body: archiveBytes, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return ImageLoadReport.fromJson( + _decodeObject(response.bodyText, '/images/load'), + ); + } + + /// Loads images from a server-local archive path. + Future<ImageLoadReport> loadImagesFromPath( + String path, { + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/images/load', + queryParameters: <String, List<String>>{ + 'path': <String>[path], + }, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return ImageLoadReport.fromJson( + _decodeObject(response.bodyText, '/images/load'), + ); + } + + /// Imports an image from request body bytes or from a URL in [options]. + Future<ImageImportReport> importImage({ + ImageImportOptions options = const ImageImportOptions(), + List<int>? archiveBytes, + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/images/import', + queryParameters: options.toQueryParameters(), + body: archiveBytes, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return ImageImportReport.fromJson( + _decodeObject(response.bodyText, '/images/import'), + ); + } + + /// Exports a single image archive. + Future<Uint8List> exportImage( + String image, { + ImageExportOptions options = const ImageExportOptions(), + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.get, + path: '/images/${_encodePath(image)}/get', + queryParameters: options.toQueryParameters(), + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return Uint8List.fromList(response.bodyBytes); + } + + /// Exports one or more image references into a single archive. + Future<Uint8List> exportImages( + Iterable<String> references, { + ImageExportOptions options = const ImageExportOptions(), + Duration? timeout, + }) async { + final refs = references + .map((value) => value.trim()) + .where((value) => value.isNotEmpty) + .toList(growable: false); + if (refs.isEmpty) { + throw ArgumentError.value( + references, + 'references', + 'At least one image reference is required.', + ); + } + + if (refs.length > 1 && options.format != 'docker-archive') { + throw ArgumentError.value( + options.format, + 'options.format', + 'Multi-image export requires format `docker-archive`.', + ); + } + + final response = await _send( + method: HttpMethod.get, + path: '/images/export', + queryParameters: <String, List<String>>{ + ...options.toQueryParameters(), + 'references': refs, + }, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return Uint8List.fromList(response.bodyBytes); + } + + /// Whether an image exists locally. + Future<bool> imageExists(String image, {Duration? timeout}) async { + final response = await _send( + method: HttpMethod.get, + path: '/images/${_encodePath(image)}/exists', + expectedStatusCodes: const <int>{204, 404}, + timeout: timeout, + ); + + return response.statusCode == 204; + } + + /// Removes an image. + Future<void> removeImage( + String image, { + bool force = false, + bool ignoreMissing = false, + Duration? timeout, + }) async { + await _send( + method: HttpMethod.delete, + path: '/images/${_encodePath(image)}', + queryParameters: <String, List<String>>{ + 'force': <String>['$force'], + }, + expectedStatusCodes: ignoreMissing + ? const <int>{200, 202, 204, 404} + : const <int>{200, 202, 204}, + timeout: timeout, + ); + } + + /// Adds a tag to an image. + Future<void> tagImage( + String source, + String target, { + Duration? timeout, + }) async { + final parsed = _parseReference(target); + + await _send( + method: HttpMethod.post, + path: '/images/${_encodePath(source)}/tag', + queryParameters: <String, List<String>>{ + 'repo': <String>[parsed.repo], + 'tag': <String>[parsed.tag], + }, + expectedStatusCodes: const <int>{200, 201}, + timeout: timeout, + ); + } + + /// Removes unused images. + Future<void> pruneImages({bool all = false, Duration? timeout}) async { + await _send( + method: HttpMethod.post, + path: '/images/prune', + queryParameters: <String, List<String>>{ + 'all': <String>['$all'], + }, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + } + + /// Removes one or more images. + Future<ImageRemoveResult> removeImages( + ImageRemoveOptions options, { + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.delete, + path: '/images/remove', + queryParameters: options.toQueryParameters(), + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + if (response.bodyText.trim().isEmpty) { + return const ImageRemoveResult( + deleted: <String>[], + untagged: <String>[], + exitCode: 0, + errors: <String>[], + raw: <String, Object?>{}, + ); + } + + return ImageRemoveResult.fromJson( + _decodeObject(response.bodyText, '/images/remove'), + ); + } + + _ImageReference _parseReference(String reference) { + final digestIndex = reference.indexOf('@'); + final withoutDigest = digestIndex >= 0 + ? reference.substring(0, digestIndex) + : reference; + + final slashIndex = withoutDigest.lastIndexOf('/'); + final colonIndex = withoutDigest.lastIndexOf(':'); + + if (colonIndex > slashIndex) { + return _ImageReference( + repo: withoutDigest.substring(0, colonIndex), + tag: withoutDigest.substring(colonIndex + 1), + ); + } + + return _ImageReference(repo: withoutDigest, tag: 'latest'); + } + + Map<String, List<String>> _normalizeFilters(Map<String, List<String>> input) { + final sortedKeys = input.keys.toList(growable: false)..sort(); + final normalized = <String, List<String>>{}; + + for (final key in sortedKeys) { + final values = input[key]; + if (values == null || values.isEmpty) { + continue; + } + + final candidates = values + .map((value) => value.trim()) + .where((value) => value.isNotEmpty) + .toList(growable: false); + if (candidates.isNotEmpty) { + normalized[key] = candidates; + } + } + + return normalized; + } +} + +final class _ImageReference { + const _ImageReference({required this.repo, required this.tag}); + + final String repo; + final String tag; +} diff --git a/lib/src/client/kube.dart b/lib/src/client/kube.dart new file mode 100644 index 0000000..3b2bde8 --- /dev/null +++ b/lib/src/client/kube.dart @@ -0,0 +1,80 @@ +part of 'podman_client.dart'; + +extension PodmanClientKubeApi on PodmanClient { + /// Plays a Kubernetes YAML document. + Future<PlayKubeReport> playKube( + String yaml, { + PlayKubeOptions options = const PlayKubeOptions(), + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/play/kube', + queryParameters: options.toQueryParameters(), + body: yaml, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return PlayKubeReport.fromJson( + _decodeObject(response.bodyText, '/play/kube'), + ); + } + + /// Tears down resources defined in a Kubernetes YAML document. + Future<PlayKubeReport> playKubeDown( + String yaml, { + bool force = false, + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.delete, + path: '/play/kube', + queryParameters: <String, List<String>>{ + if (force) 'force': const <String>['true'], + }, + body: yaml, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return PlayKubeReport.fromJson( + _decodeObject(response.bodyText, '/play/kube'), + ); + } + + /// Generates Kubernetes YAML for containers/pods. + Future<String> generateKube( + GenerateKubeOptions options, { + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.get, + path: '/generate/kube', + queryParameters: options.toQueryParameters(), + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return response.bodyText; + } + + /// Generates systemd units for a pod/container. + Future<GenerateSystemdResult> generateSystemd( + String name, { + GenerateSystemdOptions options = const GenerateSystemdOptions(), + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.get, + path: '/generate/${_encodePath(name)}/systemd', + queryParameters: options.toQueryParameters(), + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return GenerateSystemdResult.fromJson( + _decodeObject(response.bodyText, '/generate/$name/systemd'), + ); + } +} diff --git a/lib/src/client/manifests.dart b/lib/src/client/manifests.dart new file mode 100644 index 0000000..521d089 --- /dev/null +++ b/lib/src/client/manifests.dart @@ -0,0 +1,109 @@ +part of 'podman_client.dart'; + +extension PodmanClientManifestApi on PodmanClient { + /// Creates a manifest list. + Future<ManifestCreateResult> createManifest( + String name, { + ManifestCreateOptions options = const ManifestCreateOptions(), + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/manifests/${_encodePath(name)}', + queryParameters: options.toQueryParameters(), + expectedStatusCodes: const <int>{200, 201}, + timeout: timeout, + ); + + return ManifestCreateResult.fromJson( + _decodeObject(response.bodyText, '/manifests/$name'), + ); + } + + /// Whether a manifest list exists. + Future<bool> manifestExists(String name, {Duration? timeout}) async { + final response = await _send( + method: HttpMethod.get, + path: '/manifests/${_encodePath(name)}/exists', + expectedStatusCodes: const <int>{204, 404}, + timeout: timeout, + ); + return response.statusCode == 204; + } + + /// Inspects a manifest list. + Future<ManifestDetails> inspectManifest( + String name, { + bool? tlsVerify, + Duration? timeout, + }) async { + final payload = await _getObject( + '/manifests/${_encodePath(name)}/json', + queryParameters: <String, List<String>>{ + if (tlsVerify != null) 'tlsVerify': <String>['$tlsVerify'], + }, + timeout: timeout, + ); + return ManifestDetails.fromJson(payload); + } + + /// Pushes a manifest list to a registry. + Future<ManifestPushResult> pushManifest( + String name, + String destination, { + bool all = true, + bool? tlsVerify, + bool quiet = true, + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: + '/manifests/${_encodePath(name)}/registry/${_encodePath(destination)}', + queryParameters: <String, List<String>>{ + 'all': <String>['$all'], + 'quiet': <String>['$quiet'], + if (tlsVerify != null) 'tlsVerify': <String>['$tlsVerify'], + }, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return ManifestPushResult.fromJson( + _decodeObject( + response.bodyText, + '/manifests/$name/registry/$destination', + ), + ); + } + + /// Deletes a manifest list. + Future<ManifestDeleteResult> deleteManifest( + String name, { + bool ignoreMissing = false, + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.delete, + path: '/manifests/${_encodePath(name)}', + expectedStatusCodes: ignoreMissing + ? const <int>{200, 202, 204, 404} + : const <int>{200, 202, 204}, + timeout: timeout, + ); + + if (response.bodyText.trim().isEmpty) { + return const ManifestDeleteResult( + deleted: <String>[], + untagged: <String>[], + exitCode: 0, + errors: <String>[], + raw: <String, Object?>{}, + ); + } + + return ManifestDeleteResult.fromJson( + _decodeObject(response.bodyText, '/manifests/$name'), + ); + } +} diff --git a/lib/src/client/networks.dart b/lib/src/client/networks.dart new file mode 100644 index 0000000..567e1ea --- /dev/null +++ b/lib/src/client/networks.dart @@ -0,0 +1,136 @@ +part of 'podman_client.dart'; + +extension PodmanClientNetworkApi on PodmanClient { + /// Lists available networks. + Future<List<NetworkSummary>> listNetworks({Duration? timeout}) async { + final payload = await _getList('/networks/json', timeout: timeout); + return payload.map(NetworkSummary.fromJson).toList(growable: false); + } + + /// Creates a network. + Future<NetworkDetails> createNetwork( + NetworkCreateOptions options, { + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/networks/create', + body: options.toApiBody(), + expectedStatusCodes: const <int>{200, 201}, + timeout: timeout, + ); + + return NetworkDetails.fromJson( + _decodeObject(response.bodyText, '/networks/create'), + ); + } + + /// Inspects a network by name or ID. + Future<NetworkDetails> inspectNetwork( + String network, { + Duration? timeout, + }) async { + final payload = await _getObject( + '/networks/${_encodePath(network)}/json', + timeout: timeout, + ); + return NetworkDetails.fromJson(payload); + } + + /// Whether a network exists. + Future<bool> networkExists(String network, {Duration? timeout}) async { + final response = await _send( + method: HttpMethod.get, + path: '/networks/${_encodePath(network)}/exists', + expectedStatusCodes: const <int>{204, 404}, + timeout: timeout, + ); + return response.statusCode == 204; + } + + /// Updates mutable network settings. + Future<void> updateNetwork( + String network, + NetworkUpdateOptions options, { + Duration? timeout, + }) async { + await _send( + method: HttpMethod.post, + path: '/networks/${_encodePath(network)}/update', + body: options.toApiBody(), + expectedStatusCodes: const <int>{200, 204}, + timeout: timeout, + ); + } + + /// Removes a network. + Future<void> removeNetwork( + String network, { + bool force = false, + bool ignoreMissing = false, + Duration? timeout, + }) async { + await _send( + method: HttpMethod.delete, + path: '/networks/${_encodePath(network)}', + queryParameters: <String, List<String>>{ + if (force) 'force': const <String>['true'], + }, + expectedStatusCodes: ignoreMissing + ? const <int>{200, 202, 204, 404} + : const <int>{200, 202, 204}, + timeout: timeout, + ); + } + + /// Connects a container to a network. + Future<void> connectNetwork( + String network, + NetworkConnectOptions options, { + Duration? timeout, + }) async { + await _send( + method: HttpMethod.post, + path: '/networks/${_encodePath(network)}/connect', + body: options.toApiBody(), + expectedStatusCodes: const <int>{200, 204}, + timeout: timeout, + ); + } + + /// Disconnects a container from a network. + Future<void> disconnectNetwork( + String network, { + required String container, + bool force = false, + bool ignoreMissing = false, + Duration? timeout, + }) async { + await _send( + method: HttpMethod.post, + path: '/networks/${_encodePath(network)}/disconnect', + body: <String, Object?>{'Container': container, 'Force': force}, + expectedStatusCodes: ignoreMissing + ? const <int>{200, 202, 204, 404} + : const <int>{200, 202, 204}, + timeout: timeout, + ); + } + + /// Prunes unused networks. + Future<List<NetworkPruneReport>> pruneNetworks({ + NetworkPruneOptions options = const NetworkPruneOptions(), + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/networks/prune', + queryParameters: options.toQueryParameters(), + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + final payload = _decodeList(response.bodyText, '/networks/prune'); + return payload.map(NetworkPruneReport.fromJson).toList(growable: false); + } +} diff --git a/lib/src/client/podman_client.dart b/lib/src/client/podman_client.dart new file mode 100644 index 0000000..a92a83a --- /dev/null +++ b/lib/src/client/podman_client.dart @@ -0,0 +1,346 @@ +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import '../core/http_method.dart'; +import '../core/podman_api_exception.dart'; +import '../core/podman_parse_exception.dart'; +import '../core/podman_transport.dart'; +import '../core/podman_transport_request.dart'; +import '../core/podman_transport_response.dart'; +import '../core/unix_socket_podman_transport.dart'; +import '../internal/json_utils.dart'; +import '../models/artifacts/artifact_details.dart'; +import '../models/artifacts/artifact_add_result.dart'; +import '../models/artifacts/artifact_pull_result.dart'; +import '../models/artifacts/artifact_push_result.dart'; +import '../models/artifacts/artifact_remove_result.dart'; +import '../models/artifacts/artifact_summary.dart'; +import '../models/containers/container_archive_get_result.dart'; +import '../models/containers/container_checkpoint_report.dart'; +import '../models/containers/container_create_result.dart'; +import '../models/containers/container_details.dart'; +import '../models/containers/container_exec_create_result.dart'; +import '../models/containers/container_exec_inspect_result.dart'; +import '../models/containers/container_exec_start_result.dart'; +import '../models/containers/container_health_status.dart'; +import '../models/containers/container_restore_report.dart'; +import '../models/containers/container_stats.dart'; +import '../models/containers/container_summary.dart'; +import '../models/containers/container_top_report.dart'; +import '../models/containers/container_wait_result.dart'; +import '../models/generate_systemd_result.dart'; +import '../models/image_details.dart'; +import '../models/image_history_entry.dart'; +import '../models/image_import_report.dart'; +import '../models/image_load_report.dart'; +import '../models/image_push_event.dart'; +import '../models/image_remove_result.dart'; +import '../models/image_summary.dart'; +import '../models/image_tree_report.dart'; +import '../models/manifests/manifest_create_result.dart'; +import '../models/manifests/manifest_delete_result.dart'; +import '../models/manifests/manifest_details.dart'; +import '../models/manifests/manifest_push_result.dart'; +import '../models/networks/network_details.dart'; +import '../models/networks/network_prune_report.dart'; +import '../models/networks/network_summary.dart'; +import '../models/play_kube_report.dart'; +import '../models/podman_event.dart'; +import '../models/podman_event_filter.dart'; +import '../models/podman_info.dart'; +import '../models/podman_version.dart'; +import '../models/pods/pod_details.dart'; +import '../models/pods/pod_prune_report.dart'; +import '../models/pods/pod_stats_report.dart'; +import '../models/pods/pod_summary.dart'; +import '../models/pods/pod_top_report.dart'; +import '../models/secrets/secret_create_result.dart'; +import '../models/secrets/secret_details.dart'; +import '../models/secrets/secret_summary.dart'; +import '../models/system/system_check_report.dart'; +import '../models/system/system_df_report.dart'; +import '../models/system/system_prune_report.dart'; +import '../models/volume_details.dart'; +import '../models/volume_prune_report.dart'; +import '../models/volume_summary.dart'; +import '../options/artifacts/artifact_add_options.dart'; +import '../options/artifacts/artifact_pull_options.dart'; +import '../options/artifacts/artifact_push_options.dart'; +import '../options/artifacts/artifact_remove_options.dart'; +import '../options/containers/container_checkpoint_options.dart'; +import '../options/containers/container_restore_options.dart'; +import '../options/containers/container_top_options.dart'; +import '../options/containers/container_update_options.dart'; +import '../options/containers/exec_create_options.dart'; +import '../options/containers/run_options.dart'; +import '../options/images/image_export_options.dart'; +import '../options/images/image_import_options.dart'; +import '../options/images/image_push_options.dart'; +import '../options/images/image_remove_options.dart'; +import '../options/manifest_create_options.dart'; +import '../options/networks/network_connect_options.dart'; +import '../options/networks/network_create_options.dart'; +import '../options/networks/network_prune_options.dart'; +import '../options/networks/network_update_options.dart'; +import '../options/orchestration/generate_kube_options.dart'; +import '../options/orchestration/generate_systemd_options.dart'; +import '../options/orchestration/play_kube_options.dart'; +import '../options/pods/pod_create_options.dart'; +import '../options/pods/pod_stats_options.dart'; +import '../options/pods/pod_top_options.dart'; +import '../options/secret_create_options.dart'; +import '../options/system_check_options.dart'; +import '../options/system_prune_options.dart'; +import '../options/volume_create_options.dart'; +import '../options/volume_list_options.dart'; +import '../options/volume_prune_options.dart'; + +part 'artifacts.dart'; +part 'containers/container_admin.dart'; +part 'containers/container_archive.dart'; +part 'containers/container_checkpoint.dart'; +part 'containers/container_runtime.dart'; +part 'containers/containers.dart'; +part 'events.dart'; +part 'images.dart'; +part 'kube.dart'; +part 'manifests.dart'; +part 'networks.dart'; +part 'pods.dart'; +part 'secrets.dart'; +part 'system.dart'; +part 'volumes.dart'; + +/// High-level Podman libpod API client. +final class PodmanClient { + /// Creates a Podman API client. + PodmanClient({ + PodmanTransport? transport, + this.apiVersion = 'v5.0.0', + String? socketPath, + }) : _transport = + transport ?? UnixSocketPodmanTransport(socketPath: socketPath); + + final PodmanTransport _transport; + + /// API version prefix. + final String apiVersion; + + /// Closes underlying transport resources. + Future<void> close() => _transport.close(); + + Future<PodmanTransportResponse> _send({ + required HttpMethod method, + required String path, + Map<String, List<String>> queryParameters = const <String, List<String>>{}, + Map<String, String> headers = const <String, String>{}, + Object? body, + Set<int> expectedStatusCodes = const <int>{200}, + Duration? timeout, + }) async { + final request = PodmanTransportRequest( + method: method, + path: _endpoint(path), + queryParameters: queryParameters, + headers: headers, + body: body, + timeout: timeout, + ); + + final response = await _transport.send(request); + + if (!expectedStatusCodes.contains(response.statusCode)) { + throw PodmanApiException( + method: method, + path: _endpoint(path), + statusCode: response.statusCode, + responseBody: response.bodyText, + ); + } + + return response; + } + + Future<Map<String, Object?>> _getObject( + String path, { + Map<String, List<String>> queryParameters = const <String, List<String>>{}, + Set<int> expectedStatusCodes = const <int>{200}, + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.get, + path: path, + queryParameters: queryParameters, + expectedStatusCodes: expectedStatusCodes, + timeout: timeout, + ); + return _decodeObject(response.bodyText, path); + } + + Future<List<Map<String, Object?>>> _getList( + String path, { + Map<String, List<String>> queryParameters = const <String, List<String>>{}, + Set<int> expectedStatusCodes = const <int>{200}, + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.get, + path: path, + queryParameters: queryParameters, + expectedStatusCodes: expectedStatusCodes, + timeout: timeout, + ); + return _decodeList(response.bodyText, path); + } + + Map<String, Object?> _decodeObject(String bodyText, String path) { + final decoded = _decode(bodyText, path); + if (decoded is Map<String, Object?>) { + return decoded; + } + if (decoded is Map) { + return decoded.map((key, value) => MapEntry(key.toString(), value)); + } + throw PodmanParseException('Expected JSON object from `$path`.'); + } + + List<Map<String, Object?>> _decodeList(String bodyText, String path) { + final trimmed = bodyText.trim(); + if (trimmed.isEmpty) { + return const <Map<String, Object?>>[]; + } + + final decoded = _decode(trimmed, path); + if (decoded is List) { + return decoded.map(asJsonMap).toList(growable: false); + } + throw PodmanParseException('Expected JSON array from `$path`.'); + } + + Object _decode(String bodyText, String path) { + try { + return jsonDecode(bodyText); + } on FormatException catch (error) { + throw PodmanParseException('Invalid JSON from `$path`: ${error.message}'); + } + } + + String _endpoint(String path) { + final version = apiVersion.startsWith('/') + ? apiVersion.substring(1) + : apiVersion; + final normalizedPath = path.startsWith('/') ? path : '/$path'; + return '/$version/libpod$normalizedPath'; + } + + String _encodePath(String value) => Uri.encodeComponent(value); + + String? _firstHeaderValue( + Map<String, List<String>> headers, + String headerName, + ) { + final normalizedTarget = headerName.toLowerCase(); + for (final entry in headers.entries) { + if (entry.key.toLowerCase() != normalizedTarget) { + continue; + } + if (entry.value.isEmpty) { + return null; + } + return entry.value.first; + } + return null; + } + + List<Map<String, Object?>> _decodeNdjsonObjects( + String bodyText, + String path, + ) { + final trimmed = bodyText.trim(); + if (trimmed.isEmpty) { + return const <Map<String, Object?>>[]; + } + + if (trimmed.startsWith('[')) { + return _decodeList(trimmed, path); + } + if (trimmed.startsWith('{') && !trimmed.contains('\n')) { + return <Map<String, Object?>>[_decodeObject(trimmed, path)]; + } + + final items = <Map<String, Object?>>[]; + for (final line in bodyText.split('\n')) { + final candidate = line.trim(); + if (candidate.isEmpty) { + continue; + } + final decoded = _decode(candidate, path); + if (decoded is Map<String, Object?>) { + items.add(decoded); + } else if (decoded is Map) { + items.add(decoded.map((key, value) => MapEntry(key.toString(), value))); + } else { + throw PodmanParseException('Expected NDJSON object line from `$path`.'); + } + } + + return items; + } + + ContainerExecStartResult _decodeExecOutput(List<int> bytes) { + if (bytes.isEmpty) { + return const ContainerExecStartResult(); + } + + final stdout = BytesBuilder(copy: false); + final stderr = BytesBuilder(copy: false); + + var index = 0; + var usedMultiplexFrames = false; + + while (index + 8 <= bytes.length) { + final streamType = bytes[index]; + final header = ByteData.sublistView( + Uint8List.fromList(bytes), + index + 4, + index + 8, + ); + final frameLength = header.getUint32(0, Endian.big); + final payloadStart = index + 8; + final payloadEnd = payloadStart + frameLength; + + if (frameLength < 0 || payloadEnd > bytes.length) { + break; + } + + final frame = bytes.sublist(payloadStart, payloadEnd); + if (streamType == 1) { + stdout.add(frame); + } else if (streamType == 2) { + stderr.add(frame); + } else { + stdout.add(frame); + } + + usedMultiplexFrames = true; + index = payloadEnd; + } + + if (!usedMultiplexFrames) { + return ContainerExecStartResult( + stdout: utf8.decode(bytes, allowMalformed: true), + stderr: '', + rawBytes: bytes, + ); + } + + return ContainerExecStartResult( + stdout: utf8.decode(stdout.takeBytes(), allowMalformed: true), + stderr: utf8.decode(stderr.takeBytes(), allowMalformed: true), + rawBytes: bytes, + ); + } +} diff --git a/lib/src/client/pods.dart b/lib/src/client/pods.dart new file mode 100644 index 0000000..3667564 --- /dev/null +++ b/lib/src/client/pods.dart @@ -0,0 +1,269 @@ +part of 'podman_client.dart'; + +extension PodmanClientPodApi on PodmanClient { + /// Lists pods. + Future<List<PodSummary>> listPods({Duration? timeout}) async { + final payload = await _getList('/pods/json', timeout: timeout); + return payload.map(PodSummary.fromJson).toList(growable: false); + } + + /// Creates a pod. + Future<PodDetails> createPod( + PodCreateOptions options, { + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/pods/create', + body: options.toApiBody(), + expectedStatusCodes: const <int>{200, 201}, + timeout: timeout, + ); + + final created = _decodeObject(response.bodyText, '/pods/create'); + final id = + asString(created['Id']) ?? asString(created['ID']) ?? options.name; + return inspectPod(id, timeout: timeout); + } + + /// Inspects a pod by name or ID. + Future<PodDetails> inspectPod(String pod, {Duration? timeout}) async { + final payload = await _getObject( + '/pods/${_encodePath(pod)}/json', + timeout: timeout, + ); + return PodDetails.fromJson(payload); + } + + /// Whether a pod exists. + Future<bool> podExists(String pod, {Duration? timeout}) async { + final response = await _send( + method: HttpMethod.get, + path: '/pods/${_encodePath(pod)}/exists', + expectedStatusCodes: const <int>{204, 404}, + timeout: timeout, + ); + return response.statusCode == 204; + } + + /// Kills running containers in a pod. + Future<void> killPod( + String pod, { + String signal = 'SIGKILL', + Duration? timeout, + }) async { + final trimmedSignal = signal.trim(); + await _send( + method: HttpMethod.post, + path: '/pods/${_encodePath(pod)}/kill', + queryParameters: <String, List<String>>{ + if (trimmedSignal.isNotEmpty && trimmedSignal != 'SIGKILL') + 'signal': <String>[trimmedSignal], + }, + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + } + + /// Pauses running containers in a pod. + Future<void> pausePod(String pod, {Duration? timeout}) async { + await _send( + method: HttpMethod.post, + path: '/pods/${_encodePath(pod)}/pause', + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + } + + /// Restarts containers in a pod. + Future<void> restartPod(String pod, {Duration? timeout}) async { + await _send( + method: HttpMethod.post, + path: '/pods/${_encodePath(pod)}/restart', + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + } + + /// Starts a pod. + Future<void> startPod(String pod, {Duration? timeout}) async { + await _send( + method: HttpMethod.post, + path: '/pods/${_encodePath(pod)}/start', + expectedStatusCodes: const <int>{200, 204, 304}, + timeout: timeout, + ); + } + + /// Stops a pod. + Future<void> stopPod( + String pod, { + int? timeoutSeconds, + Duration? timeout, + }) async { + await _send( + method: HttpMethod.post, + path: '/pods/${_encodePath(pod)}/stop', + queryParameters: <String, List<String>>{ + if (timeoutSeconds != null) 't': <String>['$timeoutSeconds'], + }, + expectedStatusCodes: const <int>{200, 204, 304}, + timeout: timeout, + ); + } + + /// Unpauses containers in a pod. + Future<void> unpausePod(String pod, {Duration? timeout}) async { + await _send( + method: HttpMethod.post, + path: '/pods/${_encodePath(pod)}/unpause', + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + } + + /// Lists processes running in a pod. + /// + /// Streaming top output is intentionally unsupported in this high-level API + /// because this transport currently captures full HTTP bodies. + Future<PodTopReport> topPod( + String pod, { + PodTopOptions options = const PodTopOptions(), + Duration? timeout, + }) async { + final payload = await _getObject( + '/pods/${_encodePath(pod)}/top', + queryParameters: options.toQueryParameters(), + timeout: timeout, + ); + return PodTopReport.fromJson(payload); + } + + /// Returns pod stats for one or more pods. + /// + /// Streaming stats are intentionally unsupported in this high-level API + /// because this transport currently captures full HTTP bodies. + Future<List<PodStatsReport>> podStats({ + PodStatsOptions options = const PodStatsOptions(), + Duration? timeout, + }) async { + final payload = await _getList( + '/pods/stats', + queryParameters: options.toQueryParameters(), + timeout: timeout, + ); + return payload.map(PodStatsReport.fromJson).toList(growable: false); + } + + /// Polls pod stats continuously. + Stream<List<PodStatsReport>> watchPodStats({ + PodStatsOptions options = const PodStatsOptions(), + Duration pollInterval = const Duration(seconds: 2), + bool reconnect = true, + Duration reconnectDelay = const Duration(seconds: 1), + Duration? timeout, + }) { + final controller = StreamController<List<PodStatsReport>>(); + var closed = false; + + Future<void> loop() async { + while (!closed) { + try { + final reports = await podStats(options: options, timeout: timeout); + if (closed) { + break; + } + controller.add(reports); + await Future<void>.delayed(pollInterval); + } catch (error, stackTrace) { + if (!reconnect || closed) { + controller.addError(error, stackTrace); + await controller.close(); + return; + } + await Future<void>.delayed(reconnectDelay); + } + } + } + + controller.onListen = loop; + controller.onCancel = () async { + closed = true; + }; + + return controller.stream; + } + + /// Polls pod top output continuously. + Stream<PodTopReport> watchPodTop( + String pod, { + PodTopOptions options = const PodTopOptions(), + Duration pollInterval = const Duration(seconds: 2), + bool reconnect = true, + Duration reconnectDelay = const Duration(seconds: 1), + Duration? timeout, + }) { + final controller = StreamController<PodTopReport>(); + var closed = false; + + Future<void> loop() async { + while (!closed) { + try { + final report = await topPod(pod, options: options, timeout: timeout); + if (closed) { + break; + } + controller.add(report); + await Future<void>.delayed(pollInterval); + } catch (error, stackTrace) { + if (!reconnect || closed) { + controller.addError(error, stackTrace); + await controller.close(); + return; + } + await Future<void>.delayed(reconnectDelay); + } + } + } + + controller.onListen = loop; + controller.onCancel = () async { + closed = true; + }; + + return controller.stream; + } + + /// Prunes unused pods. + Future<List<PodPruneReport>> prunePods({Duration? timeout}) async { + final response = await _send( + method: HttpMethod.post, + path: '/pods/prune', + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + final payload = _decodeList(response.bodyText, '/pods/prune'); + return payload.map(PodPruneReport.fromJson).toList(growable: false); + } + + /// Removes a pod. + Future<void> removePod( + String pod, { + bool force = false, + bool ignoreMissing = false, + Duration? timeout, + }) async { + await _send( + method: HttpMethod.delete, + path: '/pods/${_encodePath(pod)}', + queryParameters: <String, List<String>>{ + 'force': <String>['$force'], + }, + expectedStatusCodes: ignoreMissing + ? const <int>{200, 202, 204, 404} + : const <int>{200, 202, 204}, + timeout: timeout, + ); + } +} diff --git a/lib/src/client/secrets.dart b/lib/src/client/secrets.dart new file mode 100644 index 0000000..2ec1ef9 --- /dev/null +++ b/lib/src/client/secrets.dart @@ -0,0 +1,71 @@ +part of 'podman_client.dart'; + +extension PodmanClientSecretsApi on PodmanClient { + /// Creates a secret. + Future<SecretCreateResult> createSecret( + SecretCreateOptions options, { + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/secrets/create', + queryParameters: options.toQueryParameters(), + body: options.data, + expectedStatusCodes: const <int>{200, 201}, + timeout: timeout, + ); + + return SecretCreateResult.fromJson( + _decodeObject(response.bodyText, '/secrets/create'), + ); + } + + /// Lists secrets. + Future<List<SecretSummary>> listSecrets({Duration? timeout}) async { + final payload = await _getList('/secrets/json', timeout: timeout); + return payload.map(SecretSummary.fromJson).toList(growable: false); + } + + /// Inspects a secret. + Future<SecretDetails> inspectSecret( + String secret, { + bool showSecret = false, + Duration? timeout, + }) async { + final payload = await _getObject( + '/secrets/${_encodePath(secret)}/json', + queryParameters: <String, List<String>>{ + if (showSecret) 'showsecret': const <String>['true'], + }, + timeout: timeout, + ); + return SecretDetails.fromJson(payload); + } + + /// Whether a secret exists. + Future<bool> secretExists(String secret, {Duration? timeout}) async { + final response = await _send( + method: HttpMethod.get, + path: '/secrets/${_encodePath(secret)}/exists', + expectedStatusCodes: const <int>{204, 404}, + timeout: timeout, + ); + return response.statusCode == 204; + } + + /// Removes a secret. + Future<void> removeSecret( + String secret, { + bool ignoreMissing = false, + Duration? timeout, + }) async { + await _send( + method: HttpMethod.delete, + path: '/secrets/${_encodePath(secret)}', + expectedStatusCodes: ignoreMissing + ? const <int>{200, 202, 204, 404} + : const <int>{200, 202, 204}, + timeout: timeout, + ); + } +} diff --git a/lib/src/client/system.dart b/lib/src/client/system.dart new file mode 100644 index 0000000..8e9c84f --- /dev/null +++ b/lib/src/client/system.dart @@ -0,0 +1,57 @@ +part of 'podman_client.dart'; + +extension PodmanClientSystemApi on PodmanClient { + /// Returns Podman version metadata. + Future<PodmanVersion> version({Duration? timeout}) async { + final object = await _getObject('/version', timeout: timeout); + return PodmanVersion.fromJson(object); + } + + /// Returns Podman host/runtime information. + Future<PodmanInfo> info({Duration? timeout}) async { + final object = await _getObject('/info', timeout: timeout); + return PodmanInfo.fromJson(object); + } + + /// Returns libpod disk usage details. + Future<SystemDfReport> systemDf({Duration? timeout}) async { + final object = await _getObject('/system/df', timeout: timeout); + return SystemDfReport.fromJson(object); + } + + /// Performs storage consistency checks. + Future<SystemCheckReport> systemCheck({ + SystemCheckOptions options = const SystemCheckOptions(), + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/system/check', + queryParameters: options.toQueryParameters(), + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return SystemCheckReport.fromJson( + _decodeObject(response.bodyText, '/system/check'), + ); + } + + /// Prunes unused data from the local podman store. + Future<SystemPruneReport> systemPrune({ + SystemPruneOptions options = const SystemPruneOptions(), + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/system/prune', + queryParameters: options.toQueryParameters(), + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return SystemPruneReport.fromJson( + _decodeObject(response.bodyText, '/system/prune'), + ); + } +} diff --git a/lib/src/client/volumes.dart b/lib/src/client/volumes.dart new file mode 100644 index 0000000..de7139c --- /dev/null +++ b/lib/src/client/volumes.dart @@ -0,0 +1,121 @@ +part of 'podman_client.dart'; + +extension PodmanClientVolumeApi on PodmanClient { + /// Lists known volumes. + Future<List<VolumeSummary>> listVolumes({ + VolumeListOptions options = const VolumeListOptions(), + Duration? timeout, + }) async { + final payload = await _getList( + '/volumes/json', + queryParameters: options.toQueryParameters(), + timeout: timeout, + ); + return payload.map(VolumeSummary.fromJson).toList(growable: false); + } + + /// Creates a volume. + Future<VolumeDetails> createVolume( + VolumeCreateOptions options, { + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/volumes/create', + body: options.toApiBody(), + expectedStatusCodes: const <int>{200, 201}, + timeout: timeout, + ); + + return VolumeDetails.fromJson( + _decodeObject(response.bodyText, '/volumes/create'), + ); + } + + /// Inspects a volume. + Future<VolumeDetails> inspectVolume( + String volume, { + Duration? timeout, + }) async { + final payload = await _getObject( + '/volumes/${_encodePath(volume)}/json', + timeout: timeout, + ); + return VolumeDetails.fromJson(payload); + } + + /// Whether a volume exists. + Future<bool> volumeExists(String volume, {Duration? timeout}) async { + final response = await _send( + method: HttpMethod.get, + path: '/volumes/${_encodePath(volume)}/exists', + expectedStatusCodes: const <int>{204, 404}, + timeout: timeout, + ); + return response.statusCode == 204; + } + + /// Removes a volume. + Future<void> removeVolume( + String volume, { + bool force = false, + bool ignoreMissing = false, + Duration? timeout, + }) async { + await _send( + method: HttpMethod.delete, + path: '/volumes/${_encodePath(volume)}', + queryParameters: <String, List<String>>{ + 'force': <String>['$force'], + }, + expectedStatusCodes: ignoreMissing + ? const <int>{200, 202, 204, 404} + : const <int>{200, 202, 204}, + timeout: timeout, + ); + } + + /// Prunes unused volumes. + Future<List<VolumePruneReport>> pruneVolumes({ + VolumePruneOptions options = const VolumePruneOptions(), + Duration? timeout, + }) async { + final response = await _send( + method: HttpMethod.post, + path: '/volumes/prune', + queryParameters: options.toQueryParameters(), + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + final payload = _decodeList(response.bodyText, '/volumes/prune'); + return payload.map(VolumePruneReport.fromJson).toList(growable: false); + } + + /// Exports volume contents as a tar archive. + Future<Uint8List> exportVolume(String volume, {Duration? timeout}) async { + final response = await _send( + method: HttpMethod.get, + path: '/volumes/${_encodePath(volume)}/export', + expectedStatusCodes: const <int>{200}, + timeout: timeout, + ); + + return Uint8List.fromList(response.bodyBytes); + } + + /// Imports volume contents from an uncompressed tar archive. + Future<void> importVolume( + String volume, { + required List<int> archiveBytes, + Duration? timeout, + }) async { + await _send( + method: HttpMethod.post, + path: '/volumes/${_encodePath(volume)}/import', + body: archiveBytes, + expectedStatusCodes: const <int>{204}, + timeout: timeout, + ); + } +} diff --git a/lib/src/core/http_method.dart b/lib/src/core/http_method.dart new file mode 100644 index 0000000..dc5a330 --- /dev/null +++ b/lib/src/core/http_method.dart @@ -0,0 +1,2 @@ +/// Supported HTTP methods for Podman transport requests. +enum HttpMethod { get, post, put, patch, delete, head } diff --git a/lib/src/core/podman_api_exception.dart b/lib/src/core/podman_api_exception.dart new file mode 100644 index 0000000..7a7cd97 --- /dev/null +++ b/lib/src/core/podman_api_exception.dart @@ -0,0 +1,34 @@ +import 'http_method.dart'; +import 'podman_exception.dart'; + +/// Exception thrown when Podman API returns an unexpected response. +final class PodmanApiException extends PodmanException { + /// Creates an API exception. + PodmanApiException({ + required this.method, + required this.path, + required this.statusCode, + required this.responseBody, + String? message, + }) : super( + message ?? + 'Unexpected Podman API response for ' + '${method.name.toUpperCase()} $path: ' + 'HTTP $statusCode ${responseBody.trim()}', + ); + + /// Request method. + final HttpMethod method; + + /// Request path. + final String path; + + /// HTTP status code. + final int statusCode; + + /// Raw response body. + final String responseBody; + + /// Whether the response represents a not-found condition. + bool get isNotFound => statusCode == 404; +} diff --git a/lib/src/core/podman_exception.dart b/lib/src/core/podman_exception.dart new file mode 100644 index 0000000..fa9ab59 --- /dev/null +++ b/lib/src/core/podman_exception.dart @@ -0,0 +1,11 @@ +/// Base exception for Podman package failures. +class PodmanException implements Exception { + /// Creates a Podman exception. + const PodmanException(this.message); + + /// Error message. + final String message; + + @override + String toString() => 'PodmanException: $message'; +} diff --git a/lib/src/core/podman_parse_exception.dart b/lib/src/core/podman_parse_exception.dart new file mode 100644 index 0000000..3704603 --- /dev/null +++ b/lib/src/core/podman_parse_exception.dart @@ -0,0 +1,7 @@ +import 'podman_exception.dart'; + +/// Exception thrown when Podman output cannot be parsed. +final class PodmanParseException extends PodmanException { + /// Creates a parse exception. + const PodmanParseException(super.message); +} diff --git a/lib/src/core/podman_transport.dart b/lib/src/core/podman_transport.dart new file mode 100644 index 0000000..9d205ad --- /dev/null +++ b/lib/src/core/podman_transport.dart @@ -0,0 +1,11 @@ +import 'podman_transport_request.dart'; +import 'podman_transport_response.dart'; + +/// Abstraction for sending Podman HTTP API requests. +abstract interface class PodmanTransport { + /// Sends a request to the Podman API. + Future<PodmanTransportResponse> send(PodmanTransportRequest request); + + /// Closes any open transport resources. + Future<void> close(); +} diff --git a/lib/src/core/podman_transport_request.dart b/lib/src/core/podman_transport_request.dart new file mode 100644 index 0000000..2cf5414 --- /dev/null +++ b/lib/src/core/podman_transport_request.dart @@ -0,0 +1,32 @@ +import 'http_method.dart'; + +/// Transport-level request to the Podman API. +final class PodmanTransportRequest { + /// Creates a transport request. + const PodmanTransportRequest({ + required this.method, + required this.path, + this.queryParameters = const <String, List<String>>{}, + this.headers = const <String, String>{}, + this.body, + this.timeout, + }); + + /// HTTP method. + final HttpMethod method; + + /// Absolute request path (for example `/v5.0.0/libpod/version`). + final String path; + + /// Query parameters. + final Map<String, List<String>> queryParameters; + + /// Request headers. + final Map<String, String> headers; + + /// Request body. + final Object? body; + + /// Optional request timeout override. + final Duration? timeout; +} diff --git a/lib/src/core/podman_transport_response.dart b/lib/src/core/podman_transport_response.dart new file mode 100644 index 0000000..9ebe349 --- /dev/null +++ b/lib/src/core/podman_transport_response.dart @@ -0,0 +1,23 @@ +import 'dart:convert'; + +/// Transport-level response from the Podman API. +final class PodmanTransportResponse { + /// Creates a transport response. + const PodmanTransportResponse({ + required this.statusCode, + required this.headers, + required this.bodyBytes, + }); + + /// HTTP status code. + final int statusCode; + + /// Response headers. + final Map<String, List<String>> headers; + + /// Raw response body bytes. + final List<int> bodyBytes; + + /// Decoded response body as UTF-8 text. + String get bodyText => utf8.decode(bodyBytes); +} diff --git a/lib/src/core/unix_socket_podman_transport.dart b/lib/src/core/unix_socket_podman_transport.dart new file mode 100644 index 0000000..8ea1560 --- /dev/null +++ b/lib/src/core/unix_socket_podman_transport.dart @@ -0,0 +1,163 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'http_method.dart'; +import 'podman_transport.dart'; +import 'podman_transport_request.dart'; +import 'podman_transport_response.dart'; + +/// Unix-socket transport for the Podman API. +final class UnixSocketPodmanTransport implements PodmanTransport { + /// Creates a Unix-socket Podman transport. + UnixSocketPodmanTransport({ + String? socketPath, + String authority = 'podman.local', + HttpClient? httpClient, + }) : socketPath = socketPath ?? defaultPodmanSocketPath(), + _authority = authority, + _httpClient = httpClient ?? HttpClient() { + _httpClient.connectionFactory = (Uri _, String? proxyHost, int? proxyPort) { + if (proxyHost != null || proxyPort != null) {} + final address = InternetAddress( + this.socketPath, + type: InternetAddressType.unix, + ); + return Socket.startConnect(address, 0); + }; + } + + /// Path to the Podman Unix socket. + final String socketPath; + + final String _authority; + final HttpClient _httpClient; + + @override + Future<PodmanTransportResponse> send(PodmanTransportRequest request) { + Future<PodmanTransportResponse> operation() async { + final uri = Uri( + scheme: 'http', + host: _authority, + path: _normalizePath(request.path), + queryParameters: _toUriQuery(request.queryParameters), + ); + + final httpRequest = await _httpClient.openUrl( + _methodName(request.method), + uri, + ); + + for (final header in request.headers.entries) { + httpRequest.headers.set(header.key, header.value); + } + + final body = request.body; + if (body != null) { + if (body is String) { + httpRequest.headers.contentType ??= ContentType.text; + httpRequest.write(body); + } else if (body is List<int>) { + httpRequest.add(body); + } else { + httpRequest.headers.contentType ??= ContentType.json; + httpRequest.write(jsonEncode(body)); + } + } + + final httpResponse = await httpRequest.close(); + final bytes = await _readAllBytes(httpResponse); + + final headers = <String, List<String>>{}; + httpResponse.headers.forEach((name, values) { + headers[name] = List<String>.from(values); + }); + + return PodmanTransportResponse( + statusCode: httpResponse.statusCode, + headers: headers, + bodyBytes: bytes, + ); + } + + final timeout = request.timeout; + if (timeout == null) { + return operation(); + } + + return operation().timeout(timeout); + } + + @override + Future<void> close() async { + _httpClient.close(force: true); + } + + Future<List<int>> _readAllBytes(HttpClientResponse response) async { + final builder = BytesBuilder(copy: false); + await for (final chunk in response) { + builder.add(chunk); + } + return builder.takeBytes(); + } + + Map<String, dynamic>? _toUriQuery(Map<String, List<String>> queryParameters) { + if (queryParameters.isEmpty) { + return null; + } + + final query = <String, dynamic>{}; + for (final entry in queryParameters.entries) { + final values = entry.value; + if (values.isEmpty) { + continue; + } + if (values.length == 1) { + query[entry.key] = values.first; + } else { + query[entry.key] = values; + } + } + + return query.isEmpty ? null : query; + } + + String _normalizePath(String path) { + if (path.startsWith('/')) { + return path; + } + return '/$path'; + } + + String _methodName(HttpMethod method) { + return switch (method) { + HttpMethod.get => 'GET', + HttpMethod.post => 'POST', + HttpMethod.put => 'PUT', + HttpMethod.patch => 'PATCH', + HttpMethod.delete => 'DELETE', + HttpMethod.head => 'HEAD', + }; + } +} + +/// Resolves the default Podman Unix socket path from environment. +String defaultPodmanSocketPath() { + final explicit = Platform.environment['PODMAN_SOCKET']; + if (explicit != null && explicit.isNotEmpty) { + return explicit; + } + + final xdgRuntimeDir = Platform.environment['XDG_RUNTIME_DIR']; + if (xdgRuntimeDir != null && xdgRuntimeDir.isNotEmpty) { + return '$xdgRuntimeDir/podman/podman.sock'; + } + + final uid = Platform.environment['UID']; + if (uid != null && uid.isNotEmpty) { + return '/run/user/$uid/podman/podman.sock'; + } + + return '/run/podman/podman.sock'; +} diff --git a/lib/src/internal/json_utils.dart b/lib/src/internal/json_utils.dart new file mode 100644 index 0000000..3875c14 --- /dev/null +++ b/lib/src/internal/json_utils.dart @@ -0,0 +1,82 @@ +Map<String, Object?> asJsonMap(Object? value) { + if (value is Map<String, Object?>) { + return value; + } + if (value is Map) { + return value.map( + (key, mappedValue) => MapEntry(key.toString(), mappedValue), + ); + } + return const <String, Object?>{}; +} + +List<Object?> asJsonList(Object? value) { + if (value is List<Object?>) { + return value; + } + if (value is List) { + return value.cast<Object?>(); + } + return const <Object?>[]; +} + +String? asString(Object? value) { + if (value == null) { + return null; + } + if (value is String) { + return value; + } + return value.toString(); +} + +int? asInt(Object? value) { + if (value == null) { + return null; + } + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return int.tryParse(value.toString()); +} + +double? asDouble(Object? value) { + if (value == null) { + return null; + } + if (value is double) { + return value; + } + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value.toString()); +} + +bool? asBool(Object? value) { + if (value == null) { + return null; + } + if (value is bool) { + return value; + } + + final normalized = value.toString().toLowerCase(); + if (normalized == 'true' || normalized == '1' || normalized == 'yes') { + return true; + } + if (normalized == 'false' || normalized == '0' || normalized == 'no') { + return false; + } + return null; +} + +Map<String, String> asStringMap(Object? value) { + final map = asJsonMap(value); + return map.map( + (key, mappedValue) => MapEntry(key, asString(mappedValue) ?? ''), + ); +} diff --git a/lib/src/models/artifacts/artifact_add_result.dart b/lib/src/models/artifacts/artifact_add_result.dart new file mode 100644 index 0000000..8b54290 --- /dev/null +++ b/lib/src/models/artifacts/artifact_add_result.dart @@ -0,0 +1,24 @@ +import '../../internal/json_utils.dart'; + +/// Result from adding content to an artifact. +final class ArtifactAddResult { + /// Creates artifact add result. + const ArtifactAddResult({required this.artifactDigest, required this.raw}); + + /// Artifact digest after add. + final String artifactDigest; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ArtifactAddResult] from JSON. + factory ArtifactAddResult.fromJson(Map<String, Object?> json) { + return ArtifactAddResult( + artifactDigest: + asString(json['ArtifactDigest']) ?? + asString(json['artifactDigest']) ?? + '', + raw: json, + ); + } +} diff --git a/lib/src/models/artifacts/artifact_details.dart b/lib/src/models/artifacts/artifact_details.dart new file mode 100644 index 0000000..8b3fa96 --- /dev/null +++ b/lib/src/models/artifacts/artifact_details.dart @@ -0,0 +1,34 @@ +import '../../internal/json_utils.dart'; + +/// Artifact details from `GET /libpod/artifacts/{name}/json`. +final class ArtifactDetails { + /// Creates artifact details. + const ArtifactDetails({ + required this.name, + required this.digest, + required this.manifest, + required this.raw, + }); + + /// Artifact name/reference. + final String name; + + /// Artifact digest. + final String digest; + + /// Embedded manifest object. + final Map<String, Object?> manifest; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ArtifactDetails] from JSON. + factory ArtifactDetails.fromJson(Map<String, Object?> json) { + return ArtifactDetails( + name: asString(json['Name']) ?? asString(json['name']) ?? '', + digest: asString(json['Digest']) ?? asString(json['digest']) ?? '', + manifest: asJsonMap(json['Manifest']), + raw: json, + ); + } +} diff --git a/lib/src/models/artifacts/artifact_pull_result.dart b/lib/src/models/artifacts/artifact_pull_result.dart new file mode 100644 index 0000000..f791572 --- /dev/null +++ b/lib/src/models/artifacts/artifact_pull_result.dart @@ -0,0 +1,24 @@ +import '../../internal/json_utils.dart'; + +/// Result from pulling an artifact. +final class ArtifactPullResult { + /// Creates artifact pull result. + const ArtifactPullResult({required this.artifactDigest, required this.raw}); + + /// Pulled artifact digest. + final String artifactDigest; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ArtifactPullResult] from JSON. + factory ArtifactPullResult.fromJson(Map<String, Object?> json) { + return ArtifactPullResult( + artifactDigest: + asString(json['ArtifactDigest']) ?? + asString(json['artifactDigest']) ?? + '', + raw: json, + ); + } +} diff --git a/lib/src/models/artifacts/artifact_push_result.dart b/lib/src/models/artifacts/artifact_push_result.dart new file mode 100644 index 0000000..5edc06b --- /dev/null +++ b/lib/src/models/artifacts/artifact_push_result.dart @@ -0,0 +1,24 @@ +import '../../internal/json_utils.dart'; + +/// Result from pushing an artifact. +final class ArtifactPushResult { + /// Creates artifact push result. + const ArtifactPushResult({required this.artifactDigest, required this.raw}); + + /// Pushed artifact digest. + final String artifactDigest; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ArtifactPushResult] from JSON. + factory ArtifactPushResult.fromJson(Map<String, Object?> json) { + return ArtifactPushResult( + artifactDigest: + asString(json['ArtifactDigest']) ?? + asString(json['artifactDigest']) ?? + '', + raw: json, + ); + } +} diff --git a/lib/src/models/artifacts/artifact_remove_result.dart b/lib/src/models/artifacts/artifact_remove_result.dart new file mode 100644 index 0000000..6aa2ea3 --- /dev/null +++ b/lib/src/models/artifacts/artifact_remove_result.dart @@ -0,0 +1,26 @@ +import '../../internal/json_utils.dart'; + +/// Result from removing artifacts. +final class ArtifactRemoveResult { + /// Creates artifact remove result. + const ArtifactRemoveResult({ + required this.artifactDigests, + required this.raw, + }); + + /// Removed artifact digests. + final List<String> artifactDigests; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ArtifactRemoveResult] from JSON. + factory ArtifactRemoveResult.fromJson(Map<String, Object?> json) { + return ArtifactRemoveResult( + artifactDigests: asJsonList( + json['ArtifactDigests'], + ).map(asString).whereType<String>().toList(growable: false), + raw: json, + ); + } +} diff --git a/lib/src/models/artifacts/artifact_summary.dart b/lib/src/models/artifacts/artifact_summary.dart new file mode 100644 index 0000000..078c7ea --- /dev/null +++ b/lib/src/models/artifacts/artifact_summary.dart @@ -0,0 +1,34 @@ +import '../../internal/json_utils.dart'; + +/// Artifact summary from `GET /libpod/artifacts/json`. +final class ArtifactSummary { + /// Creates artifact summary. + const ArtifactSummary({ + required this.name, + required this.digest, + required this.manifest, + required this.raw, + }); + + /// Artifact name/reference. + final String name; + + /// Artifact digest. + final String digest; + + /// Embedded manifest object. + final Map<String, Object?> manifest; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ArtifactSummary] from JSON. + factory ArtifactSummary.fromJson(Map<String, Object?> json) { + return ArtifactSummary( + name: asString(json['Name']) ?? asString(json['name']) ?? '', + digest: asString(json['Digest']) ?? asString(json['digest']) ?? '', + manifest: asJsonMap(json['Manifest']), + raw: json, + ); + } +} diff --git a/lib/src/models/containers/container_archive_get_result.dart b/lib/src/models/containers/container_archive_get_result.dart new file mode 100644 index 0000000..be5fb44 --- /dev/null +++ b/lib/src/models/containers/container_archive_get_result.dart @@ -0,0 +1,18 @@ +/// Result of copying a container path as a tar archive. +final class ContainerArchiveGetResult { + /// Creates an archive get result. + const ContainerArchiveGetResult({ + required this.archiveBytes, + required this.pathStatHeader, + required this.headers, + }); + + /// Tar archive bytes. + final List<int> archiveBytes; + + /// `X-Docker-Container-Path-Stat` header value when present. + final String? pathStatHeader; + + /// Raw response headers. + final Map<String, List<String>> headers; +} diff --git a/lib/src/models/containers/container_checkpoint_report.dart b/lib/src/models/containers/container_checkpoint_report.dart new file mode 100644 index 0000000..ea593b9 --- /dev/null +++ b/lib/src/models/containers/container_checkpoint_report.dart @@ -0,0 +1,34 @@ +import '../../internal/json_utils.dart'; + +/// Report from `POST /libpod/containers/{name}/checkpoint`. +final class ContainerCheckpointReport { + /// Creates a container checkpoint report. + const ContainerCheckpointReport({ + required this.id, + required this.runtimeDuration, + required this.criuStatistics, + required this.raw, + }); + + /// Container ID. + final String id; + + /// Runtime checkpoint duration. + final int runtimeDuration; + + /// CRIU statistics, when included. + final Map<String, Object?> criuStatistics; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ContainerCheckpointReport] from JSON. + factory ContainerCheckpointReport.fromJson(Map<String, Object?> json) { + return ContainerCheckpointReport( + id: asString(json['Id']) ?? '', + runtimeDuration: asInt(json['runtime_checkpoint_duration']) ?? 0, + criuStatistics: asJsonMap(json['criu_statistics']), + raw: json, + ); + } +} diff --git a/lib/src/models/containers/container_create_result.dart b/lib/src/models/containers/container_create_result.dart new file mode 100644 index 0000000..2cfe4f9 --- /dev/null +++ b/lib/src/models/containers/container_create_result.dart @@ -0,0 +1,32 @@ +import '../../internal/json_utils.dart'; + +/// Result from `POST /containers/create`. +final class ContainerCreateResult { + /// Creates a container create result. + const ContainerCreateResult({ + required this.id, + required this.warnings, + required this.raw, + }); + + /// Created container ID. + final String id; + + /// Podman warning messages. + final List<String> warnings; + + /// Raw parsed JSON payload. + final Map<String, Object?> raw; + + /// Builds [ContainerCreateResult] from API JSON payload. + factory ContainerCreateResult.fromJson(Map<String, Object?> json) { + return ContainerCreateResult( + id: asString(json['Id']) ?? asString(json['ID']) ?? '', + warnings: asJsonList(json['Warnings']) + .map((value) => asString(value) ?? '') + .where((value) => value.isNotEmpty) + .toList(growable: false), + raw: json, + ); + } +} diff --git a/lib/src/models/containers/container_details.dart b/lib/src/models/containers/container_details.dart new file mode 100644 index 0000000..89ce1d5 --- /dev/null +++ b/lib/src/models/containers/container_details.dart @@ -0,0 +1,71 @@ +import '../../internal/json_utils.dart'; + +/// Detailed container data parsed from `GET /libpod/containers/{id}/json`. +final class ContainerDetails { + /// Creates container details. + const ContainerDetails({ + required this.id, + required this.name, + required this.image, + required this.state, + required this.status, + required this.createdAt, + required this.labels, + required this.raw, + }); + + /// Container ID. + final String id; + + /// Container name. + final String name; + + /// Image reference/name. + final String image; + + /// State string. + final String state; + + /// Human-readable status. + final String status; + + /// Creation timestamp. + final DateTime? createdAt; + + /// Labels. + final Map<String, String> labels; + + /// Raw inspect payload. + final Map<String, Object?> raw; + + /// Builds [ContainerDetails] from inspect JSON payload. + factory ContainerDetails.fromJson(Map<String, Object?> json) { + final stateMap = asJsonMap(json['State']); + final configMap = asJsonMap(json['Config']); + + final rawName = asString(json['Name']) ?? ''; + final normalizedName = rawName.startsWith('/') + ? rawName.substring(1) + : rawName; + + return ContainerDetails( + id: asString(json['Id']) ?? asString(json['ID']) ?? '', + name: normalizedName, + image: + asString(json['ImageName']) ?? + asString(configMap['Image']) ?? + asString(json['Image']) ?? + '', + state: + asString(stateMap['Status']) ?? asString(json['State']) ?? 'unknown', + status: + asString(stateMap['Status']) ?? + asString(stateMap['State']) ?? + asString(json['Status']) ?? + '', + createdAt: DateTime.tryParse(asString(json['Created']) ?? ''), + labels: asStringMap(configMap['Labels']), + raw: json, + ); + } +} diff --git a/lib/src/models/containers/container_exec_create_result.dart b/lib/src/models/containers/container_exec_create_result.dart new file mode 100644 index 0000000..b60d0ac --- /dev/null +++ b/lib/src/models/containers/container_exec_create_result.dart @@ -0,0 +1,21 @@ +import '../../internal/json_utils.dart'; + +/// Exec creation result from `POST /libpod/containers/{id}/exec`. +final class ContainerExecCreateResult { + /// Creates exec-create result. + const ContainerExecCreateResult({required this.id, required this.raw}); + + /// Exec ID. + final String id; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ContainerExecCreateResult] from JSON. + factory ContainerExecCreateResult.fromJson(Map<String, Object?> json) { + return ContainerExecCreateResult( + id: asString(json['Id']) ?? asString(json['ID']) ?? '', + raw: json, + ); + } +} diff --git a/lib/src/models/containers/container_exec_inspect_result.dart b/lib/src/models/containers/container_exec_inspect_result.dart new file mode 100644 index 0000000..a69abf2 --- /dev/null +++ b/lib/src/models/containers/container_exec_inspect_result.dart @@ -0,0 +1,49 @@ +import '../../internal/json_utils.dart'; + +/// Exec inspect result from `GET /libpod/exec/{id}/json`. +final class ContainerExecInspectResult { + /// Creates exec inspect result. + const ContainerExecInspectResult({ + required this.id, + required this.containerId, + required this.running, + required this.exitCode, + required this.openStdout, + required this.openStderr, + required this.raw, + }); + + /// Exec ID. + final String id; + + /// Container ID the exec belongs to. + final String containerId; + + /// Whether exec process is still running. + final bool running; + + /// Exit code when available. + final int? exitCode; + + /// Whether stdout was attached. + final bool openStdout; + + /// Whether stderr was attached. + final bool openStderr; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ContainerExecInspectResult] from JSON. + factory ContainerExecInspectResult.fromJson(Map<String, Object?> json) { + return ContainerExecInspectResult( + id: asString(json['Id']) ?? asString(json['ID']) ?? '', + containerId: asString(json['ContainerID']) ?? '', + running: asBool(json['Running']) ?? false, + exitCode: asInt(json['ExitCode']), + openStdout: asBool(json['OpenStdout']) ?? false, + openStderr: asBool(json['OpenStderr']) ?? false, + raw: json, + ); + } +} diff --git a/lib/src/models/containers/container_exec_start_result.dart b/lib/src/models/containers/container_exec_start_result.dart new file mode 100644 index 0000000..9e2a853 --- /dev/null +++ b/lib/src/models/containers/container_exec_start_result.dart @@ -0,0 +1,21 @@ +/// Captured output from `POST /libpod/exec/{id}/start`. +final class ContainerExecStartResult { + /// Creates exec-start result. + const ContainerExecStartResult({ + this.stdout = '', + this.stderr = '', + this.rawBytes = const <int>[], + }); + + /// Captured stdout text. + final String stdout; + + /// Captured stderr text. + final String stderr; + + /// Raw response payload bytes. + final List<int> rawBytes; + + /// Combined stdout+stderr output. + String get output => '$stdout$stderr'; +} diff --git a/lib/src/models/containers/container_health_status.dart b/lib/src/models/containers/container_health_status.dart new file mode 100644 index 0000000..007cb63 --- /dev/null +++ b/lib/src/models/containers/container_health_status.dart @@ -0,0 +1,40 @@ +import '../../internal/json_utils.dart'; + +/// Container health status from inspect payload. +final class ContainerHealthStatus { + /// Creates health status. + const ContainerHealthStatus({ + required this.status, + required this.failingStreak, + required this.raw, + }); + + /// Health status (`healthy`, `unhealthy`, `starting`, or `none`). + final String status; + + /// Consecutive failing checks. + final int failingStreak; + + /// Raw health-check object from inspect payload. + final Map<String, Object?> raw; + + /// Builds [ContainerHealthStatus] from inspect JSON. + factory ContainerHealthStatus.fromInspect(Map<String, Object?> inspectJson) { + final state = asJsonMap(inspectJson['State']); + final healthcheck = asJsonMap(state['Healthcheck']); + + if (healthcheck.isEmpty) { + return const ContainerHealthStatus( + status: 'none', + failingStreak: 0, + raw: <String, Object?>{}, + ); + } + + return ContainerHealthStatus( + status: asString(healthcheck['Status']) ?? 'unknown', + failingStreak: asInt(healthcheck['FailingStreak']) ?? 0, + raw: healthcheck, + ); + } +} diff --git a/lib/src/models/containers/container_restore_report.dart b/lib/src/models/containers/container_restore_report.dart new file mode 100644 index 0000000..1f6d9d2 --- /dev/null +++ b/lib/src/models/containers/container_restore_report.dart @@ -0,0 +1,34 @@ +import '../../internal/json_utils.dart'; + +/// Report from `POST /libpod/containers/{name}/restore`. +final class ContainerRestoreReport { + /// Creates a container restore report. + const ContainerRestoreReport({ + required this.id, + required this.runtimeDuration, + required this.criuStatistics, + required this.raw, + }); + + /// Restored container ID. + final String id; + + /// Runtime restore duration. + final int runtimeDuration; + + /// CRIU statistics, when included. + final Map<String, Object?> criuStatistics; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ContainerRestoreReport] from JSON. + factory ContainerRestoreReport.fromJson(Map<String, Object?> json) { + return ContainerRestoreReport( + id: asString(json['Id']) ?? '', + runtimeDuration: asInt(json['runtime_restore_duration']) ?? 0, + criuStatistics: asJsonMap(json['criu_statistics']), + raw: json, + ); + } +} diff --git a/lib/src/models/containers/container_stats.dart b/lib/src/models/containers/container_stats.dart new file mode 100644 index 0000000..5be9948 --- /dev/null +++ b/lib/src/models/containers/container_stats.dart @@ -0,0 +1,59 @@ +import '../../internal/json_utils.dart'; + +/// Snapshot stats from `GET /libpod/containers/{id}/stats?stream=false`. +final class ContainerStats { + /// Creates container stats. + const ContainerStats({ + required this.id, + required this.name, + required this.readAt, + required this.cpuTotalUsage, + required this.memoryUsage, + required this.memoryLimit, + required this.pidsCurrent, + required this.raw, + }); + + /// Container ID. + final String id; + + /// Container name. + final String name; + + /// Read timestamp. + final DateTime? readAt; + + /// Total CPU usage (nanoseconds). + final int cpuTotalUsage; + + /// Memory usage in bytes. + final int memoryUsage; + + /// Memory limit in bytes. + final int memoryLimit; + + /// Current PID count. + final int pidsCurrent; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ContainerStats] from JSON. + factory ContainerStats.fromJson(Map<String, Object?> json) { + final cpuStats = asJsonMap(json['cpu_stats']); + final cpuUsage = asJsonMap(cpuStats['cpu_usage']); + final memoryStats = asJsonMap(json['memory_stats']); + final pidsStats = asJsonMap(json['pids_stats']); + + return ContainerStats( + id: asString(json['Id']) ?? asString(json['ID']) ?? '', + name: asString(json['name']) ?? asString(json['Name']) ?? '', + readAt: DateTime.tryParse(asString(json['read']) ?? ''), + cpuTotalUsage: asInt(cpuUsage['total_usage']) ?? 0, + memoryUsage: asInt(memoryStats['usage']) ?? 0, + memoryLimit: asInt(memoryStats['limit']) ?? 0, + pidsCurrent: asInt(pidsStats['current']) ?? 0, + raw: json, + ); + } +} diff --git a/lib/src/models/containers/container_summary.dart b/lib/src/models/containers/container_summary.dart new file mode 100644 index 0000000..188820c --- /dev/null +++ b/lib/src/models/containers/container_summary.dart @@ -0,0 +1,90 @@ +import '../../internal/json_utils.dart'; + +/// Lightweight container data from `GET /libpod/containers/json`. +final class ContainerSummary { + /// Creates a container summary. + const ContainerSummary({ + required this.id, + required this.image, + required this.name, + required this.state, + required this.status, + required this.command, + required this.createdAt, + required this.labels, + required this.raw, + }); + + /// Container ID. + final String id; + + /// Container image reference. + final String image; + + /// Container name. + final String name; + + /// Container state. + final String state; + + /// Human-readable status. + final String status; + + /// Container command. + final String command; + + /// Creation timestamp if available. + final DateTime? createdAt; + + /// Container labels. + final Map<String, String> labels; + + /// Raw parsed JSON payload. + final Map<String, Object?> raw; + + /// Builds [ContainerSummary] from API JSON output. + factory ContainerSummary.fromJson(Map<String, Object?> json) { + return ContainerSummary( + id: asString(json['Id']) ?? asString(json['ID']) ?? '', + image: asString(json['Image']) ?? '', + name: _parseName(json['Names'], json['Name']), + state: asString(json['State']) ?? 'unknown', + status: asString(json['Status']) ?? '', + command: asString(json['Command']) ?? '', + createdAt: DateTime.tryParse(asString(json['CreatedAt']) ?? ''), + labels: _parseLabels(json['Labels']), + raw: json, + ); + } + + static String _parseName(Object? namesValue, Object? nameValue) { + final names = asJsonList(namesValue); + if (names.isNotEmpty) { + return asString(names.first) ?? ''; + } + + final fromNames = asString(namesValue); + if (fromNames != null && fromNames.isNotEmpty) { + return fromNames; + } + + return asString(nameValue) ?? ''; + } + + static Map<String, String> _parseLabels(Object? labelsValue) { + if (labelsValue is String && labelsValue.isNotEmpty) { + final map = <String, String>{}; + final pairs = labelsValue.split(','); + for (final pair in pairs) { + final index = pair.indexOf('='); + if (index <= 0) { + continue; + } + map[pair.substring(0, index)] = pair.substring(index + 1); + } + return map; + } + + return asStringMap(labelsValue); + } +} diff --git a/lib/src/models/containers/container_top_report.dart b/lib/src/models/containers/container_top_report.dart new file mode 100644 index 0000000..30331c0 --- /dev/null +++ b/lib/src/models/containers/container_top_report.dart @@ -0,0 +1,37 @@ +import '../../internal/json_utils.dart'; + +/// Process listing from `GET /libpod/containers/{name}/top`. +final class ContainerTopReport { + /// Creates a container top report. + const ContainerTopReport({ + required this.titles, + required this.processes, + required this.raw, + }); + + /// Column titles. + final List<String> titles; + + /// Process rows. + final List<List<String>> processes; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ContainerTopReport] from JSON. + factory ContainerTopReport.fromJson(Map<String, Object?> json) { + final titles = asJsonList( + json['Titles'] ?? json['titles'], + ).map((value) => asString(value) ?? '').toList(growable: false); + + final processes = asJsonList(json['Processes'] ?? json['processes']) + .map( + (row) => asJsonList( + row, + ).map((value) => asString(value) ?? '').toList(growable: false), + ) + .toList(growable: false); + + return ContainerTopReport(titles: titles, processes: processes, raw: json); + } +} diff --git a/lib/src/models/containers/container_wait_result.dart b/lib/src/models/containers/container_wait_result.dart new file mode 100644 index 0000000..c6f05d1 --- /dev/null +++ b/lib/src/models/containers/container_wait_result.dart @@ -0,0 +1,22 @@ +/// Wait response parsed from `POST /libpod/containers/{id}/wait`. +final class ContainerWaitResult { + /// Creates wait result. + const ContainerWaitResult({required this.statusCode, required this.rawBody}); + + /// Exit/status code value returned by Podman wait API. + final int statusCode; + + /// Raw response body. + final String rawBody; + + /// Parses a wait result from raw response body. + factory ContainerWaitResult.fromBody(String body) { + final trimmed = body.trim(); + final direct = int.tryParse(trimmed); + if (direct != null) { + return ContainerWaitResult(statusCode: direct, rawBody: body); + } + + return ContainerWaitResult(statusCode: -1, rawBody: body); + } +} diff --git a/lib/src/models/generate_systemd_result.dart b/lib/src/models/generate_systemd_result.dart new file mode 100644 index 0000000..0b60fd1 --- /dev/null +++ b/lib/src/models/generate_systemd_result.dart @@ -0,0 +1,18 @@ +import '../internal/json_utils.dart'; + +/// Result from `GET /libpod/generate/{name}/systemd`. +final class GenerateSystemdResult { + /// Creates generate-systemd result. + const GenerateSystemdResult({required this.units, required this.raw}); + + /// Generated unit file text by filename. + final Map<String, String> units; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [GenerateSystemdResult] from JSON. + factory GenerateSystemdResult.fromJson(Map<String, Object?> json) { + return GenerateSystemdResult(units: asStringMap(json), raw: json); + } +} diff --git a/lib/src/models/image_details.dart b/lib/src/models/image_details.dart new file mode 100644 index 0000000..cbe3b63 --- /dev/null +++ b/lib/src/models/image_details.dart @@ -0,0 +1,53 @@ +import '../internal/json_utils.dart'; + +/// Detailed image data from `GET /libpod/images/{name}/json`. +final class ImageDetails { + /// Creates image details. + const ImageDetails({ + required this.id, + required this.digest, + required this.repoTags, + required this.repoDigests, + required this.createdAt, + required this.size, + required this.raw, + }); + + /// Image ID. + final String id; + + /// Image digest. + final String digest; + + /// Known image tags. + final List<String> repoTags; + + /// Known image digests. + final List<String> repoDigests; + + /// Creation timestamp if present. + final DateTime? createdAt; + + /// Image size in bytes. + final int size; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ImageDetails] from JSON. + factory ImageDetails.fromJson(Map<String, Object?> json) { + return ImageDetails( + id: asString(json['Id']) ?? asString(json['ID']) ?? '', + digest: asString(json['Digest']) ?? asString(json['digest']) ?? '', + repoTags: asJsonList( + json['RepoTags'], + ).map(asString).whereType<String>().toList(growable: false), + repoDigests: asJsonList( + json['RepoDigests'], + ).map(asString).whereType<String>().toList(growable: false), + createdAt: DateTime.tryParse(asString(json['Created']) ?? ''), + size: asInt(json['Size']) ?? asInt(json['size']) ?? 0, + raw: json, + ); + } +} diff --git a/lib/src/models/image_history_entry.dart b/lib/src/models/image_history_entry.dart new file mode 100644 index 0000000..6724aaf --- /dev/null +++ b/lib/src/models/image_history_entry.dart @@ -0,0 +1,57 @@ +import '../internal/json_utils.dart'; + +/// Image history entry from `GET /libpod/images/{name}/history`. +final class ImageHistoryEntry { + /// Creates image history entry. + const ImageHistoryEntry({ + required this.id, + required this.createdAt, + required this.createdBy, + required this.tags, + required this.size, + required this.comment, + required this.raw, + }); + + /// Layer ID. + final String id; + + /// Layer creation timestamp. + final DateTime? createdAt; + + /// Layer creator command. + final String createdBy; + + /// Layer tags. + final List<String> tags; + + /// Layer size. + final int size; + + /// Layer comment. + final String comment; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ImageHistoryEntry] from JSON. + factory ImageHistoryEntry.fromJson(Map<String, Object?> json) { + final createdUnix = asInt(json['Created']); + final createdAt = createdUnix == null + ? DateTime.tryParse(asString(json['created']) ?? '') + : DateTime.fromMillisecondsSinceEpoch(createdUnix * 1000, isUtc: true); + + return ImageHistoryEntry( + id: asString(json['Id']) ?? asString(json['ID']) ?? '', + createdAt: createdAt, + createdBy: + asString(json['CreatedBy']) ?? asString(json['createdBy']) ?? '', + tags: asJsonList( + json['Tags'], + ).map(asString).whereType<String>().toList(growable: false), + size: asInt(json['Size']) ?? asInt(json['size']) ?? 0, + comment: asString(json['Comment']) ?? asString(json['comment']) ?? '', + raw: json, + ); + } +} diff --git a/lib/src/models/image_import_report.dart b/lib/src/models/image_import_report.dart new file mode 100644 index 0000000..52eed7a --- /dev/null +++ b/lib/src/models/image_import_report.dart @@ -0,0 +1,21 @@ +import '../internal/json_utils.dart'; + +/// Result from `POST /libpod/images/import`. +final class ImageImportReport { + /// Creates image import report. + const ImageImportReport({required this.id, required this.raw}); + + /// Imported image ID. + final String id; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ImageImportReport] from JSON. + factory ImageImportReport.fromJson(Map<String, Object?> json) { + return ImageImportReport( + id: asString(json['Id']) ?? asString(json['ID']) ?? '', + raw: json, + ); + } +} diff --git a/lib/src/models/image_load_report.dart b/lib/src/models/image_load_report.dart new file mode 100644 index 0000000..1667893 --- /dev/null +++ b/lib/src/models/image_load_report.dart @@ -0,0 +1,23 @@ +import '../internal/json_utils.dart'; + +/// Result from `POST /libpod/images/load`. +final class ImageLoadReport { + /// Creates image load report. + const ImageLoadReport({required this.names, required this.raw}); + + /// Loaded image names. + final List<String> names; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ImageLoadReport] from JSON. + factory ImageLoadReport.fromJson(Map<String, Object?> json) { + return ImageLoadReport( + names: asJsonList( + json['Names'], + ).map(asString).whereType<String>().toList(growable: false), + raw: json, + ); + } +} diff --git a/lib/src/models/image_push_event.dart b/lib/src/models/image_push_event.dart new file mode 100644 index 0000000..867b618 --- /dev/null +++ b/lib/src/models/image_push_event.dart @@ -0,0 +1,37 @@ +import '../internal/json_utils.dart'; + +/// Push stream event from `POST /libpod/images/{name}/push`. +final class ImagePushEvent { + /// Creates image push event. + const ImagePushEvent({ + required this.stream, + required this.manifestDigest, + required this.error, + required this.raw, + }); + + /// Stream output line. + final String stream; + + /// Manifest digest, when provided. + final String manifestDigest; + + /// Error text, when provided. + final String error; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ImagePushEvent] from JSON. + factory ImagePushEvent.fromJson(Map<String, Object?> json) { + return ImagePushEvent( + stream: asString(json['stream']) ?? asString(json['Stream']) ?? '', + manifestDigest: + asString(json['manifestdigest']) ?? + asString(json['ManifestDigest']) ?? + '', + error: asString(json['error']) ?? asString(json['Error']) ?? '', + raw: json, + ); + } +} diff --git a/lib/src/models/image_remove_result.dart b/lib/src/models/image_remove_result.dart new file mode 100644 index 0000000..0126fde --- /dev/null +++ b/lib/src/models/image_remove_result.dart @@ -0,0 +1,45 @@ +import '../internal/json_utils.dart'; + +/// Image removal result from libpod image remove endpoints. +final class ImageRemoveResult { + /// Creates image remove result. + const ImageRemoveResult({ + required this.deleted, + required this.untagged, + required this.exitCode, + required this.errors, + required this.raw, + }); + + /// Deleted image IDs. + final List<String> deleted; + + /// Untagged image references. + final List<String> untagged; + + /// Command-style exit code from Podman. + final int exitCode; + + /// Removal errors. + final List<String> errors; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ImageRemoveResult] from JSON. + factory ImageRemoveResult.fromJson(Map<String, Object?> json) { + return ImageRemoveResult( + deleted: asJsonList( + json['Deleted'], + ).map(asString).whereType<String>().toList(growable: false), + untagged: asJsonList( + json['Untagged'], + ).map(asString).whereType<String>().toList(growable: false), + exitCode: asInt(json['ExitCode']) ?? asInt(json['exitCode']) ?? 0, + errors: asJsonList( + json['Errors'], + ).map(asString).whereType<String>().toList(growable: false), + raw: json, + ); + } +} diff --git a/lib/src/models/image_summary.dart b/lib/src/models/image_summary.dart new file mode 100644 index 0000000..42087cf --- /dev/null +++ b/lib/src/models/image_summary.dart @@ -0,0 +1,60 @@ +import '../internal/json_utils.dart'; + +/// Image data from `GET /libpod/images/json`. +final class ImageSummary { + /// Creates image summary. + const ImageSummary({ + required this.id, + required this.repository, + required this.tag, + required this.digest, + required this.createdAt, + required this.size, + required this.raw, + }); + + /// Image ID. + final String id; + + /// Repository name. + final String repository; + + /// Image tag. + final String tag; + + /// Digest string. + final String digest; + + /// Created timestamp if provided. + final DateTime? createdAt; + + /// Human-readable size string. + final String size; + + /// Raw parsed JSON payload. + final Map<String, Object?> raw; + + /// Full repository reference. + String get reference { + if (repository.isEmpty) { + return id; + } + if (tag.isEmpty) { + return repository; + } + return '$repository:$tag'; + } + + /// Builds [ImageSummary] from API JSON output. + factory ImageSummary.fromJson(Map<String, Object?> json) { + return ImageSummary( + id: asString(json['Id']) ?? asString(json['ID']) ?? '', + repository: asString(json['Repository']) ?? '', + tag: asString(json['Tag']) ?? '', + digest: asString(json['Digest']) ?? '', + createdAt: DateTime.tryParse(asString(json['CreatedAt']) ?? ''), + size: asString(json['Size']) ?? '', + raw: json, + ); + } +} diff --git a/lib/src/models/image_tree_report.dart b/lib/src/models/image_tree_report.dart new file mode 100644 index 0000000..d0a4b4a --- /dev/null +++ b/lib/src/models/image_tree_report.dart @@ -0,0 +1,21 @@ +import '../internal/json_utils.dart'; + +/// Image tree report from `GET /libpod/images/{name}/tree`. +final class ImageTreeReport { + /// Creates image tree report. + const ImageTreeReport({required this.tree, required this.raw}); + + /// Printable tree representation. + final String tree; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ImageTreeReport] from JSON. + factory ImageTreeReport.fromJson(Map<String, Object?> json) { + return ImageTreeReport( + tree: asString(json['Tree']) ?? asString(json['tree']) ?? '', + raw: json, + ); + } +} diff --git a/lib/src/models/manifests/manifest_create_result.dart b/lib/src/models/manifests/manifest_create_result.dart new file mode 100644 index 0000000..2185ccb --- /dev/null +++ b/lib/src/models/manifests/manifest_create_result.dart @@ -0,0 +1,25 @@ +import '../../internal/json_utils.dart'; + +/// Manifest create result from `POST /libpod/manifests/{name}`. +final class ManifestCreateResult { + /// Creates manifest create result. + const ManifestCreateResult({required this.id, required this.raw}); + + /// Manifest/image ID. + final String id; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ManifestCreateResult] from JSON. + factory ManifestCreateResult.fromJson(Map<String, Object?> json) { + return ManifestCreateResult( + id: + asString(json['Id']) ?? + asString(json['ID']) ?? + asString(json['id']) ?? + '', + raw: json, + ); + } +} diff --git a/lib/src/models/manifests/manifest_delete_result.dart b/lib/src/models/manifests/manifest_delete_result.dart new file mode 100644 index 0000000..12f595c --- /dev/null +++ b/lib/src/models/manifests/manifest_delete_result.dart @@ -0,0 +1,45 @@ +import '../../internal/json_utils.dart'; + +/// Result from deleting a manifest list. +final class ManifestDeleteResult { + /// Creates manifest delete result. + const ManifestDeleteResult({ + required this.deleted, + required this.untagged, + required this.exitCode, + required this.errors, + required this.raw, + }); + + /// Deleted object IDs. + final List<String> deleted; + + /// Untagged references. + final List<String> untagged; + + /// Command-style exit code. + final int exitCode; + + /// Error messages. + final List<String> errors; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ManifestDeleteResult] from JSON. + factory ManifestDeleteResult.fromJson(Map<String, Object?> json) { + return ManifestDeleteResult( + deleted: asJsonList( + json['Deleted'], + ).map(asString).whereType<String>().toList(growable: false), + untagged: asJsonList( + json['Untagged'], + ).map(asString).whereType<String>().toList(growable: false), + exitCode: asInt(json['ExitCode']) ?? 0, + errors: asJsonList( + json['Errors'], + ).map(asString).whereType<String>().toList(growable: false), + raw: json, + ); + } +} diff --git a/lib/src/models/manifests/manifest_details.dart b/lib/src/models/manifests/manifest_details.dart new file mode 100644 index 0000000..3ebb425 --- /dev/null +++ b/lib/src/models/manifests/manifest_details.dart @@ -0,0 +1,38 @@ +import '../../internal/json_utils.dart'; + +/// Manifest details from `GET /libpod/manifests/{name}/json`. +final class ManifestDetails { + /// Creates manifest details. + const ManifestDetails({ + required this.schemaVersion, + required this.mediaType, + required this.manifests, + required this.raw, + }); + + /// Manifest schema version. + final int schemaVersion; + + /// Manifest media type. + final String mediaType; + + /// Embedded manifests list. + final List<Map<String, Object?>> manifests; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ManifestDetails] from JSON. + factory ManifestDetails.fromJson(Map<String, Object?> json) { + final manifests = asJsonList( + json['manifests'], + ).map(asJsonMap).toList(growable: false); + + return ManifestDetails( + schemaVersion: asInt(json['schemaVersion']) ?? 0, + mediaType: asString(json['mediaType']) ?? '', + manifests: manifests, + raw: json, + ); + } +} diff --git a/lib/src/models/manifests/manifest_push_result.dart b/lib/src/models/manifests/manifest_push_result.dart new file mode 100644 index 0000000..9b49089 --- /dev/null +++ b/lib/src/models/manifests/manifest_push_result.dart @@ -0,0 +1,25 @@ +import '../../internal/json_utils.dart'; + +/// Result from pushing a manifest list. +final class ManifestPushResult { + /// Creates manifest push result. + const ManifestPushResult({required this.id, required this.raw}); + + /// Digest/ID returned by push. + final String id; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [ManifestPushResult] from JSON. + factory ManifestPushResult.fromJson(Map<String, Object?> json) { + return ManifestPushResult( + id: + asString(json['Id']) ?? + asString(json['ID']) ?? + asString(json['id']) ?? + '', + raw: json, + ); + } +} diff --git a/lib/src/models/networks/network_details.dart b/lib/src/models/networks/network_details.dart new file mode 100644 index 0000000..ab82707 --- /dev/null +++ b/lib/src/models/networks/network_details.dart @@ -0,0 +1,80 @@ +import '../../internal/json_utils.dart'; +import 'network_subnet.dart'; + +/// Detailed network data from `GET /libpod/networks/{name}/json`. +final class NetworkDetails { + /// Creates network details. + const NetworkDetails({ + required this.id, + required this.name, + required this.driver, + required this.networkInterface, + required this.internal, + required this.ipv6Enabled, + required this.dnsEnabled, + required this.createdAt, + required this.labels, + required this.options, + required this.subnets, + required this.raw, + }); + + /// Network ID. + final String id; + + /// Network name. + final String name; + + /// Driver name. + final String driver; + + /// Host interface name. + final String networkInterface; + + /// Internal-only flag. + final bool internal; + + /// IPv6-enabled flag. + final bool ipv6Enabled; + + /// DNS-enabled flag. + final bool dnsEnabled; + + /// Creation timestamp. + final DateTime? createdAt; + + /// Labels. + final Map<String, String> labels; + + /// Driver options. + final Map<String, String> options; + + /// Subnet definitions. + final List<NetworkSubnet> subnets; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [NetworkDetails] from JSON. + factory NetworkDetails.fromJson(Map<String, Object?> json) { + return NetworkDetails( + id: asString(json['id']) ?? asString(json['Id']) ?? '', + name: asString(json['name']) ?? asString(json['Name']) ?? '', + driver: asString(json['driver']) ?? asString(json['Driver']) ?? '', + networkInterface: + asString(json['network_interface']) ?? + asString(json['NetworkInterface']) ?? + '', + internal: asBool(json['internal']) ?? false, + ipv6Enabled: asBool(json['ipv6_enabled']) ?? false, + dnsEnabled: asBool(json['dns_enabled']) ?? false, + createdAt: DateTime.tryParse(asString(json['created']) ?? ''), + labels: asStringMap(json['labels']), + options: asStringMap(json['options']), + subnets: asJsonList( + json['subnets'], + ).map(asJsonMap).map(NetworkSubnet.fromJson).toList(growable: false), + raw: json, + ); + } +} diff --git a/lib/src/models/networks/network_prune_report.dart b/lib/src/models/networks/network_prune_report.dart new file mode 100644 index 0000000..4d9b30a --- /dev/null +++ b/lib/src/models/networks/network_prune_report.dart @@ -0,0 +1,36 @@ +import '../../internal/json_utils.dart'; + +/// Network prune result item from `POST /libpod/networks/prune`. +final class NetworkPruneReport { + /// Creates a network prune report. + const NetworkPruneReport({ + required this.name, + required this.error, + required this.raw, + }); + + /// Network name. + final String name; + + /// Prune error message (if any). + final String? error; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [NetworkPruneReport] from JSON. + factory NetworkPruneReport.fromJson(Map<String, Object?> json) { + return NetworkPruneReport( + name: + asString(json['Name']) ?? + asString(json['name']) ?? + asString(json['Id']) ?? + '', + error: + asString(json['Error']) ?? + asString(json['Err']) ?? + asString(json['error']), + raw: json, + ); + } +} diff --git a/lib/src/models/networks/network_subnet.dart b/lib/src/models/networks/network_subnet.dart new file mode 100644 index 0000000..6be5e37 --- /dev/null +++ b/lib/src/models/networks/network_subnet.dart @@ -0,0 +1,29 @@ +import '../../internal/json_utils.dart'; + +/// Network subnet/gateway pair from Podman network APIs. +final class NetworkSubnet { + /// Creates a subnet model. + const NetworkSubnet({ + required this.subnet, + required this.gateway, + required this.raw, + }); + + /// Subnet CIDR. + final String subnet; + + /// Gateway IP. + final String gateway; + + /// Raw JSON payload. + final Map<String, Object?> raw; + + /// Builds [NetworkSubnet] from JSON. + factory NetworkSubnet.fromJson(Map<String, Object?> json) { + return NetworkSubnet( + subnet: asString(json['subnet']) ?? asString(json['Subnet']) ?? '', + gateway: asString(json['gateway']) ?? asString(json['Gateway']) ?? '', + raw: json, + ); + } +} diff --git a/lib/src/models/networks/network_summary.dart b/lib/src/models/networks/network_summary.dart new file mode 100644 index 0000000..b0cef35 --- /dev/null +++ b/lib/src/models/networks/network_summary.dart @@ -0,0 +1,64 @@ +import '../../internal/json_utils.dart'; +import 'network_subnet.dart'; + +/// Network summary from `GET /libpod/networks/json`. +final class NetworkSummary { + /// Creates a network summary. + const NetworkSummary({ + required this.id, + required this.name, + required this.driver, + required this.internal, + required this.ipv6Enabled, + required this.dnsEnabled, + required this.labels, + required this.subnets, + required this.raw, + }); + + /// Network ID. + final String id; + + /// Network name. + final String name; + + /// Network driver. + final String driver; + + /// Internal-only flag. + final bool internal; + + /// IPv6-enabled flag. + final bool ipv6Enabled; + + /// DNS-enabled flag. + final bool dnsEnabled; + + /// Labels. + final Map<String, String> labels; + + /// Subnet definitions. + final List<NetworkSubnet> subnets; + + /// Raw JSON payload. + final Map<String, Object?> raw; + + /// Builds [NetworkSummary] from JSON. + factory NetworkSummary.fromJson(Map<String, Object?> json) { + final subnets = asJsonList( + json['subnets'], + ).map(asJsonMap).map(NetworkSubnet.fromJson).toList(growable: false); + + return NetworkSummary( + id: asString(json['id']) ?? asString(json['Id']) ?? '', + name: asString(json['name']) ?? asString(json['Name']) ?? '', + driver: asString(json['driver']) ?? asString(json['Driver']) ?? '', + internal: asBool(json['internal']) ?? false, + ipv6Enabled: asBool(json['ipv6_enabled']) ?? false, + dnsEnabled: asBool(json['dns_enabled']) ?? false, + labels: asStringMap(json['labels']), + subnets: subnets, + raw: json, + ); + } +} diff --git a/lib/src/models/play_kube_report.dart b/lib/src/models/play_kube_report.dart new file mode 100644 index 0000000..e3c5367 --- /dev/null +++ b/lib/src/models/play_kube_report.dart @@ -0,0 +1,45 @@ +import '../internal/json_utils.dart'; + +/// Report from play-kube up/down APIs. +final class PlayKubeReport { + /// Creates play-kube report. + const PlayKubeReport({ + required this.pods, + required this.volumes, + required this.secrets, + required this.serviceContainerId, + required this.raw, + }); + + /// Pod IDs/names in report. + final List<String> pods; + + /// Volume names in report. + final List<String> volumes; + + /// Secret names in report. + final List<String> secrets; + + /// Service container ID, when present. + final String serviceContainerId; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [PlayKubeReport] from JSON. + factory PlayKubeReport.fromJson(Map<String, Object?> json) { + return PlayKubeReport( + pods: asJsonList( + json['Pods'], + ).map(asString).whereType<String>().toList(growable: false), + volumes: asJsonList( + json['Volumes'], + ).map(asString).whereType<String>().toList(growable: false), + secrets: asJsonList( + json['Secrets'], + ).map(asString).whereType<String>().toList(growable: false), + serviceContainerId: asString(json['ServiceContainerID']) ?? '', + raw: json, + ); + } +} diff --git a/lib/src/models/podman_event.dart b/lib/src/models/podman_event.dart new file mode 100644 index 0000000..e86094c --- /dev/null +++ b/lib/src/models/podman_event.dart @@ -0,0 +1,113 @@ +import '../internal/json_utils.dart'; + +/// Event actor object included in Podman events. +final class PodmanEventActor { + /// Creates event actor. + const PodmanEventActor({ + required this.id, + required this.attributes, + required this.raw, + }); + + /// Actor ID. + final String id; + + /// Actor attributes. + final Map<String, String> attributes; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [PodmanEventActor] from JSON. + factory PodmanEventActor.fromJson(Map<String, Object?> json) { + return PodmanEventActor( + id: asString(json['ID']) ?? asString(json['Id']) ?? '', + attributes: asStringMap(json['Attributes']), + raw: json, + ); + } +} + +/// Event from `GET /libpod/events`. +final class PodmanEvent { + /// Creates Podman event. + const PodmanEvent({ + required this.type, + required this.action, + required this.status, + required this.id, + required this.name, + required this.scope, + required this.time, + required this.timeNano, + required this.actor, + required this.raw, + }); + + /// Event type (`container`, `image`, `volume`, ...). + final String type; + + /// Event action. + final String action; + + /// Event status. + final String status; + + /// Resource ID. + final String id; + + /// Resource name. + final String name; + + /// Event scope. + final String scope; + + /// Event time in seconds since epoch. + final int time; + + /// Event time in nanoseconds since epoch. + final int timeNano; + + /// Event actor info. + final PodmanEventActor actor; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Event timestamp as [DateTime] when available. + DateTime? get timestamp { + if (timeNano > 0) { + final micros = timeNano ~/ 1000; + return DateTime.fromMicrosecondsSinceEpoch(micros, isUtc: true); + } + if (time > 0) { + return DateTime.fromMillisecondsSinceEpoch(time * 1000, isUtc: true); + } + return null; + } + + /// Builds [PodmanEvent] from JSON. + factory PodmanEvent.fromJson(Map<String, Object?> json) { + final actorMap = asJsonMap(json['Actor']); + final actor = PodmanEventActor.fromJson(actorMap); + + final eventId = asString(json['id']) ?? asString(json['ID']) ?? actor.id; + + final attributes = actor.attributes; + final eventName = + asString(attributes['name']) ?? asString(json['name']) ?? ''; + + return PodmanEvent( + type: asString(json['Type']) ?? '', + action: asString(json['Action']) ?? '', + status: asString(json['status']) ?? asString(json['Status']) ?? '', + id: eventId, + name: eventName, + scope: asString(json['scope']) ?? '', + time: asInt(json['time']) ?? 0, + timeNano: asInt(json['timeNano']) ?? 0, + actor: actor, + raw: json, + ); + } +} diff --git a/lib/src/models/podman_event_filter.dart b/lib/src/models/podman_event_filter.dart new file mode 100644 index 0000000..25d2075 --- /dev/null +++ b/lib/src/models/podman_event_filter.dart @@ -0,0 +1,14 @@ +/// Single key-value event filter for Podman events API. +final class PodmanEventFilter { + /// Creates event filter. + const PodmanEventFilter(this.key, this.value); + + /// Filter key (for example `type`, `event`, `container`). + final String key; + + /// Filter value. + final String value; + + /// Serializes filter as `key=value`. + String asQueryValue() => '$key=$value'; +} diff --git a/lib/src/models/podman_info.dart b/lib/src/models/podman_info.dart new file mode 100644 index 0000000..fbf2fe2 --- /dev/null +++ b/lib/src/models/podman_info.dart @@ -0,0 +1,25 @@ +import '../internal/json_utils.dart'; + +/// Parsed output from `GET /libpod/info`. +final class PodmanInfo { + /// Creates parsed info model. + const PodmanInfo({required this.raw}); + + /// Raw parsed JSON payload. + final Map<String, Object?> raw; + + /// Host operating system when available. + String? get hostOs => asString(asJsonMap(raw['host'])['os']); + + /// Host architecture when available. + String? get hostArch => asString(asJsonMap(raw['host'])['arch']); + + /// Host cgroup version when available. + String? get cgroupVersion => + asString(asJsonMap(raw['host'])['cgroupVersion']); + + /// Build from API JSON output. + factory PodmanInfo.fromJson(Map<String, Object?> json) { + return PodmanInfo(raw: json); + } +} diff --git a/lib/src/models/podman_version.dart b/lib/src/models/podman_version.dart new file mode 100644 index 0000000..b5d5683 --- /dev/null +++ b/lib/src/models/podman_version.dart @@ -0,0 +1,39 @@ +import '../internal/json_utils.dart'; + +/// Parsed output from `GET /libpod/version`. +final class PodmanVersion { + /// Creates a parsed version model. + const PodmanVersion({ + required this.clientVersion, + required this.serverVersion, + required this.raw, + }); + + /// Client version string. + final String? clientVersion; + + /// Server version string. + final String? serverVersion; + + /// Raw parsed JSON payload. + final Map<String, Object?> raw; + + /// Builds [PodmanVersion] from API JSON output. + factory PodmanVersion.fromJson(Map<String, Object?> json) { + final client = asJsonMap(json['Client']); + final server = asJsonMap(json['Server']); + final fallback = asString(json['Version']); + + return PodmanVersion( + clientVersion: + asString(client['Version']) ?? + asString(client['version']) ?? + fallback, + serverVersion: + asString(server['Version']) ?? + asString(server['version']) ?? + fallback, + raw: json, + ); + } +} diff --git a/lib/src/models/pods/pod_details.dart b/lib/src/models/pods/pod_details.dart new file mode 100644 index 0000000..1ec9b61 --- /dev/null +++ b/lib/src/models/pods/pod_details.dart @@ -0,0 +1,65 @@ +import '../../internal/json_utils.dart'; + +/// Detailed pod data from `GET /libpod/pods/{name}/json`. +final class PodDetails { + /// Creates pod details. + const PodDetails({ + required this.id, + required this.name, + required this.state, + required this.cgroup, + required this.labels, + required this.createdAt, + required this.containerIds, + required this.raw, + }); + + /// Pod ID. + final String id; + + /// Pod name. + final String name; + + /// Pod state. + final String state; + + /// Pod cgroup parent. + final String cgroup; + + /// Pod labels. + final Map<String, String> labels; + + /// Creation timestamp. + final DateTime? createdAt; + + /// IDs of containers belonging to the pod. + final List<String> containerIds; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [PodDetails] from JSON. + factory PodDetails.fromJson(Map<String, Object?> json) { + return PodDetails( + id: asString(json['Id']) ?? asString(json['ID']) ?? '', + name: asString(json['Name']) ?? '', + state: asString(json['State']) ?? asString(json['Status']) ?? '', + cgroup: asString(json['CgroupParent']) ?? asString(json['Cgroup']) ?? '', + labels: asStringMap(json['Labels']), + createdAt: DateTime.tryParse(asString(json['Created']) ?? ''), + containerIds: _parseContainerIds(json['Containers']), + raw: json, + ); + } + + static List<String> _parseContainerIds(Object? value) { + final items = asJsonList(value); + return items + .map((item) { + final map = asJsonMap(item); + return asString(map['Id']) ?? asString(map['ID']) ?? ''; + }) + .where((id) => id.isNotEmpty) + .toList(growable: false); + } +} diff --git a/lib/src/models/pods/pod_prune_report.dart b/lib/src/models/pods/pod_prune_report.dart new file mode 100644 index 0000000..86b5abe --- /dev/null +++ b/lib/src/models/pods/pod_prune_report.dart @@ -0,0 +1,36 @@ +import '../../internal/json_utils.dart'; + +/// Pod prune result item from `POST /libpod/pods/prune`. +final class PodPruneReport { + /// Creates a pod prune report. + const PodPruneReport({ + required this.id, + required this.error, + required this.raw, + }); + + /// Pod ID. + final String id; + + /// Prune error message (if any). + final String? error; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [PodPruneReport] from JSON. + factory PodPruneReport.fromJson(Map<String, Object?> json) { + return PodPruneReport( + id: + asString(json['Id']) ?? + asString(json['ID']) ?? + asString(json['id']) ?? + '', + error: + asString(json['Err']) ?? + asString(json['Error']) ?? + asString(json['err']), + raw: json, + ); + } +} diff --git a/lib/src/models/pods/pod_stats_report.dart b/lib/src/models/pods/pod_stats_report.dart new file mode 100644 index 0000000..fce0cc2 --- /dev/null +++ b/lib/src/models/pods/pod_stats_report.dart @@ -0,0 +1,77 @@ +import '../../internal/json_utils.dart'; + +/// Pod stats item from `GET /libpod/pods/stats`. +final class PodStatsReport { + /// Creates a pod stats report. + const PodStatsReport({ + required this.podId, + required this.containerId, + required this.name, + required this.cpuPercent, + required this.memoryUsage, + required this.memoryUsageBytes, + required this.memoryPercent, + required this.networkIo, + required this.blockIo, + required this.pids, + required this.raw, + }); + + /// Pod ID. + final String podId; + + /// Container ID. + final String containerId; + + /// Pod name. + final String name; + + /// CPU usage percent. + final String cpuPercent; + + /// Humanized memory usage. + final String memoryUsage; + + /// Memory usage/limit in bytes. + final String memoryUsageBytes; + + /// Memory percent. + final String memoryPercent; + + /// Network I/O value. + final String networkIo; + + /// Block I/O value. + final String blockIo; + + /// PID count. + final String pids; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [PodStatsReport] from JSON. + factory PodStatsReport.fromJson(Map<String, Object?> json) { + return PodStatsReport( + podId: asString(json['Pod']) ?? asString(json['pod']) ?? '', + containerId: + asString(json['CID']) ?? + asString(json['Cid']) ?? + asString(json['cid']) ?? + '', + name: asString(json['Name']) ?? asString(json['name']) ?? '', + cpuPercent: asString(json['CPU']) ?? asString(json['cpu']) ?? '', + memoryUsage: + asString(json['MemUsage']) ?? asString(json['memUsage']) ?? '', + memoryUsageBytes: + asString(json['MemUsageBytes']) ?? + asString(json['memUsageBytes']) ?? + '', + memoryPercent: asString(json['Mem']) ?? asString(json['mem']) ?? '', + networkIo: asString(json['NetIO']) ?? asString(json['netIO']) ?? '', + blockIo: asString(json['BlockIO']) ?? asString(json['blockIO']) ?? '', + pids: asString(json['PIDS']) ?? asString(json['pids']) ?? '', + raw: json, + ); + } +} diff --git a/lib/src/models/pods/pod_summary.dart b/lib/src/models/pods/pod_summary.dart new file mode 100644 index 0000000..a568407 --- /dev/null +++ b/lib/src/models/pods/pod_summary.dart @@ -0,0 +1,57 @@ +import '../../internal/json_utils.dart'; + +/// Pod summary from `GET /libpod/pods/json`. +final class PodSummary { + /// Creates a pod summary. + const PodSummary({ + required this.id, + required this.name, + required this.status, + required this.cgroup, + required this.containers, + required this.labels, + required this.createdAt, + required this.raw, + }); + + /// Pod ID. + final String id; + + /// Pod name. + final String name; + + /// Pod status. + final String status; + + /// Pod cgroup parent. + final String cgroup; + + /// Number of containers in the pod. + final int containers; + + /// Pod labels. + final Map<String, String> labels; + + /// Creation timestamp. + final DateTime? createdAt; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [PodSummary] from JSON. + factory PodSummary.fromJson(Map<String, Object?> json) { + return PodSummary( + id: asString(json['Id']) ?? asString(json['ID']) ?? '', + name: asString(json['Name']) ?? '', + status: asString(json['Status']) ?? '', + cgroup: asString(json['Cgroup']) ?? '', + containers: + asInt(json['NumberOfContainers']) ?? + asInt(json['Containers']) ?? + asJsonList(json['Containers']).length, + labels: asStringMap(json['Labels']), + createdAt: DateTime.tryParse(asString(json['Created']) ?? ''), + raw: json, + ); + } +} diff --git a/lib/src/models/pods/pod_top_report.dart b/lib/src/models/pods/pod_top_report.dart new file mode 100644 index 0000000..2f6a0e8 --- /dev/null +++ b/lib/src/models/pods/pod_top_report.dart @@ -0,0 +1,37 @@ +import '../../internal/json_utils.dart'; + +/// Process listing from `GET /libpod/pods/{name}/top`. +final class PodTopReport { + /// Creates a pod top report. + const PodTopReport({ + required this.titles, + required this.processes, + required this.raw, + }); + + /// Column titles. + final List<String> titles; + + /// Process rows. + final List<List<String>> processes; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [PodTopReport] from JSON. + factory PodTopReport.fromJson(Map<String, Object?> json) { + final titles = asJsonList( + json['Titles'] ?? json['titles'], + ).map((value) => asString(value) ?? '').toList(growable: false); + + final processes = asJsonList(json['Processes'] ?? json['processes']) + .map( + (row) => asJsonList( + row, + ).map((value) => asString(value) ?? '').toList(growable: false), + ) + .toList(growable: false); + + return PodTopReport(titles: titles, processes: processes, raw: json); + } +} diff --git a/lib/src/models/secrets/secret_create_result.dart b/lib/src/models/secrets/secret_create_result.dart new file mode 100644 index 0000000..0798b5c --- /dev/null +++ b/lib/src/models/secrets/secret_create_result.dart @@ -0,0 +1,25 @@ +import '../../internal/json_utils.dart'; + +/// Secret creation result from `POST /libpod/secrets/create`. +final class SecretCreateResult { + /// Creates secret create result. + const SecretCreateResult({required this.id, required this.raw}); + + /// Secret ID. + final String id; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [SecretCreateResult] from JSON. + factory SecretCreateResult.fromJson(Map<String, Object?> json) { + return SecretCreateResult( + id: + asString(json['ID']) ?? + asString(json['Id']) ?? + asString(json['id']) ?? + '', + raw: json, + ); + } +} diff --git a/lib/src/models/secrets/secret_details.dart b/lib/src/models/secrets/secret_details.dart new file mode 100644 index 0000000..3c7b791 --- /dev/null +++ b/lib/src/models/secrets/secret_details.dart @@ -0,0 +1,61 @@ +import '../../internal/json_utils.dart'; + +/// Secret details from `GET /libpod/secrets/{name}/json`. +final class SecretDetails { + /// Creates secret details. + const SecretDetails({ + required this.id, + required this.name, + required this.driver, + required this.createdAt, + required this.updatedAt, + required this.labels, + required this.secretData, + required this.raw, + }); + + /// Secret ID. + final String id; + + /// Secret name. + final String name; + + /// Secret driver. + final String driver; + + /// Created timestamp. + final DateTime? createdAt; + + /// Last update timestamp. + final DateTime? updatedAt; + + /// Secret labels. + final Map<String, String> labels; + + /// Optional clear secret data when `showsecret=true`. + final String secretData; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [SecretDetails] from JSON. + factory SecretDetails.fromJson(Map<String, Object?> json) { + final spec = asJsonMap(json['Spec']); + final driverMap = asJsonMap(spec['Driver']); + + return SecretDetails( + id: + asString(json['ID']) ?? + asString(json['Id']) ?? + asString(json['id']) ?? + '', + name: asString(spec['Name']) ?? asString(json['Name']) ?? '', + driver: asString(driverMap['Name']) ?? asString(spec['Driver']) ?? '', + createdAt: DateTime.tryParse(asString(json['CreatedAt']) ?? ''), + updatedAt: DateTime.tryParse(asString(json['UpdatedAt']) ?? ''), + labels: asStringMap(spec['Labels']), + secretData: asString(json['SecretData']) ?? '', + raw: json, + ); + } +} diff --git a/lib/src/models/secrets/secret_summary.dart b/lib/src/models/secrets/secret_summary.dart new file mode 100644 index 0000000..0e2108a --- /dev/null +++ b/lib/src/models/secrets/secret_summary.dart @@ -0,0 +1,56 @@ +import '../../internal/json_utils.dart'; + +/// Secret summary from `GET /libpod/secrets/json`. +final class SecretSummary { + /// Creates secret summary. + const SecretSummary({ + required this.id, + required this.name, + required this.driver, + required this.createdAt, + required this.updatedAt, + required this.labels, + required this.raw, + }); + + /// Secret ID. + final String id; + + /// Secret name. + final String name; + + /// Secret driver. + final String driver; + + /// Created timestamp. + final DateTime? createdAt; + + /// Last update timestamp. + final DateTime? updatedAt; + + /// Labels. + final Map<String, String> labels; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [SecretSummary] from JSON. + factory SecretSummary.fromJson(Map<String, Object?> json) { + final spec = asJsonMap(json['Spec']); + final driverMap = asJsonMap(spec['Driver']); + + return SecretSummary( + id: + asString(json['ID']) ?? + asString(json['Id']) ?? + asString(json['id']) ?? + '', + name: asString(spec['Name']) ?? asString(json['Name']) ?? '', + driver: asString(driverMap['Name']) ?? asString(spec['Driver']) ?? '', + createdAt: DateTime.tryParse(asString(json['CreatedAt']) ?? ''), + updatedAt: DateTime.tryParse(asString(json['UpdatedAt']) ?? ''), + labels: asStringMap(spec['Labels']), + raw: json, + ); + } +} diff --git a/lib/src/models/system/system_check_report.dart b/lib/src/models/system/system_check_report.dart new file mode 100644 index 0000000..fb4b044 --- /dev/null +++ b/lib/src/models/system/system_check_report.dart @@ -0,0 +1,81 @@ +import '../../internal/json_utils.dart'; + +/// Storage consistency report from `POST /libpod/system/check`. +final class SystemCheckReport { + /// Creates a system-check report. + const SystemCheckReport({ + required this.errors, + required this.layers, + required this.roLayers, + required this.removedLayers, + required this.images, + required this.roImages, + required this.removedImages, + required this.containers, + required this.removedContainers, + required this.raw, + }); + + /// Whether check found errors. + final bool errors; + + /// Layer findings keyed by layer ID. + final Map<String, List<String>> layers; + + /// Read-only layer findings keyed by layer ID. + final Map<String, List<String>> roLayers; + + /// Removed layer IDs. + final List<String> removedLayers; + + /// Image findings keyed by image ID. + final Map<String, List<String>> images; + + /// Read-only image findings keyed by image ID. + final Map<String, List<String>> roImages; + + /// Removed image names keyed by image ID. + final Map<String, List<String>> removedImages; + + /// Container findings keyed by container ID. + final Map<String, List<String>> containers; + + /// Removed container names keyed by container ID. + final Map<String, String> removedContainers; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [SystemCheckReport] from JSON. + factory SystemCheckReport.fromJson(Map<String, Object?> json) { + return SystemCheckReport( + errors: asBool(json['Errors']) ?? false, + layers: _stringListMap(json['Layers']), + roLayers: _stringListMap(json['ROLayers']), + removedLayers: asJsonList( + json['RemovedLayers'], + ).map(asString).whereType<String>().toList(growable: false), + images: _stringListMap(json['Images']), + roImages: _stringListMap(json['ROImages']), + removedImages: _stringListMap(json['RemovedImages']), + containers: _stringListMap(json['Containers']), + removedContainers: asStringMap(json['RemovedContainers']), + raw: json, + ); + } +} + +Map<String, List<String>> _stringListMap(Object? value) { + final map = asJsonMap(value); + final keys = map.keys.toList(growable: false)..sort(); + final output = <String, List<String>>{}; + + for (final key in keys) { + final list = asJsonList( + map[key], + ).map(asString).whereType<String>().toList(growable: false); + output[key] = list; + } + + return output; +} diff --git a/lib/src/models/system/system_df_container.dart b/lib/src/models/system/system_df_container.dart new file mode 100644 index 0000000..bb84dfe --- /dev/null +++ b/lib/src/models/system/system_df_container.dart @@ -0,0 +1,68 @@ +import '../../internal/json_utils.dart'; + +/// Container row from `GET /libpod/system/df`. +final class SystemDfContainer { + /// Creates a system-df container row. + const SystemDfContainer({ + required this.containerId, + required this.image, + required this.command, + required this.localVolumes, + required this.size, + required this.rwSize, + required this.createdAt, + required this.status, + required this.names, + required this.raw, + }); + + /// Container ID. + final String containerId; + + /// Image used by container. + final String image; + + /// Container command. + final List<String> command; + + /// Count of local volumes. + final int localVolumes; + + /// Container size in bytes. + final int size; + + /// Container read-write layer size in bytes. + final int rwSize; + + /// Creation timestamp. + final DateTime? createdAt; + + /// Human-readable status. + final String status; + + /// Container name. + final String names; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [SystemDfContainer] from JSON. + factory SystemDfContainer.fromJson(Map<String, Object?> json) { + final commandList = asJsonList( + json['Command'], + ).map(asString).whereType<String>().toList(growable: false); + + return SystemDfContainer( + containerId: asString(json['ContainerID']) ?? '', + image: asString(json['Image']) ?? '', + command: commandList, + localVolumes: asInt(json['LocalVolumes']) ?? 0, + size: asInt(json['Size']) ?? 0, + rwSize: asInt(json['RWSize']) ?? 0, + createdAt: DateTime.tryParse(asString(json['Created']) ?? ''), + status: asString(json['Status']) ?? '', + names: asString(json['Names']) ?? '', + raw: json, + ); + } +} diff --git a/lib/src/models/system/system_df_image.dart b/lib/src/models/system/system_df_image.dart new file mode 100644 index 0000000..d1254de --- /dev/null +++ b/lib/src/models/system/system_df_image.dart @@ -0,0 +1,59 @@ +import '../../internal/json_utils.dart'; + +/// Image row from `GET /libpod/system/df`. +final class SystemDfImage { + /// Creates a system-df image row. + const SystemDfImage({ + required this.repository, + required this.tag, + required this.imageId, + required this.createdAt, + required this.size, + required this.sharedSize, + required this.uniqueSize, + required this.containers, + required this.raw, + }); + + /// Repository name. + final String repository; + + /// Image tag. + final String tag; + + /// Image ID. + final String imageId; + + /// Creation timestamp. + final DateTime? createdAt; + + /// Total image size in bytes. + final int size; + + /// Shared size in bytes. + final int sharedSize; + + /// Unique size in bytes. + final int uniqueSize; + + /// Number of containers using this image. + final int containers; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [SystemDfImage] from JSON. + factory SystemDfImage.fromJson(Map<String, Object?> json) { + return SystemDfImage( + repository: asString(json['Repository']) ?? '', + tag: asString(json['Tag']) ?? '', + imageId: asString(json['ImageID']) ?? '', + createdAt: DateTime.tryParse(asString(json['Created']) ?? ''), + size: asInt(json['Size']) ?? 0, + sharedSize: asInt(json['SharedSize']) ?? 0, + uniqueSize: asInt(json['UniqueSize']) ?? 0, + containers: asInt(json['Containers']) ?? 0, + raw: json, + ); + } +} diff --git a/lib/src/models/system/system_df_report.dart b/lib/src/models/system/system_df_report.dart new file mode 100644 index 0000000..6bfb7a7 --- /dev/null +++ b/lib/src/models/system/system_df_report.dart @@ -0,0 +1,48 @@ +import '../../internal/json_utils.dart'; +import 'system_df_container.dart'; +import 'system_df_image.dart'; +import 'system_df_volume.dart'; + +/// Disk usage report from `GET /libpod/system/df`. +final class SystemDfReport { + /// Creates a system-df report. + const SystemDfReport({ + required this.imagesSize, + required this.images, + required this.containers, + required this.volumes, + required this.raw, + }); + + /// Aggregate image size in bytes. + final int imagesSize; + + /// Image usage rows. + final List<SystemDfImage> images; + + /// Container usage rows. + final List<SystemDfContainer> containers; + + /// Volume usage rows. + final List<SystemDfVolume> volumes; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [SystemDfReport] from JSON. + factory SystemDfReport.fromJson(Map<String, Object?> json) { + return SystemDfReport( + imagesSize: asInt(json['ImagesSize']) ?? 0, + images: asJsonList( + json['Images'], + ).map(asJsonMap).map(SystemDfImage.fromJson).toList(growable: false), + containers: asJsonList( + json['Containers'], + ).map(asJsonMap).map(SystemDfContainer.fromJson).toList(growable: false), + volumes: asJsonList( + json['Volumes'], + ).map(asJsonMap).map(SystemDfVolume.fromJson).toList(growable: false), + raw: json, + ); + } +} diff --git a/lib/src/models/system/system_df_volume.dart b/lib/src/models/system/system_df_volume.dart new file mode 100644 index 0000000..4e64282 --- /dev/null +++ b/lib/src/models/system/system_df_volume.dart @@ -0,0 +1,39 @@ +import '../../internal/json_utils.dart'; + +/// Volume row from `GET /libpod/system/df`. +final class SystemDfVolume { + /// Creates a system-df volume row. + const SystemDfVolume({ + required this.volumeName, + required this.links, + required this.size, + required this.reclaimableSize, + required this.raw, + }); + + /// Volume name. + final String volumeName; + + /// Number of links/users. + final int links; + + /// Total size in bytes. + final int size; + + /// Reclaimable size in bytes. + final int reclaimableSize; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [SystemDfVolume] from JSON. + factory SystemDfVolume.fromJson(Map<String, Object?> json) { + return SystemDfVolume( + volumeName: asString(json['VolumeName']) ?? '', + links: asInt(json['Links']) ?? 0, + size: asInt(json['Size']) ?? 0, + reclaimableSize: asInt(json['ReclaimableSize']) ?? 0, + raw: json, + ); + } +} diff --git a/lib/src/models/system/system_prune_report.dart b/lib/src/models/system/system_prune_report.dart new file mode 100644 index 0000000..464eba3 --- /dev/null +++ b/lib/src/models/system/system_prune_report.dart @@ -0,0 +1,76 @@ +import '../../internal/json_utils.dart'; + +/// System prune report from `POST /libpod/system/prune`. +final class SystemPruneReport { + /// Creates a system-prune report. + const SystemPruneReport({ + required this.podIds, + required this.containerIds, + required this.imageIds, + required this.networkNames, + required this.volumeIds, + required this.reclaimedSpace, + required this.raw, + }); + + /// Pruned pod IDs. + final List<String> podIds; + + /// Pruned container IDs. + final List<String> containerIds; + + /// Pruned image IDs. + final List<String> imageIds; + + /// Pruned network names. + final List<String> networkNames; + + /// Pruned volume IDs. + final List<String> volumeIds; + + /// Total reclaimed bytes. + final int reclaimedSpace; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [SystemPruneReport] from JSON. + factory SystemPruneReport.fromJson(Map<String, Object?> json) { + return SystemPruneReport( + podIds: _extractReportIds(json['PodPruneReport']), + containerIds: _extractReportIds(json['ContainerPruneReports']), + imageIds: _extractReportIds(json['ImagePruneReports']), + networkNames: _extractReportIds( + json['NetworkPruneReports'], + preferredKeys: const <String>['Name', 'Id'], + ), + volumeIds: _extractReportIds(json['VolumePruneReports']), + reclaimedSpace: asInt(json['ReclaimedSpace']) ?? 0, + raw: json, + ); + } +} + +List<String> _extractReportIds( + Object? value, { + List<String> preferredKeys = const <String>['Id', 'ID', 'id'], +}) { + final list = asJsonList(value); + final output = <String>[]; + + for (final item in list) { + final map = asJsonMap(item); + String? id; + for (final key in preferredKeys) { + id = asString(map[key]); + if (id != null && id.isNotEmpty) { + break; + } + } + if (id != null && id.isNotEmpty) { + output.add(id); + } + } + + return output; +} diff --git a/lib/src/models/volume_details.dart b/lib/src/models/volume_details.dart new file mode 100644 index 0000000..872b2ba --- /dev/null +++ b/lib/src/models/volume_details.dart @@ -0,0 +1,62 @@ +import '../internal/json_utils.dart'; + +/// Detailed volume data from `GET /libpod/volumes/{name}/json`. +final class VolumeDetails { + /// Creates detailed volume data. + const VolumeDetails({ + required this.name, + required this.driver, + required this.mountpoint, + required this.createdAt, + required this.scope, + required this.mountCount, + required this.labels, + required this.options, + required this.raw, + }); + + /// Volume name. + final String name; + + /// Driver name. + final String driver; + + /// Mount path. + final String mountpoint; + + /// Creation timestamp. + final DateTime? createdAt; + + /// Scope value. + final String scope; + + /// Active mount count. + final int mountCount; + + /// Labels. + final Map<String, String> labels; + + /// Driver options. + final Map<String, String> options; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [VolumeDetails] from JSON. + factory VolumeDetails.fromJson(Map<String, Object?> json) { + return VolumeDetails( + name: asString(json['Name']) ?? asString(json['name']) ?? '', + driver: asString(json['Driver']) ?? asString(json['driver']) ?? '', + mountpoint: + asString(json['Mountpoint']) ?? asString(json['mountpoint']) ?? '', + createdAt: DateTime.tryParse( + asString(json['CreatedAt']) ?? asString(json['created_at']) ?? '', + ), + scope: asString(json['Scope']) ?? asString(json['scope']) ?? '', + mountCount: asInt(json['MountCount']) ?? asInt(json['mount_count']) ?? 0, + labels: asStringMap(json['Labels']), + options: asStringMap(json['Options']), + raw: json, + ); + } +} diff --git a/lib/src/models/volume_prune_report.dart b/lib/src/models/volume_prune_report.dart new file mode 100644 index 0000000..1508461 --- /dev/null +++ b/lib/src/models/volume_prune_report.dart @@ -0,0 +1,37 @@ +import '../internal/json_utils.dart'; + +/// Volume prune report item from `POST /libpod/volumes/prune`. +final class VolumePruneReport { + /// Creates volume prune report. + const VolumePruneReport({ + required this.id, + required this.error, + required this.size, + required this.raw, + }); + + /// Pruned volume ID/name. + final String id; + + /// Prune error if present. + final String? error; + + /// Reclaimed bytes. + final int size; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [VolumePruneReport] from JSON. + factory VolumePruneReport.fromJson(Map<String, Object?> json) { + return VolumePruneReport( + id: asString(json['Id']) ?? asString(json['ID']) ?? '', + error: + asString(json['Err']) ?? + asString(json['Error']) ?? + asString(json['error']), + size: asInt(json['Size']) ?? asInt(json['size']) ?? 0, + raw: json, + ); + } +} diff --git a/lib/src/models/volume_summary.dart b/lib/src/models/volume_summary.dart new file mode 100644 index 0000000..19896fe --- /dev/null +++ b/lib/src/models/volume_summary.dart @@ -0,0 +1,62 @@ +import '../internal/json_utils.dart'; + +/// Volume summary from `GET /libpod/volumes/json`. +final class VolumeSummary { + /// Creates a volume summary. + const VolumeSummary({ + required this.name, + required this.driver, + required this.mountpoint, + required this.createdAt, + required this.scope, + required this.mountCount, + required this.labels, + required this.options, + required this.raw, + }); + + /// Volume name. + final String name; + + /// Driver name. + final String driver; + + /// Volume mountpoint. + final String mountpoint; + + /// Creation timestamp. + final DateTime? createdAt; + + /// Scope (`local`, etc). + final String scope; + + /// Active mount count. + final int mountCount; + + /// Volume labels. + final Map<String, String> labels; + + /// Driver options. + final Map<String, String> options; + + /// Raw payload. + final Map<String, Object?> raw; + + /// Builds [VolumeSummary] from JSON. + factory VolumeSummary.fromJson(Map<String, Object?> json) { + return VolumeSummary( + name: asString(json['Name']) ?? asString(json['name']) ?? '', + driver: asString(json['Driver']) ?? asString(json['driver']) ?? '', + mountpoint: + asString(json['Mountpoint']) ?? asString(json['mountpoint']) ?? '', + createdAt: DateTime.tryParse( + asString(json['CreatedAt']) ?? asString(json['created_at']) ?? '', + ), + scope: asString(json['Scope']) ?? asString(json['scope']) ?? '', + mountCount: asInt(json['MountCount']) ?? asInt(json['mount_count']) ?? 0, + labels: asStringMap(json['Labels']), + options: asStringMap(json['Options']), + raw: json, + ); + } +} diff --git a/lib/src/options/artifacts/artifact_add_options.dart b/lib/src/options/artifacts/artifact_add_options.dart new file mode 100644 index 0000000..f9c0b4f --- /dev/null +++ b/lib/src/options/artifacts/artifact_add_options.dart @@ -0,0 +1,46 @@ +/// Options for adding artifact blobs. +final class ArtifactAddOptions { + /// Creates artifact add options. + const ArtifactAddOptions({ + this.fileMimeType, + this.annotations = const <String>[], + this.artifactMimeType, + this.append = false, + this.replace = false, + }); + + /// MIME type of the added file. + final String? fileMimeType; + + /// Artifact annotations (`key=value`). + final List<String> annotations; + + /// MIME type for the artifact as a whole. + final String? artifactMimeType; + + /// Append files to an existing artifact. + final bool append; + + /// Replace an existing artifact with the same name. + final bool replace; + + /// Serializes options to query parameters. + Map<String, List<String>> toQueryParameters({ + required String name, + required String fileName, + String? path, + }) { + return <String, List<String>>{ + 'name': <String>[name], + 'fileName': <String>[fileName], + if (path != null && path.trim().isNotEmpty) 'path': <String>[path.trim()], + if (fileMimeType != null && fileMimeType!.trim().isNotEmpty) + 'fileMIMEType': <String>[fileMimeType!.trim()], + if (annotations.isNotEmpty) 'annotations': List<String>.from(annotations), + if (artifactMimeType != null && artifactMimeType!.trim().isNotEmpty) + 'artifactMIMEType': <String>[artifactMimeType!.trim()], + if (append) 'append': const <String>['true'], + if (replace) 'replace': const <String>['true'], + }; + } +} diff --git a/lib/src/options/artifacts/artifact_pull_options.dart b/lib/src/options/artifacts/artifact_pull_options.dart new file mode 100644 index 0000000..f426f33 --- /dev/null +++ b/lib/src/options/artifacts/artifact_pull_options.dart @@ -0,0 +1,24 @@ +/// Options for pulling an OCI artifact. +final class ArtifactPullOptions { + /// Creates artifact pull options. + const ArtifactPullOptions({this.retry, this.retryDelay, this.tlsVerify}); + + /// Retry count. + final int? retry; + + /// Delay between retries (for example `1s`). + final String? retryDelay; + + /// TLS verification preference. + final bool? tlsVerify; + + /// Serializes to query parameters. + Map<String, List<String>> toQueryParameters() { + return <String, List<String>>{ + if (retry != null) 'retry': <String>['$retry'], + if (retryDelay != null && retryDelay!.isNotEmpty) + 'retryDelay': <String>[retryDelay!], + if (tlsVerify != null) 'tlsVerify': <String>['$tlsVerify'], + }; + } +} diff --git a/lib/src/options/artifacts/artifact_push_options.dart b/lib/src/options/artifacts/artifact_push_options.dart new file mode 100644 index 0000000..eab3a18 --- /dev/null +++ b/lib/src/options/artifacts/artifact_push_options.dart @@ -0,0 +1,24 @@ +/// Options for pushing an OCI artifact. +final class ArtifactPushOptions { + /// Creates artifact push options. + const ArtifactPushOptions({this.retry, this.retryDelay, this.tlsVerify}); + + /// Retry count. + final int? retry; + + /// Delay between retries (for example `1s`). + final String? retryDelay; + + /// TLS verification preference. + final bool? tlsVerify; + + /// Serializes to query parameters. + Map<String, List<String>> toQueryParameters() { + return <String, List<String>>{ + if (retry != null) 'retry': <String>['$retry'], + if (retryDelay != null && retryDelay!.isNotEmpty) + 'retrydelay': <String>[retryDelay!], + if (tlsVerify != null) 'tlsVerify': <String>['$tlsVerify'], + }; + } +} diff --git a/lib/src/options/artifacts/artifact_remove_options.dart b/lib/src/options/artifacts/artifact_remove_options.dart new file mode 100644 index 0000000..d3b7319 --- /dev/null +++ b/lib/src/options/artifacts/artifact_remove_options.dart @@ -0,0 +1,27 @@ +/// Options for batch artifact removal. +final class ArtifactRemoveOptions { + /// Creates artifact remove options. + const ArtifactRemoveOptions({ + this.artifacts = const <String>[], + this.all = false, + this.ignore = false, + }); + + /// Artifact names/digests to remove. + final List<String> artifacts; + + /// Remove all artifacts. + final bool all; + + /// Ignore not-found errors. + final bool ignore; + + /// Serializes to query parameters. + Map<String, List<String>> toQueryParameters() { + return <String, List<String>>{ + if (artifacts.isNotEmpty) 'artifacts': artifacts, + if (all) 'all': const <String>['true'], + if (ignore) 'ignore': const <String>['true'], + }; + } +} diff --git a/lib/src/options/containers/container_checkpoint_options.dart b/lib/src/options/containers/container_checkpoint_options.dart new file mode 100644 index 0000000..7be5894 --- /dev/null +++ b/lib/src/options/containers/container_checkpoint_options.dart @@ -0,0 +1,64 @@ +/// Options for container checkpoint operations. +final class ContainerCheckpointOptions { + /// Creates checkpoint options. + const ContainerCheckpointOptions({ + this.keep = false, + this.leaveRunning = false, + this.tcpEstablished = false, + this.ignoreRootFs = false, + this.ignoreVolumes = false, + this.printStats = false, + this.preCheckpoint = false, + this.withPrevious = false, + this.fileLocks = false, + this.createImage, + }); + + /// Keep temporary checkpoint files. + final bool keep; + + /// Keep container running after checkpoint. + final bool leaveRunning; + + /// Include established TCP connections. + final bool tcpEstablished; + + /// Omit root filesystem changes for export. + final bool ignoreRootFs; + + /// Omit volume content for export. + final bool ignoreVolumes; + + /// Include checkpoint timing/statistics data. + final bool printStats; + + /// Create only a pre-checkpoint dump. + final bool preCheckpoint; + + /// Use previous checkpoint images for incremental dump. + final bool withPrevious; + + /// Include file locks in checkpoint. + final bool fileLocks; + + /// Optional checkpoint image name. + final String? createImage; + + /// Serializes to query parameters. + Map<String, List<String>> toQueryParameters({bool exportArchive = false}) { + return <String, List<String>>{ + if (keep) 'keep': const <String>['true'], + if (leaveRunning) 'leaveRunning': const <String>['true'], + if (tcpEstablished) 'tcpEstablished': const <String>['true'], + if (exportArchive) 'export': const <String>['true'], + if (ignoreRootFs) 'ignoreRootFS': const <String>['true'], + if (ignoreVolumes) 'ignoreVolumes': const <String>['true'], + if (printStats) 'printStats': const <String>['true'], + if (preCheckpoint) 'preCheckpoint': const <String>['true'], + if (withPrevious) 'withPrevious': const <String>['true'], + if (fileLocks) 'fileLocks': const <String>['true'], + if (createImage != null && createImage!.isNotEmpty) + 'createImage': <String>[createImage!], + }; + } +} diff --git a/lib/src/options/containers/container_restore_options.dart b/lib/src/options/containers/container_restore_options.dart new file mode 100644 index 0000000..2c86a36 --- /dev/null +++ b/lib/src/options/containers/container_restore_options.dart @@ -0,0 +1,73 @@ +/// Options for container restore operations. +final class ContainerRestoreOptions { + /// Creates restore options. + const ContainerRestoreOptions({ + this.name, + this.keep = false, + this.tcpEstablished = false, + this.tcpClose = false, + this.ignoreRootFs = false, + this.ignoreVolumes = false, + this.ignoreStaticIp = false, + this.ignoreStaticMac = false, + this.printStats = false, + this.fileLocks = false, + this.publishPorts = const <String>[], + this.pod, + }); + + /// Name to assign restored container (import flow). + final String? name; + + /// Keep temporary restore files. + final bool keep; + + /// Restore established TCP connections. + final bool tcpEstablished; + + /// Restore while closing TCP connections. + final bool tcpClose; + + /// Ignore root filesystem changes (import flow). + final bool ignoreRootFs; + + /// Ignore associated volumes (import flow). + final bool ignoreVolumes; + + /// Ignore configured static IP. + final bool ignoreStaticIp; + + /// Ignore configured static MAC. + final bool ignoreStaticMac; + + /// Include restore timing/statistics data. + final bool printStats; + + /// Include file locks in restore. + final bool fileLocks; + + /// Port publish mappings during restore. + final List<String> publishPorts; + + /// Pod to restore into. + final String? pod; + + /// Serializes to query parameters. + Map<String, List<String>> toQueryParameters({bool importArchive = false}) { + return <String, List<String>>{ + if (name != null && name!.isNotEmpty) 'name': <String>[name!], + if (keep) 'keep': const <String>['true'], + if (tcpEstablished) 'tcpEstablished': const <String>['true'], + if (tcpClose) 'tcpClose': const <String>['true'], + if (importArchive) 'import': const <String>['true'], + if (ignoreRootFs) 'ignoreRootFS': const <String>['true'], + if (ignoreVolumes) 'ignoreVolumes': const <String>['true'], + if (ignoreStaticIp) 'ignoreStaticIP': const <String>['true'], + if (ignoreStaticMac) 'ignoreStaticMAC': const <String>['true'], + if (fileLocks) 'fileLocks': const <String>['true'], + if (printStats) 'printStats': const <String>['true'], + if (publishPorts.isNotEmpty) 'publishPorts': publishPorts, + if (pod != null && pod!.isNotEmpty) 'pod': <String>[pod!], + }; + } +} diff --git a/lib/src/options/containers/container_top_options.dart b/lib/src/options/containers/container_top_options.dart new file mode 100644 index 0000000..899b7df --- /dev/null +++ b/lib/src/options/containers/container_top_options.dart @@ -0,0 +1,15 @@ +/// Options for `GET /libpod/containers/{name}/top`. +final class ContainerTopOptions { + /// Creates container top options. + const ContainerTopOptions({this.psArgs = const <String>[]}); + + /// Arguments to pass to `ps` (for example `aux`). + final List<String> psArgs; + + /// Serializes options to query parameters. + Map<String, List<String>> toQueryParameters() { + return <String, List<String>>{ + if (psArgs.isNotEmpty) 'ps_args': List<String>.from(psArgs), + }; + } +} diff --git a/lib/src/options/containers/container_update_options.dart b/lib/src/options/containers/container_update_options.dart new file mode 100644 index 0000000..f710852 --- /dev/null +++ b/lib/src/options/containers/container_update_options.dart @@ -0,0 +1,42 @@ +/// Options for `POST /libpod/containers/{name}/update`. +final class ContainerUpdateOptions { + /// Creates container update options. + const ContainerUpdateOptions({ + this.config = const <String, Object?>{}, + this.restartPolicy, + this.restartRetries, + }); + + /// Update body payload. + /// + /// This is forwarded to Podman as-is and can include keys such as + /// `resources`, `env`, `unsetEnv`, `r_limits`, and health-check updates. + final Map<String, Object?> config; + + /// Optional restart policy. + final String? restartPolicy; + + /// Optional restart retries (valid when restart policy is `on-failure`). + final int? restartRetries; + + /// Serializes options to query parameters. + Map<String, List<String>> toQueryParameters() { + if (restartRetries != null && + (restartPolicy == null || restartPolicy!.trim().isEmpty)) { + throw ArgumentError( + 'restartRetries requires restartPolicy (typically `on-failure`).', + ); + } + + return <String, List<String>>{ + if (restartPolicy != null && restartPolicy!.trim().isNotEmpty) + 'restartPolicy': <String>[restartPolicy!.trim()], + if (restartRetries != null) 'restartRetries': <String>['$restartRetries'], + }; + } + + /// Serializes options for the update API body. + Map<String, Object?> toApiBody() { + return Map<String, Object?>.from(config); + } +} diff --git a/lib/src/options/containers/exec_create_options.dart b/lib/src/options/containers/exec_create_options.dart new file mode 100644 index 0000000..bd6d94a --- /dev/null +++ b/lib/src/options/containers/exec_create_options.dart @@ -0,0 +1,64 @@ +/// Options for creating an exec process in a running container. +final class ExecCreateOptions { + /// Creates exec options. + const ExecCreateOptions({ + required this.command, + this.environment = const <String, String>{}, + this.user, + this.workingDirectory, + this.privileged = false, + this.tty = false, + this.attachStdin = false, + this.attachStdout = true, + this.attachStderr = true, + }); + + /// Command and arguments. + final List<String> command; + + /// Environment variables. + final Map<String, String> environment; + + /// User to run as. + final String? user; + + /// Working directory. + final String? workingDirectory; + + /// Privileged exec mode. + final bool privileged; + + /// Allocate TTY. + final bool tty; + + /// Attach stdin stream. + final bool attachStdin; + + /// Attach stdout stream. + final bool attachStdout; + + /// Attach stderr stream. + final bool attachStderr; + + /// Serializes options for libpod exec create API. + Map<String, Object?> toApiBody() { + final env = + environment.entries + .map((entry) => '${entry.key}=${entry.value}') + .toList(growable: false) + ..sort(); + + return <String, Object?>{ + 'Cmd': command, + 'Privileged': privileged, + 'Tty': tty, + 'AttachStdin': attachStdin, + 'AttachStdout': attachStdout, + 'AttachStderr': attachStderr, + if (env.isNotEmpty) 'Env': env, + if (user != null && user!.isNotEmpty) 'User': user, + if (workingDirectory != null && workingDirectory!.isNotEmpty) + 'WorkingDir': workingDirectory, + }; + } +} diff --git a/lib/src/options/containers/mount_binding.dart b/lib/src/options/containers/mount_binding.dart new file mode 100644 index 0000000..e566633 --- /dev/null +++ b/lib/src/options/containers/mount_binding.dart @@ -0,0 +1,55 @@ +/// Supported Podman mount types. +enum PodmanMountType { + /// Bind mount from host path. + bind, + + /// Named/anonymous volume. + volume, + + /// Tmpfs mount. + tmpfs, +} + +/// Podman container mount configuration. +final class MountBinding { + /// Creates a bind mount. + const MountBinding.bind({ + required this.source, + required this.target, + this.readOnly = false, + }) : type = PodmanMountType.bind; + + /// Creates a volume mount. + const MountBinding.volume({ + this.source, + required this.target, + this.readOnly = false, + }) : type = PodmanMountType.volume; + + /// Creates a tmpfs mount. + const MountBinding.tmpfs({required this.target, this.readOnly = false}) + : type = PodmanMountType.tmpfs, + source = null; + + /// Mount type. + final PodmanMountType type; + + /// Source path/volume name when applicable. + final String? source; + + /// Container target path. + final String target; + + /// Whether the mount is read-only. + final bool readOnly; + + /// Serializes to API mount JSON. + Map<String, Object?> toApiJson() { + return <String, Object?>{ + 'Type': type.name, + if (source != null && source!.isNotEmpty) 'Source': source, + 'Target': target, + 'ReadOnly': readOnly, + }; + } +} diff --git a/lib/src/options/containers/port_binding.dart b/lib/src/options/containers/port_binding.dart new file mode 100644 index 0000000..4408d31 --- /dev/null +++ b/lib/src/options/containers/port_binding.dart @@ -0,0 +1,41 @@ +/// Podman container port mapping. +final class PortBinding { + /// Exposes [containerPort] without binding to a specific host port. + const PortBinding.expose(this.containerPort, {this.protocol = 'tcp'}) + : hostPort = null, + hostIp = null; + + /// Publishes [containerPort] on [hostPort]. + const PortBinding.publish( + this.hostPort, + this.containerPort, { + this.protocol = 'tcp', + this.hostIp, + }); + + /// Host port. + final int? hostPort; + + /// Container port. + final int containerPort; + + /// Protocol (usually `tcp` or `udp`). + final String protocol; + + /// Optional host IP. + final String? hostIp; + + /// Podman/Docker API exposed port key format (`80/tcp`). + String get apiKey => '$containerPort/$protocol'; + + /// Whether host-side binding is defined. + bool get hasHostBinding => hostPort != null || hostIp != null; + + /// Serializes to API port binding entry. + Map<String, String> toApiBindingJson() { + return <String, String>{ + if (hostIp != null && hostIp!.isNotEmpty) 'HostIp': hostIp!, + if (hostPort != null) 'HostPort': '$hostPort', + }; + } +} diff --git a/lib/src/options/containers/run_options.dart b/lib/src/options/containers/run_options.dart new file mode 100644 index 0000000..9c62338 --- /dev/null +++ b/lib/src/options/containers/run_options.dart @@ -0,0 +1,112 @@ +import 'mount_binding.dart'; +import 'port_binding.dart'; + +/// Options for creating/running a container through the Podman API. +final class RunOptions { + /// Creates container run options. + const RunOptions({ + required this.image, + this.name, + this.removeWhenStopped = false, + this.command = const <String>[], + this.environment = const <String, String>{}, + this.labels = const <String, String>{}, + this.ports = const <PortBinding>[], + this.mounts = const <MountBinding>[], + this.network, + this.hostname, + this.entrypoint, + this.user, + this.workingDirectory, + this.restartPolicy, + }); + + /// Image reference to run. + final String image; + + /// Optional container name. + final String? name; + + /// Whether to remove the container after it exits. + final bool removeWhenStopped; + + /// Command to execute in the container. + final List<String> command; + + /// Environment variables. + final Map<String, String> environment; + + /// Container labels. + final Map<String, String> labels; + + /// Port mappings. + final List<PortBinding> ports; + + /// Mount configuration. + final List<MountBinding> mounts; + + /// Optional network name. + final String? network; + + /// Optional hostname. + final String? hostname; + + /// Optional entrypoint. + final String? entrypoint; + + /// Optional user. + final String? user; + + /// Optional working directory. + final String? workingDirectory; + + /// Optional restart policy. + final String? restartPolicy; + + /// Builds API request body for `POST /containers/create`. + Map<String, Object?> toCreateBody() { + final exposedPorts = <String, Object?>{}; + final portBindings = <String, List<Map<String, String>>>{}; + + for (final port in ports) { + exposedPorts[port.apiKey] = <String, Object?>{}; + if (port.hasHostBinding) { + portBindings + .putIfAbsent(port.apiKey, () => <Map<String, String>>[]) + .add(port.toApiBindingJson()); + } + } + + final hostConfig = <String, Object?>{ + if (removeWhenStopped) 'AutoRemove': true, + if (network != null && network!.isNotEmpty) 'NetworkMode': network, + if (restartPolicy != null && restartPolicy!.isNotEmpty) + 'RestartPolicy': <String, Object?>{'Name': restartPolicy}, + if (portBindings.isNotEmpty) 'PortBindings': portBindings, + if (mounts.isNotEmpty) + 'Mounts': mounts + .map((mount) => mount.toApiJson()) + .toList(growable: false), + }; + + final envKeys = environment.keys.toList()..sort(); + final envList = envKeys + .map((key) => '$key=${environment[key] ?? ''}') + .toList(growable: false); + + return <String, Object?>{ + 'Image': image, + if (command.isNotEmpty) 'Cmd': command, + if (entrypoint != null && entrypoint!.isNotEmpty) + 'Entrypoint': <String>[entrypoint!], + if (envList.isNotEmpty) 'Env': envList, + if (labels.isNotEmpty) 'Labels': labels, + if (hostname != null && hostname!.isNotEmpty) 'Hostname': hostname, + if (user != null && user!.isNotEmpty) 'User': user, + if (workingDirectory != null && workingDirectory!.isNotEmpty) + 'WorkingDir': workingDirectory, + if (exposedPorts.isNotEmpty) 'ExposedPorts': exposedPorts, + if (hostConfig.isNotEmpty) 'HostConfig': hostConfig, + }; + } +} diff --git a/lib/src/options/images/image_export_options.dart b/lib/src/options/images/image_export_options.dart new file mode 100644 index 0000000..a535968 --- /dev/null +++ b/lib/src/options/images/image_export_options.dart @@ -0,0 +1,28 @@ +/// Options for exporting image archives. +final class ImageExportOptions { + /// Creates image export options. + const ImageExportOptions({ + this.compress = false, + this.format = 'oci-archive', + this.ociAcceptUncompressedLayers = false, + }); + + /// Compress output archive. + final bool compress; + + /// Export format (for example `oci-archive`, `docker-archive`, `oci-dir`). + final String format; + + /// Accept uncompressed layers in OCI exports. + final bool ociAcceptUncompressedLayers; + + /// Serializes options to query parameters. + Map<String, List<String>> toQueryParameters() { + return <String, List<String>>{ + if (compress) 'compress': const <String>['true'], + if (format.trim().isNotEmpty) 'format': <String>[format.trim()], + if (ociAcceptUncompressedLayers) + 'ociAcceptUncompressedLayers': const <String>['true'], + }; + } +} diff --git a/lib/src/options/images/image_import_options.dart b/lib/src/options/images/image_import_options.dart new file mode 100644 index 0000000..53a061d --- /dev/null +++ b/lib/src/options/images/image_import_options.dart @@ -0,0 +1,51 @@ +/// Options for `POST /libpod/images/import`. +final class ImageImportOptions { + /// Creates image import options. + const ImageImportOptions({ + this.changes = const <String>[], + this.message, + this.reference, + this.url, + this.os, + this.architecture, + this.variant, + }); + + /// Dockerfile-style changes to apply. + final List<String> changes; + + /// Commit message. + final String? message; + + /// Target image reference. + final String? reference; + + /// Source URL when importing without request body. + final String? url; + + /// Override OS. + final String? os; + + /// Override architecture. + final String? architecture; + + /// Override architecture variant. + final String? variant; + + /// Serializes options to query parameters. + Map<String, List<String>> toQueryParameters() { + return <String, List<String>>{ + if (changes.isNotEmpty) 'changes': List<String>.from(changes), + if (message != null && message!.trim().isNotEmpty) + 'message': <String>[message!.trim()], + if (reference != null && reference!.trim().isNotEmpty) + 'reference': <String>[reference!.trim()], + if (url != null && url!.trim().isNotEmpty) 'URL': <String>[url!.trim()], + if (os != null && os!.trim().isNotEmpty) 'OS': <String>[os!.trim()], + if (architecture != null && architecture!.trim().isNotEmpty) + 'Architecture': <String>[architecture!.trim()], + if (variant != null && variant!.trim().isNotEmpty) + 'Variant': <String>[variant!.trim()], + }; + } +} diff --git a/lib/src/options/images/image_push_options.dart b/lib/src/options/images/image_push_options.dart new file mode 100644 index 0000000..7193260 --- /dev/null +++ b/lib/src/options/images/image_push_options.dart @@ -0,0 +1,73 @@ +/// Options for `POST /libpod/images/{name}/push`. +final class ImagePushOptions { + /// Creates image push options. + const ImagePushOptions({ + this.all = false, + this.compressionFormat, + this.compressionLevel, + this.forceCompressionFormat, + this.destination, + this.format, + this.removeSignatures = false, + this.retry, + this.retryDelay, + this.tlsVerify, + this.quiet = true, + }); + + /// Push all related tags/images. + final bool all; + + /// Compression format. + final String? compressionFormat; + + /// Compression level. + final int? compressionLevel; + + /// Force compression format when explicitly set. + final bool? forceCompressionFormat; + + /// Destination registry reference. + final String? destination; + + /// Manifest format. + final String? format; + + /// Remove signatures before pushing. + final bool removeSignatures; + + /// Retry count. + final int? retry; + + /// Retry delay (for example `2s`). + final String? retryDelay; + + /// TLS verification preference. + final bool? tlsVerify; + + /// Request quiet mode. + final bool quiet; + + /// Serializes options to query parameters. + Map<String, List<String>> toQueryParameters() { + return <String, List<String>>{ + if (all) 'all': const <String>['true'], + if (compressionFormat != null && compressionFormat!.trim().isNotEmpty) + 'compressionFormat': <String>[compressionFormat!.trim()], + if (compressionLevel != null) + 'compressionLevel': <String>['$compressionLevel'], + if (forceCompressionFormat != null) + 'forceCompressionFormat': <String>['$forceCompressionFormat'], + if (destination != null && destination!.trim().isNotEmpty) + 'destination': <String>[destination!.trim()], + if (format != null && format!.trim().isNotEmpty) + 'format': <String>[format!.trim()], + if (removeSignatures) 'removeSignatures': const <String>['true'], + if (retry != null) 'retry': <String>['$retry'], + if (retryDelay != null && retryDelay!.trim().isNotEmpty) + 'retryDelay': <String>[retryDelay!.trim()], + if (tlsVerify != null) 'tlsVerify': <String>['$tlsVerify'], + 'quiet': <String>['$quiet'], + }; + } +} diff --git a/lib/src/options/images/image_remove_options.dart b/lib/src/options/images/image_remove_options.dart new file mode 100644 index 0000000..3bf29fd --- /dev/null +++ b/lib/src/options/images/image_remove_options.dart @@ -0,0 +1,42 @@ +/// Options for `DELETE /libpod/images/remove`. +final class ImageRemoveOptions { + /// Creates image remove options. + const ImageRemoveOptions({ + this.images = const <String>[], + this.all = false, + this.force = false, + this.ignore = false, + this.lookupManifest = false, + this.noPrune = false, + }); + + /// Image names/IDs to remove. + final List<String> images; + + /// Remove all images. + final bool all; + + /// Force removal. + final bool force; + + /// Ignore missing images. + final bool ignore; + + /// Include manifest lookup. + final bool lookupManifest; + + /// Do not prune dangling parents. + final bool noPrune; + + /// Serializes options to query parameters. + Map<String, List<String>> toQueryParameters() { + return <String, List<String>>{ + if (images.isNotEmpty) 'images': List<String>.from(images), + if (all) 'all': const <String>['true'], + if (force) 'force': const <String>['true'], + if (ignore) 'ignore': const <String>['true'], + if (lookupManifest) 'lookupManifest': const <String>['true'], + if (noPrune) 'noprune': const <String>['true'], + }; + } +} diff --git a/lib/src/options/manifest_create_options.dart b/lib/src/options/manifest_create_options.dart new file mode 100644 index 0000000..a59ff73 --- /dev/null +++ b/lib/src/options/manifest_create_options.dart @@ -0,0 +1,38 @@ +/// Options for creating a Podman manifest list. +final class ManifestCreateOptions { + /// Creates manifest create options. + const ManifestCreateOptions({ + this.images = const <String>[], + this.all = false, + this.amend = false, + this.annotations = const <String, String>{}, + }); + + /// Images to include in the manifest list. + final List<String> images; + + /// Include all images when adding lists. + final bool all; + + /// Amend existing manifest if present. + final bool amend; + + /// Top-level annotations. + final Map<String, String> annotations; + + /// Serializes to API query parameters. + Map<String, List<String>> toQueryParameters() { + final annotationValues = + annotations.entries + .map((entry) => '${entry.key}=${entry.value}') + .toList(growable: false) + ..sort(); + + return <String, List<String>>{ + if (images.isNotEmpty) 'images': images, + if (all) 'all': const <String>['true'], + if (amend) 'amend': const <String>['true'], + if (annotationValues.isNotEmpty) 'annotation': annotationValues, + }; + } +} diff --git a/lib/src/options/networks/network_connect_options.dart b/lib/src/options/networks/network_connect_options.dart new file mode 100644 index 0000000..02b5eb2 --- /dev/null +++ b/lib/src/options/networks/network_connect_options.dart @@ -0,0 +1,33 @@ +/// Options for connecting a container to a Podman network. +final class NetworkConnectOptions { + /// Creates network connect options. + const NetworkConnectOptions({ + required this.container, + this.aliases = const <String>[], + this.interfaceName, + this.staticIps = const <String>[], + }); + + /// Container name or ID. + final String container; + + /// DNS aliases exposed on this network. + final List<String> aliases; + + /// Optional interface name inside the container. + final String? interfaceName; + + /// Optional static IP assignments. + final List<String> staticIps; + + /// Serializes options for the libpod network connect API. + Map<String, Object?> toApiBody() { + return <String, Object?>{ + 'Container': container, + if (aliases.isNotEmpty) 'Aliases': aliases, + if (interfaceName != null && interfaceName!.isNotEmpty) + 'InterfaceName': interfaceName, + if (staticIps.isNotEmpty) 'StaticIPs': staticIps, + }; + } +} diff --git a/lib/src/options/networks/network_create_options.dart b/lib/src/options/networks/network_create_options.dart new file mode 100644 index 0000000..ae514f2 --- /dev/null +++ b/lib/src/options/networks/network_create_options.dart @@ -0,0 +1,62 @@ +/// Options for creating a Podman network. +final class NetworkCreateOptions { + /// Creates network creation options. + const NetworkCreateOptions({ + required this.name, + this.driver = 'bridge', + this.internal, + this.ipv6Enabled, + this.dnsEnabled, + this.labels = const <String, String>{}, + this.options = const <String, String>{}, + this.subnet, + this.gateway, + }); + + /// Network name. + final String name; + + /// Network driver. + final String driver; + + /// Whether network should be internal-only. + final bool? internal; + + /// Whether IPv6 should be enabled. + final bool? ipv6Enabled; + + /// Whether DNS should be enabled. + final bool? dnsEnabled; + + /// User-defined labels. + final Map<String, String> labels; + + /// Driver-specific options. + final Map<String, String> options; + + /// Optional CIDR subnet. + final String? subnet; + + /// Optional gateway IP. + final String? gateway; + + /// Serializes options for the libpod create-network API. + Map<String, Object?> toApiBody() { + return <String, Object?>{ + 'name': name, + 'driver': driver, + if (internal != null) 'internal': internal, + if (ipv6Enabled != null) 'ipv6_enabled': ipv6Enabled, + if (dnsEnabled != null) 'dns_enabled': dnsEnabled, + if (labels.isNotEmpty) 'labels': labels, + if (options.isNotEmpty) 'options': options, + if (subnet != null) + 'subnets': <Object?>[ + <String, Object?>{ + 'subnet': subnet, + if (gateway != null) 'gateway': gateway, + }, + ], + }; + } +} diff --git a/lib/src/options/networks/network_prune_options.dart b/lib/src/options/networks/network_prune_options.dart new file mode 100644 index 0000000..0c75384 --- /dev/null +++ b/lib/src/options/networks/network_prune_options.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +/// Options for `POST /libpod/networks/prune`. +final class NetworkPruneOptions { + /// Creates network prune options. + const NetworkPruneOptions({this.filters = const <String, List<String>>{}}); + + /// Prune filters. + final Map<String, List<String>> filters; + + /// Serializes options to query parameters. + Map<String, List<String>> toQueryParameters() { + return <String, List<String>>{ + if (filters.isNotEmpty) 'filters': <String>[_encodeFilters(filters)], + }; + } + + String _encodeFilters(Map<String, List<String>> input) { + final sortedKeys = input.keys.toList(growable: false)..sort(); + final normalized = <String, List<String>>{}; + + for (final key in sortedKeys) { + final values = input[key]; + if (values == null || values.isEmpty) { + continue; + } + normalized[key] = List<String>.from(values); + } + + return jsonEncode(normalized); + } +} diff --git a/lib/src/options/networks/network_update_options.dart b/lib/src/options/networks/network_update_options.dart new file mode 100644 index 0000000..8031bb2 --- /dev/null +++ b/lib/src/options/networks/network_update_options.dart @@ -0,0 +1,22 @@ +/// Options for `POST /libpod/networks/{name}/update`. +final class NetworkUpdateOptions { + /// Creates network update options. + const NetworkUpdateOptions({ + this.addDnsServers = const <String>[], + this.removeDnsServers = const <String>[], + }); + + /// DNS servers to add to this network. + final List<String> addDnsServers; + + /// DNS servers to remove from this network. + final List<String> removeDnsServers; + + /// Serializes options for the libpod network update API. + Map<String, Object?> toApiBody() { + return <String, Object?>{ + if (addDnsServers.isNotEmpty) 'adddnsservers': addDnsServers, + if (removeDnsServers.isNotEmpty) 'removednsservers': removeDnsServers, + }; + } +} diff --git a/lib/src/options/orchestration/generate_kube_options.dart b/lib/src/options/orchestration/generate_kube_options.dart new file mode 100644 index 0000000..fd5104d --- /dev/null +++ b/lib/src/options/orchestration/generate_kube_options.dart @@ -0,0 +1,42 @@ +/// Options for generating Kubernetes YAML. +final class GenerateKubeOptions { + /// Creates generate-kube options. + const GenerateKubeOptions({ + required this.names, + this.service = false, + this.podmanOnly = false, + this.type, + this.replicas, + this.noTrunc = false, + }); + + /// Container/pod names or IDs. + final List<String> names; + + /// Include service objects. + final bool service; + + /// Include Podman-specific annotations. + final bool podmanOnly; + + /// Kubernetes object type (`pod`, `deployment`, ...). + final String? type; + + /// Replica count when supported by type. + final int? replicas; + + /// Use non-truncated annotations. + final bool noTrunc; + + /// Serializes to query parameters. + Map<String, List<String>> toQueryParameters() { + return <String, List<String>>{ + 'names': names, + if (service) 'service': const <String>['true'], + if (podmanOnly) 'podmanOnly': const <String>['true'], + if (type != null && type!.isNotEmpty) 'type': <String>[type!], + if (replicas != null) 'replicas': <String>['$replicas'], + if (noTrunc) 'noTrunc': const <String>['true'], + }; + } +} diff --git a/lib/src/options/orchestration/generate_systemd_options.dart b/lib/src/options/orchestration/generate_systemd_options.dart new file mode 100644 index 0000000..44d17a8 --- /dev/null +++ b/lib/src/options/orchestration/generate_systemd_options.dart @@ -0,0 +1,89 @@ +/// Options for generating systemd units. +final class GenerateSystemdOptions { + /// Creates generate-systemd options. + const GenerateSystemdOptions({ + this.useName = false, + this.createNew = false, + this.noHeader = false, + this.startTimeoutSeconds, + this.stopTimeoutSeconds, + this.restartPolicy, + this.containerPrefix, + this.podPrefix, + this.separator, + this.restartSec, + this.wants = const <String>[], + this.after = const <String>[], + this.requires = const <String>[], + this.additionalEnvVariables = const <String>[], + }); + + /// Use container/pod name rather than ID. + final bool useName; + + /// Generate a unit for creating a new container. + final bool createNew; + + /// Suppress header comments. + final bool noHeader; + + /// Start timeout in seconds. + final int? startTimeoutSeconds; + + /// Stop timeout in seconds. + final int? stopTimeoutSeconds; + + /// Restart policy. + final String? restartPolicy; + + /// Unit prefix for containers. + final String? containerPrefix; + + /// Unit prefix for pods. + final String? podPrefix; + + /// Name separator. + final String? separator; + + /// Restart delay in seconds. + final int? restartSec; + + /// Systemd `Wants=` dependencies. + final List<String> wants; + + /// Systemd `After=` dependencies. + final List<String> after; + + /// Systemd `Requires=` dependencies. + final List<String> requires; + + /// Additional env vars for unit files. + final List<String> additionalEnvVariables; + + /// Serializes to query parameters. + Map<String, List<String>> toQueryParameters() { + return <String, List<String>>{ + if (useName) 'useName': const <String>['true'], + if (createNew) 'new': const <String>['true'], + if (noHeader) 'noHeader': const <String>['true'], + if (startTimeoutSeconds != null) + 'startTimeout': <String>['$startTimeoutSeconds'], + if (stopTimeoutSeconds != null) + 'stopTimeout': <String>['$stopTimeoutSeconds'], + if (restartPolicy != null && restartPolicy!.isNotEmpty) + 'restartPolicy': <String>[restartPolicy!], + if (containerPrefix != null && containerPrefix!.isNotEmpty) + 'containerPrefix': <String>[containerPrefix!], + if (podPrefix != null && podPrefix!.isNotEmpty) + 'podPrefix': <String>[podPrefix!], + if (separator != null && separator!.isNotEmpty) + 'separator': <String>[separator!], + if (restartSec != null) 'restartSec': <String>['$restartSec'], + if (wants.isNotEmpty) 'wants': wants, + if (after.isNotEmpty) 'after': after, + if (requires.isNotEmpty) 'requires': requires, + if (additionalEnvVariables.isNotEmpty) + 'additionalEnvVariables': additionalEnvVariables, + }; + } +} diff --git a/lib/src/options/orchestration/play_kube_options.dart b/lib/src/options/orchestration/play_kube_options.dart new file mode 100644 index 0000000..73e3b07 --- /dev/null +++ b/lib/src/options/orchestration/play_kube_options.dart @@ -0,0 +1,82 @@ +/// Options for `play kube` operations. +final class PlayKubeOptions { + /// Creates play-kube options. + const PlayKubeOptions({ + this.networks = const <String>[], + this.noHosts = false, + this.noTrunc = false, + this.publishPorts = const <String>[], + this.publishAllPorts = false, + this.replace = false, + this.serviceContainer = false, + this.start, + this.staticIps = const <String>[], + this.staticMacs = const <String>[], + this.tlsVerify, + this.userns, + this.wait = false, + this.build, + }); + + /// Networks for created pods. + final List<String> networks; + + /// Do not create `/etc/hosts`. + final bool noHosts; + + /// Use non-truncated annotations. + final bool noTrunc; + + /// Port publish specs. + final List<String> publishPorts; + + /// Publish all declared ports. + final bool publishAllPorts; + + /// Replace existing resources. + final bool replace; + + /// Start a service container first. + final bool serviceContainer; + + /// Start resources after creation. + final bool? start; + + /// Static IPs for pods. + final List<String> staticIps; + + /// Static MACs for pods. + final List<String> staticMacs; + + /// TLS verification preference. + final bool? tlsVerify; + + /// User namespace mode. + final String? userns; + + /// Wait and clean up when pods exit. + final bool wait; + + /// Build missing images. + final bool? build; + + /// Serializes to query parameters. + Map<String, List<String>> toQueryParameters() { + return <String, List<String>>{ + if (networks.isNotEmpty) 'network': networks, + if (noHosts) 'noHosts': const <String>['true'], + if (noTrunc) 'noTrunc': const <String>['true'], + if (publishPorts.isNotEmpty) 'publishPorts': publishPorts, + if (publishAllPorts) 'publishAllPorts': const <String>['true'], + if (replace) 'replace': const <String>['true'], + if (serviceContainer) 'serviceContainer': const <String>['true'], + if (start != null) 'start': <String>['$start'], + if (staticIps.isNotEmpty) 'staticIPs': staticIps, + if (staticMacs.isNotEmpty) 'staticMACs': staticMacs, + if (tlsVerify != null) 'tlsVerify': <String>['$tlsVerify'], + if (userns != null && userns!.isNotEmpty) 'userns': <String>[userns!], + if (wait) 'wait': const <String>['true'], + if (build != null) 'build': <String>['$build'], + }; + } +} diff --git a/lib/src/options/pods/pod_create_options.dart b/lib/src/options/pods/pod_create_options.dart new file mode 100644 index 0000000..f5503fb --- /dev/null +++ b/lib/src/options/pods/pod_create_options.dart @@ -0,0 +1,37 @@ +/// Options for creating a Podman pod. +final class PodCreateOptions { + /// Creates pod creation options. + const PodCreateOptions({ + required this.name, + this.hostname, + this.labels = const <String, String>{}, + this.network, + this.infra = true, + }); + + /// Pod name. + final String name; + + /// Optional pod hostname. + final String? hostname; + + /// Pod labels. + final Map<String, String> labels; + + /// Optional network to attach. + final String? network; + + /// Whether to create infra container. + final bool infra; + + /// Serializes options for the libpod pod create API. + Map<String, Object?> toApiBody() { + return <String, Object?>{ + 'name': name, + if (hostname != null && hostname!.isNotEmpty) 'hostname': hostname, + if (labels.isNotEmpty) 'labels': labels, + if (network != null && network!.isNotEmpty) 'network': network, + 'infra': infra, + }; + } +} diff --git a/lib/src/options/pods/pod_stats_options.dart b/lib/src/options/pods/pod_stats_options.dart new file mode 100644 index 0000000..cec0156 --- /dev/null +++ b/lib/src/options/pods/pod_stats_options.dart @@ -0,0 +1,19 @@ +/// Options for `GET /libpod/pods/stats`. +final class PodStatsOptions { + /// Creates pod stats options. + const PodStatsOptions({this.namesOrIds = const <String>[], this.all = false}); + + /// Pod names or IDs to query. + final List<String> namesOrIds; + + /// Include all running pods. + final bool all; + + /// Serializes options to query parameters. + Map<String, List<String>> toQueryParameters() { + return <String, List<String>>{ + if (namesOrIds.isNotEmpty) 'namesOrIDs': List<String>.from(namesOrIds), + if (all) 'all': const <String>['true'], + }; + } +} diff --git a/lib/src/options/pods/pod_top_options.dart b/lib/src/options/pods/pod_top_options.dart new file mode 100644 index 0000000..b8d48ca --- /dev/null +++ b/lib/src/options/pods/pod_top_options.dart @@ -0,0 +1,16 @@ +/// Options for `GET /libpod/pods/{name}/top`. +final class PodTopOptions { + /// Creates pod top options. + const PodTopOptions({this.psArgs}); + + /// Arguments to pass to `ps` (for example `aux`). + final String? psArgs; + + /// Serializes options to query parameters. + Map<String, List<String>> toQueryParameters() { + return <String, List<String>>{ + if (psArgs != null && psArgs!.trim().isNotEmpty) + 'ps_args': <String>[psArgs!.trim()], + }; + } +} diff --git a/lib/src/options/secret_create_options.dart b/lib/src/options/secret_create_options.dart new file mode 100644 index 0000000..e1342f8 --- /dev/null +++ b/lib/src/options/secret_create_options.dart @@ -0,0 +1,57 @@ +/// Options for creating a Podman secret. +final class SecretCreateOptions { + /// Creates secret options. + const SecretCreateOptions({ + required this.name, + required this.data, + this.driver, + this.driverOptions = const <String, String>{}, + this.labels = const <String, String>{}, + this.replace = false, + this.ignore = false, + }); + + /// Secret name. + final String name; + + /// Secret payload. + final String data; + + /// Optional secret driver. + final String? driver; + + /// Driver options. + final Map<String, String> driverOptions; + + /// Secret labels. + final Map<String, String> labels; + + /// Replace an existing secret with the same name. + final bool replace; + + /// Ignore create errors if secret exists. + final bool ignore; + + /// Serializes options for query parameters. + Map<String, List<String>> toQueryParameters() { + final driverOpts = + driverOptions.entries + .map((entry) => '${entry.key}=${entry.value}') + .toList(growable: false) + ..sort(); + final labelValues = + labels.entries + .map((entry) => '${entry.key}=${entry.value}') + .toList(growable: false) + ..sort(); + + return <String, List<String>>{ + 'name': <String>[name], + if (driver != null && driver!.isNotEmpty) 'driver': <String>[driver!], + if (driverOpts.isNotEmpty) 'driveropts': driverOpts, + if (labelValues.isNotEmpty) 'labels': labelValues, + if (replace) 'replace': const <String>['true'], + if (ignore) 'ignore': const <String>['true'], + }; + } +} diff --git a/lib/src/options/system_check_options.dart b/lib/src/options/system_check_options.dart new file mode 100644 index 0000000..59f073c --- /dev/null +++ b/lib/src/options/system_check_options.dart @@ -0,0 +1,34 @@ +/// Options for `POST /libpod/system/check`. +final class SystemCheckOptions { + /// Creates system-check options. + const SystemCheckOptions({ + this.quick = false, + this.repair = false, + this.repairLossy = false, + this.unreferencedLayerMaxAge, + }); + + /// Skip slower integrity checks. + final bool quick; + + /// Remove inconsistent images. + final bool repair; + + /// Remove inconsistent images and containers. + final bool repairLossy; + + /// Maximum age for unreferenced layers (for example `24h`). + final String? unreferencedLayerMaxAge; + + /// Serializes to query parameters. + Map<String, List<String>> toQueryParameters() { + return <String, List<String>>{ + if (quick) 'quick': const <String>['true'], + if (repair) 'repair': const <String>['true'], + if (repairLossy) 'repair_lossy': const <String>['true'], + if (unreferencedLayerMaxAge != null && + unreferencedLayerMaxAge!.isNotEmpty) + 'unreferenced_layer_max_age': <String>[unreferencedLayerMaxAge!], + }; + } +} diff --git a/lib/src/options/system_prune_options.dart b/lib/src/options/system_prune_options.dart new file mode 100644 index 0000000..9a99352 --- /dev/null +++ b/lib/src/options/system_prune_options.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; + +/// Options for `POST /libpod/system/prune`. +final class SystemPruneOptions { + /// Creates system-prune options. + const SystemPruneOptions({ + this.all = false, + this.volumes = false, + this.external = false, + this.build = false, + this.filters = const <String, List<String>>{}, + }); + + /// Prune all unused images, not only dangling ones. + final bool all; + + /// Prune unused volumes. + final bool volumes; + + /// Prune external data. + final bool external; + + /// Prune build cache. + final bool build; + + /// Prune filters. + final Map<String, List<String>> filters; + + /// Serializes to query parameters. + Map<String, List<String>> toQueryParameters() { + return <String, List<String>>{ + if (all) 'all': const <String>['true'], + if (volumes) 'volumes': const <String>['true'], + if (external) 'external': const <String>['true'], + if (build) 'build': const <String>['true'], + if (filters.isNotEmpty) 'filters': <String>[_encodeFilters(filters)], + }; + } + + String _encodeFilters(Map<String, List<String>> input) { + final sortedKeys = input.keys.toList(growable: false)..sort(); + final normalized = <String, List<String>>{}; + + for (final key in sortedKeys) { + final values = input[key]; + if (values == null || values.isEmpty) { + continue; + } + normalized[key] = List<String>.from(values); + } + + return jsonEncode(normalized); + } +} diff --git a/lib/src/options/volume_create_options.dart b/lib/src/options/volume_create_options.dart new file mode 100644 index 0000000..5d5f3fc --- /dev/null +++ b/lib/src/options/volume_create_options.dart @@ -0,0 +1,32 @@ +/// Options for creating a Podman volume. +final class VolumeCreateOptions { + /// Creates volume options. + const VolumeCreateOptions({ + this.name, + this.driver = 'local', + this.labels = const <String, String>{}, + this.options = const <String, String>{}, + }); + + /// Optional volume name. Podman will auto-generate one when omitted. + final String? name; + + /// Volume driver. + final String driver; + + /// Labels attached to the volume. + final Map<String, String> labels; + + /// Driver options. + final Map<String, String> options; + + /// Serializes options for the libpod volume create API. + Map<String, Object?> toApiBody() { + return <String, Object?>{ + if (name != null && name!.isNotEmpty) 'Name': name, + 'Driver': driver, + if (labels.isNotEmpty) 'Labels': labels, + if (options.isNotEmpty) 'Options': options, + }; + } +} diff --git a/lib/src/options/volume_list_options.dart b/lib/src/options/volume_list_options.dart new file mode 100644 index 0000000..fdd6988 --- /dev/null +++ b/lib/src/options/volume_list_options.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +/// Options for `GET /libpod/volumes/json`. +final class VolumeListOptions { + /// Creates volume list options. + const VolumeListOptions({this.filters = const <String, List<String>>{}}); + + /// Volume list filters. + final Map<String, List<String>> filters; + + /// Serializes options to query parameters. + Map<String, List<String>> toQueryParameters() { + return <String, List<String>>{ + if (filters.isNotEmpty) 'filters': <String>[_encodeFilters(filters)], + }; + } + + String _encodeFilters(Map<String, List<String>> input) { + final sortedKeys = input.keys.toList(growable: false)..sort(); + final normalized = <String, List<String>>{}; + + for (final key in sortedKeys) { + final values = input[key]; + if (values == null || values.isEmpty) { + continue; + } + normalized[key] = List<String>.from(values); + } + + return jsonEncode(normalized); + } +} diff --git a/lib/src/options/volume_prune_options.dart b/lib/src/options/volume_prune_options.dart new file mode 100644 index 0000000..ffd4884 --- /dev/null +++ b/lib/src/options/volume_prune_options.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +/// Options for `POST /libpod/volumes/prune`. +final class VolumePruneOptions { + /// Creates volume prune options. + const VolumePruneOptions({this.filters = const <String, List<String>>{}}); + + /// Prune filters. + final Map<String, List<String>> filters; + + /// Serializes options to query parameters. + Map<String, List<String>> toQueryParameters() { + return <String, List<String>>{ + if (filters.isNotEmpty) 'filters': <String>[_encodeFilters(filters)], + }; + } + + String _encodeFilters(Map<String, List<String>> input) { + final sortedKeys = input.keys.toList(growable: false)..sort(); + final normalized = <String, List<String>>{}; + + for (final key in sortedKeys) { + final values = input[key]; + if (values == null || values.isEmpty) { + continue; + } + normalized[key] = List<String>.from(values); + } + + return jsonEncode(normalized); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..25e1123 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,389 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b + url: "https://pub.dev" + source: hosted + version: "100.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" + url: "https://pub.dev" + source: hosted + version: "13.0.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + lints: + dependency: "direct dev" + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + meta: + dependency: transitive + description: + name: meta + sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 + url: "https://pub.dev" + source: hosted + version: "1.18.2" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + url: "https://pub.dev" + source: hosted + version: "1.31.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + test_core: + dependency: transitive + description: + name: test_core + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + url: "https://pub.dev" + source: hosted + version: "0.6.18" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.4 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..3d7f4e4 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,14 @@ +name: podman +description: A Dart client for the Podman libpod API over Unix sockets. +version: 0.1.0 +repository: https://git.artificery.dev/artificery/podman +homepage: https://git.artificery.dev/artificery/podman + +environment: + sdk: ^3.11.4 + +dependencies: {} + +dev_dependencies: + lints: ^6.0.0 + test: ^1.25.6 diff --git a/test/podman_client_artifacts_test.dart b/test/podman_client_artifacts_test.dart new file mode 100644 index 0000000..29308ea --- /dev/null +++ b/test/podman_client_artifacts_test.dart @@ -0,0 +1,191 @@ +import 'package:podman/podman.dart'; +import 'package:test/test.dart'; + +import 'support/fake_podman_transport.dart'; + +void main() { + group('PodmanClient artifacts API', () { + test('supports list, inspect, pull, add, push, extract, and remove', () async { + final blobBytes = <int>[1, 2, 3, 4]; + final extractBytes = <int>[9, 8, 7]; + + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/artifacts/json', + responseBody: <Object?>[ + <String, Object?>{ + 'Name': 'quay.io/groupware/policy:latest', + 'Digest': 'sha256:abc', + 'Manifest': <String, Object?>{'schemaVersion': 2}, + }, + ], + ) + ..enqueue( + method: HttpMethod.get, + path: + '/v5.0.0/libpod/artifacts/quay.io%2Fgroupware%2Fpolicy%3Alatest/json', + responseBody: const <String, Object?>{ + 'Name': 'quay.io/groupware/policy:latest', + 'Digest': 'sha256:abc', + 'Manifest': <String, Object?>{'schemaVersion': 2}, + }, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/artifacts/pull', + queryParameters: const <String, List<String>>{ + 'name': <String>['quay.io/groupware/policy:latest'], + 'retry': <String>['5'], + 'retryDelay': <String>['2s'], + 'tlsVerify': <String>['true'], + }, + statusCode: 200, + responseBody: const <String, Object?>{'ArtifactDigest': 'sha256:abc'}, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/artifacts/add', + queryParameters: const <String, List<String>>{ + 'name': <String>['quay.io/groupware/policy:latest'], + 'fileName': <String>['policy.json'], + 'fileMIMEType': <String>['application/json'], + 'annotations': <String>['purpose=policy'], + 'append': <String>['true'], + }, + body: blobBytes, + statusCode: 201, + responseBody: const <String, Object?>{'ArtifactDigest': 'sha256:add'}, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/artifacts/local/add', + queryParameters: const <String, List<String>>{ + 'name': <String>['quay.io/groupware/policy:latest'], + 'fileName': <String>['policy.json'], + 'path': <String>['/tmp/policy.json'], + 'replace': <String>['true'], + }, + statusCode: 201, + responseBody: const <String, Object?>{ + 'ArtifactDigest': 'sha256:localadd', + }, + ) + ..enqueue( + method: HttpMethod.post, + path: + '/v5.0.0/libpod/artifacts/quay.io%2Fgroupware%2Fpolicy%3Alatest/push', + queryParameters: const <String, List<String>>{ + 'retry': <String>['3'], + 'retrydelay': <String>['1s'], + 'tlsVerify': <String>['false'], + }, + statusCode: 200, + responseBody: const <String, Object?>{'ArtifactDigest': 'sha256:abc'}, + ) + ..enqueue( + method: HttpMethod.get, + path: + '/v5.0.0/libpod/artifacts/quay.io%2Fgroupware%2Fpolicy%3Alatest/extract', + queryParameters: const <String, List<String>>{ + 'title': <String>['policy.json'], + }, + statusCode: 200, + responseBody: extractBytes, + ) + ..enqueue( + method: HttpMethod.delete, + path: + '/v5.0.0/libpod/artifacts/quay.io%2Fgroupware%2Fpolicy%3Alatest', + statusCode: 200, + responseBody: const <String, Object?>{ + 'ArtifactDigests': <String>['sha256:abc'], + }, + ) + ..enqueue( + method: HttpMethod.delete, + path: '/v5.0.0/libpod/artifacts/remove', + queryParameters: const <String, List<String>>{ + 'artifacts': <String>['quay.io/groupware/policy:latest'], + 'ignore': <String>['true'], + }, + statusCode: 200, + responseBody: const <String, Object?>{ + 'ArtifactDigests': <String>['sha256:abc'], + }, + ); + + final client = PodmanClient(transport: transport); + + final list = await client.listArtifacts(); + expect(list, hasLength(1)); + expect(list.first.digest, 'sha256:abc'); + + final inspected = await client.inspectArtifact( + 'quay.io/groupware/policy:latest', + ); + expect(inspected.name, 'quay.io/groupware/policy:latest'); + + final pulled = await client.pullArtifact( + 'quay.io/groupware/policy:latest', + options: const ArtifactPullOptions( + retry: 5, + retryDelay: '2s', + tlsVerify: true, + ), + ); + expect(pulled.artifactDigest, 'sha256:abc'); + + final added = await client.addArtifact( + 'quay.io/groupware/policy:latest', + fileName: 'policy.json', + fileBytes: blobBytes, + options: const ArtifactAddOptions( + fileMimeType: 'application/json', + annotations: <String>['purpose=policy'], + append: true, + ), + ); + expect(added.artifactDigest, 'sha256:add'); + + final localAdded = await client.addLocalArtifact( + 'quay.io/groupware/policy:latest', + path: '/tmp/policy.json', + fileName: 'policy.json', + options: const ArtifactAddOptions(replace: true), + ); + expect(localAdded.artifactDigest, 'sha256:localadd'); + + final pushed = await client.pushArtifact( + 'quay.io/groupware/policy:latest', + options: const ArtifactPushOptions( + retry: 3, + retryDelay: '1s', + tlsVerify: false, + ), + ); + expect(pushed.artifactDigest, 'sha256:abc'); + + final extracted = await client.extractArtifact( + 'quay.io/groupware/policy:latest', + title: 'policy.json', + ); + expect(extracted, extractBytes); + + final removed = await client.removeArtifact( + 'quay.io/groupware/policy:latest', + ); + expect(removed.artifactDigests, contains('sha256:abc')); + + final batchRemoved = await client.removeArtifacts( + const ArtifactRemoveOptions( + artifacts: <String>['quay.io/groupware/policy:latest'], + ignore: true, + ), + ); + expect(batchRemoved.artifactDigests, contains('sha256:abc')); + + transport.expectNoPending(); + }); + }); +} diff --git a/test/podman_client_container_admin_test.dart b/test/podman_client_container_admin_test.dart new file mode 100644 index 0000000..ffc1aff --- /dev/null +++ b/test/podman_client_container_admin_test.dart @@ -0,0 +1,138 @@ +import 'package:podman/podman.dart'; +import 'package:test/test.dart'; + +import 'support/fake_podman_transport.dart'; + +void main() { + group('PodmanClient container admin API', () { + test('supports container admin endpoints', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/containers/showmounted', + responseBody: const <String, Object?>{ + 'ctr-1': '/var/lib/containers/storage/overlay/ctr-1/merged', + }, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/ctr-1/pause', + statusCode: 204, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/ctr-1/unpause', + statusCode: 204, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/ctr-1/kill', + queryParameters: const <String, List<String>>{ + 'signal': <String>['SIGTERM'], + }, + statusCode: 204, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/containers/ctr-1/top', + queryParameters: const <String, List<String>>{ + 'ps_args': <String>['aux', '--sort', 'pid'], + }, + responseBody: const <String, Object?>{ + 'Titles': <String>['PID', 'USER', 'COMMAND'], + 'Processes': <Object?>[ + <Object?>['1', 'root', '/pause'], + ], + }, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/ctr-1/init', + statusCode: 304, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/ctr-1/rename', + queryParameters: const <String, List<String>>{ + 'name': <String>['orchestrator'], + }, + statusCode: 204, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/orchestrator/update', + queryParameters: const <String, List<String>>{ + 'restartPolicy': <String>['on-failure'], + 'restartRetries': <String>['3'], + }, + body: const <String, Object?>{ + 'resources': <String, Object?>{'memory': 268435456}, + }, + statusCode: 201, + responseBody: 'ctr-1', + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/orchestrator/mount', + statusCode: 200, + responseBody: + '/var/lib/containers/storage/overlay/orchestrator/merged', + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/orchestrator/unmount', + statusCode: 204, + ); + + final client = PodmanClient(transport: transport); + + final mounted = await client.showMountedContainers(); + expect( + mounted['ctr-1'], + '/var/lib/containers/storage/overlay/ctr-1/merged', + ); + + await client.pauseContainer('ctr-1'); + await client.unpauseContainer('ctr-1'); + await client.killContainer('ctr-1', signal: 'SIGTERM'); + + final top = await client.topContainer( + 'ctr-1', + options: const ContainerTopOptions( + psArgs: <String>['aux', '--sort', 'pid'], + ), + ); + expect(top.titles, <String>['PID', 'USER', 'COMMAND']); + expect(top.processes.first, <String>['1', 'root', '/pause']); + + await client.initContainer('ctr-1'); + await client.renameContainer('ctr-1', name: 'orchestrator'); + + final updatedId = await client.updateContainer( + 'orchestrator', + const ContainerUpdateOptions( + restartPolicy: 'on-failure', + restartRetries: 3, + config: <String, Object?>{ + 'resources': <String, Object?>{'memory': 268435456}, + }, + ), + ); + expect(updatedId, 'ctr-1'); + + final mountPoint = await client.mountContainer('orchestrator'); + expect( + mountPoint, + '/var/lib/containers/storage/overlay/orchestrator/merged', + ); + await client.unmountContainer('orchestrator'); + + transport.expectNoPending(); + }); + + test('validates restartRetries requires restartPolicy', () { + const options = ContainerUpdateOptions(restartRetries: 2); + expect(options.toQueryParameters, throwsArgumentError); + }); + }); +} diff --git a/test/podman_client_container_archive_test.dart b/test/podman_client_container_archive_test.dart new file mode 100644 index 0000000..7247473 --- /dev/null +++ b/test/podman_client_container_archive_test.dart @@ -0,0 +1,75 @@ +import 'package:podman/podman.dart'; +import 'package:test/test.dart'; + +import 'support/fake_podman_transport.dart'; + +void main() { + group('PodmanClient container archive API', () { + test('supports head/get/put archive endpoints', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.head, + path: '/v5.0.0/libpod/containers/orchestrator/archive', + queryParameters: const <String, List<String>>{ + 'path': <String>['/etc'], + }, + statusCode: 200, + headers: const <String, List<String>>{ + 'x-docker-container-path-stat': <String>['stat-head'], + }, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/containers/orchestrator/archive', + queryParameters: const <String, List<String>>{ + 'path': <String>['/etc'], + 'rename': <String>['{"etc":"config"}'], + }, + statusCode: 200, + headers: const <String, List<String>>{ + 'x-docker-container-path-stat': <String>['stat-get'], + }, + responseBody: <int>[1, 2, 3, 4], + ) + ..enqueue( + method: HttpMethod.put, + path: '/v5.0.0/libpod/containers/orchestrator/archive', + queryParameters: const <String, List<String>>{ + 'path': <String>['/tmp'], + 'copyUIDGID': <String>['false'], + 'noOverwriteDirNonDir': <String>['true'], + 'rename': <String>['{"from":"to"}'], + }, + body: <int>[65, 66, 67], + statusCode: 200, + ); + + final client = PodmanClient(transport: transport); + + final headStat = await client.headContainerArchive( + 'orchestrator', + path: '/etc', + ); + expect(headStat, 'stat-head'); + + final archive = await client.getContainerArchive( + 'orchestrator', + path: '/etc', + rename: const <String, String>{'etc': 'config'}, + ); + expect(archive.pathStatHeader, 'stat-get'); + expect(archive.archiveBytes, <int>[1, 2, 3, 4]); + + await client.putContainerArchive( + 'orchestrator', + path: '/tmp', + archiveBytes: const <int>[65, 66, 67], + copyUidGid: false, + noOverwriteDirNonDir: true, + rename: const <String, String>{'from': 'to'}, + ); + + transport.expectNoPending(); + }); + }); +} diff --git a/test/podman_client_container_checkpoint_test.dart b/test/podman_client_container_checkpoint_test.dart new file mode 100644 index 0000000..f68a405 --- /dev/null +++ b/test/podman_client_container_checkpoint_test.dart @@ -0,0 +1,140 @@ +import 'package:podman/podman.dart'; +import 'package:test/test.dart'; + +import 'support/fake_podman_transport.dart'; + +void main() { + group('PodmanClient container checkpoint API', () { + test('supports checkpoint/export/restore flows', () async { + final checkpointArchiveBytes = <int>[1, 2, 3, 4]; + final restoreArchiveBytes = <int>[9, 8, 7, 6]; + + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/web/checkpoint', + queryParameters: const <String, List<String>>{ + 'keep': <String>['true'], + 'leaveRunning': <String>['true'], + 'tcpEstablished': <String>['true'], + 'ignoreRootFS': <String>['true'], + 'printStats': <String>['true'], + 'preCheckpoint': <String>['true'], + 'withPrevious': <String>['true'], + 'fileLocks': <String>['true'], + 'createImage': <String>['checkpoint/web:v1'], + }, + responseBody: const <String, Object?>{ + 'Id': 'container-123', + 'runtime_checkpoint_duration': 42, + 'criu_statistics': <String, Object?>{'freezing_time': 2}, + }, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/web/checkpoint', + queryParameters: const <String, List<String>>{ + 'keep': <String>['true'], + 'export': <String>['true'], + }, + responseBody: checkpointArchiveBytes, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/web/restore', + queryParameters: const <String, List<String>>{ + 'keep': <String>['true'], + 'tcpEstablished': <String>['true'], + 'ignoreStaticIP': <String>['true'], + 'printStats': <String>['true'], + 'publishPorts': <String>['8080:80'], + 'pod': <String>['groupware-services'], + }, + responseBody: const <String, Object?>{ + 'Id': 'container-123', + 'runtime_restore_duration': 55, + 'criu_statistics': <String, Object?>{'restore_time': 3}, + }, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/import/restore', + queryParameters: const <String, List<String>>{ + 'name': <String>['web-restored'], + 'import': <String>['true'], + 'ignoreRootFS': <String>['true'], + 'ignoreVolumes': <String>['true'], + }, + body: restoreArchiveBytes, + responseBody: const <String, Object?>{ + 'Id': 'container-456', + 'runtime_restore_duration': 12, + 'criu_statistics': <String, Object?>{}, + }, + ); + + final client = PodmanClient(transport: transport); + + final checkpoint = await client.checkpointContainer( + 'web', + options: const ContainerCheckpointOptions( + keep: true, + leaveRunning: true, + tcpEstablished: true, + ignoreRootFs: true, + printStats: true, + preCheckpoint: true, + withPrevious: true, + fileLocks: true, + createImage: 'checkpoint/web:v1', + ), + ); + expect(checkpoint.id, 'container-123'); + expect(checkpoint.runtimeDuration, 42); + expect(checkpoint.criuStatistics['freezing_time'], 2); + + final exported = await client.exportContainerCheckpoint( + 'web', + options: const ContainerCheckpointOptions(keep: true), + ); + expect(exported, checkpointArchiveBytes); + + final restored = await client.restoreContainer( + 'web', + options: const ContainerRestoreOptions( + keep: true, + tcpEstablished: true, + ignoreStaticIp: true, + printStats: true, + publishPorts: <String>['8080:80'], + pod: 'groupware-services', + ), + ); + expect(restored.id, 'container-123'); + expect(restored.runtimeDuration, 55); + expect(restored.criuStatistics['restore_time'], 3); + + final imported = await client.restoreContainerFromArchive( + restoreArchiveBytes, + options: const ContainerRestoreOptions( + name: 'web-restored', + ignoreRootFs: true, + ignoreVolumes: true, + ), + ); + expect(imported.id, 'container-456'); + expect(imported.runtimeDuration, 12); + + transport.expectNoPending(); + }); + + test('restoreContainerFromArchive validates non-empty bytes', () async { + final client = PodmanClient(transport: FakePodmanTransport()); + + expect( + () => client.restoreContainerFromArchive(const <int>[]), + throwsArgumentError, + ); + }); + }); +} diff --git a/test/podman_client_container_runtime_test.dart b/test/podman_client_container_runtime_test.dart new file mode 100644 index 0000000..1b06628 --- /dev/null +++ b/test/podman_client_container_runtime_test.dart @@ -0,0 +1,237 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:podman/podman.dart'; +import 'package:test/test.dart'; + +import 'support/fake_podman_transport.dart'; + +void main() { + group('PodmanClient container runtime API', () { + test('wait parses numeric status', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/web/wait', + queryParameters: const <String, List<String>>{ + 'condition': <String>['stopped'], + }, + responseBody: '0', + ); + + final client = PodmanClient(transport: transport); + final result = await client.wait('web'); + + expect(result.statusCode, 0); + transport.expectNoPending(); + }); + + test('healthStatus returns parsed healthcheck state', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/containers/web/json', + responseBody: <String, Object?>{ + 'Id': 'abc', + 'Name': '/web', + 'ImageName': 'example:latest', + 'State': <String, Object?>{ + 'Status': 'running', + 'Healthcheck': <String, Object?>{ + 'Status': 'healthy', + 'FailingStreak': 0, + }, + }, + }, + ); + + final client = PodmanClient(transport: transport); + final status = await client.healthStatus('web'); + + expect(status.status, 'healthy'); + expect(status.failingStreak, 0); + transport.expectNoPending(); + }); + + test('supports exec create/start/inspect', () async { + final framed = _frame(streamType: 1, payload: utf8.encode('hello\n')); + + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/web/exec', + body: const <String, Object?>{ + 'Cmd': <String>['echo', 'hello'], + 'Privileged': false, + 'Tty': false, + 'AttachStdin': false, + 'AttachStdout': true, + 'AttachStderr': true, + }, + statusCode: 201, + responseBody: const <String, Object?>{'Id': 'exec-1'}, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/exec/exec-1/start', + body: const <String, Object?>{'Detach': false, 'Tty': false}, + statusCode: 200, + responseBody: framed, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/exec/exec-1/json', + responseBody: const <String, Object?>{ + 'Id': 'exec-1', + 'ContainerID': 'container-1', + 'Running': false, + 'ExitCode': 0, + 'OpenStdout': true, + 'OpenStderr': true, + }, + ); + + final client = PodmanClient(transport: transport); + final created = await client.createExec( + 'web', + const ExecCreateOptions(command: <String>['echo', 'hello']), + ); + final started = await client.startExec(created.id); + final inspected = await client.inspectExec(created.id); + + expect(created.id, 'exec-1'); + expect(started.stdout, 'hello\n'); + expect(started.stderr, ''); + expect(inspected.exitCode, 0); + transport.expectNoPending(); + }); + + test('reads one-shot stats snapshot', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/containers/web/stats', + queryParameters: const <String, List<String>>{ + 'stream': <String>['false'], + }, + responseBody: const <String, Object?>{ + 'Id': 'container-1', + 'name': 'web', + 'read': '2026-04-06T21:00:00Z', + 'cpu_stats': <String, Object?>{ + 'cpu_usage': <String, Object?>{'total_usage': 42}, + }, + 'memory_stats': <String, Object?>{'usage': 512, 'limit': 1024}, + 'pids_stats': <String, Object?>{'current': 3}, + }, + ); + + final client = PodmanClient(transport: transport); + final stats = await client.stats('web'); + + expect(stats.id, 'container-1'); + expect(stats.cpuTotalUsage, 42); + expect(stats.memoryUsage, 512); + expect(stats.pidsCurrent, 3); + transport.expectNoPending(); + }); + + test('rejects streaming stats for now', () async { + final client = PodmanClient(transport: FakePodmanTransport()); + expect(() => client.stats('web', stream: true), throwsArgumentError); + }); + + test('supports container and exec tty resize', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/web/resize', + queryParameters: const <String, List<String>>{ + 'w': <String>['120'], + 'h': <String>['40'], + 'running': <String>['true'], + }, + statusCode: 200, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/exec/exec-1/resize', + queryParameters: const <String, List<String>>{ + 'w': <String>['120'], + 'h': <String>['40'], + }, + statusCode: 201, + ); + + final client = PodmanClient(transport: transport); + await client.resizeContainerTty( + 'web', + width: 120, + height: 40, + ignoreNotRunning: true, + ); + await client.resizeExecTty('exec-1', width: 120, height: 40); + + transport.expectNoPending(); + }); + + test('watchStats and watchContainerTop emit polling snapshots', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/containers/web/stats', + queryParameters: const <String, List<String>>{ + 'stream': <String>['false'], + }, + responseBody: const <String, Object?>{ + 'Id': 'container-1', + 'name': 'web', + 'cpu_stats': <String, Object?>{ + 'cpu_usage': <String, Object?>{'total_usage': 99}, + }, + 'memory_stats': <String, Object?>{'usage': 512, 'limit': 1024}, + 'pids_stats': <String, Object?>{'current': 2}, + }, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/containers/web/top', + responseBody: const <String, Object?>{ + 'Titles': <String>['PID', 'CMD'], + 'Processes': <Object?>[ + <String>['1', 'dart run bin/orchestrator.dart'], + ], + }, + ); + + final client = PodmanClient(transport: transport); + + final stats = await client + .watchStats( + 'web', + pollInterval: const Duration(milliseconds: 1), + reconnect: false, + ) + .first; + expect(stats.cpuTotalUsage, 99); + + final top = await client + .watchContainerTop( + 'web', + pollInterval: const Duration(milliseconds: 1), + reconnect: false, + ) + .first; + expect(top.processes, hasLength(1)); + + transport.expectNoPending(); + }); + }); +} + +List<int> _frame({required int streamType, required List<int> payload}) { + final header = ByteData(8); + header.setUint8(0, streamType); + header.setUint32(4, payload.length, Endian.big); + return <int>[...header.buffer.asUint8List(), ...payload]; +} diff --git a/test/podman_client_containers_test.dart b/test/podman_client_containers_test.dart new file mode 100644 index 0000000..33ec495 --- /dev/null +++ b/test/podman_client_containers_test.dart @@ -0,0 +1,233 @@ +import 'package:podman/podman.dart'; +import 'package:test/test.dart'; + +import 'support/fake_podman_transport.dart'; + +void main() { + group('PodmanClient container API', () { + test('lists containers', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/containers/json', + queryParameters: const <String, List<String>>{ + 'all': <String>['true'], + }, + responseBody: <Object?>[ + <String, Object?>{ + 'Id': 'abc123', + 'Image': 'hello-world:latest', + 'Names': <String>['web'], + 'State': 'running', + 'Status': 'Up 4m', + }, + ], + ); + + final client = PodmanClient(transport: transport); + final containers = await client.listContainers(); + + expect(containers, hasLength(1)); + expect(containers.first.id, 'abc123'); + expect(containers.first.image, 'hello-world:latest'); + expect(containers.first.name, 'web'); + transport.expectNoPending(); + }); + + test('creates and starts container via run', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/create', + queryParameters: const <String, List<String>>{ + 'name': <String>['web'], + }, + body: const <String, Object?>{ + 'Image': 'docker.io/library/hello-world:latest', + }, + statusCode: 201, + responseBody: const <String, Object?>{'Id': 'container-id-123'}, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/container-id-123/start', + statusCode: 204, + ); + + final client = PodmanClient(transport: transport); + final id = await client.run( + const RunOptions( + image: 'docker.io/library/hello-world:latest', + name: 'web', + ), + ); + + expect(id, 'container-id-123'); + transport.expectNoPending(); + }); + + test('inspects container details', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/containers/web/json', + responseBody: <String, Object?>{ + 'Id': 'abc', + 'Name': '/web', + 'ImageName': 'hello-world:latest', + 'State': <String, Object?>{'Status': 'running'}, + 'Config': <String, Object?>{ + 'Labels': <String, String>{'role': 'frontend'}, + }, + }, + ); + + final client = PodmanClient(transport: transport); + final details = await client.inspectContainer('web'); + + expect(details.id, 'abc'); + expect(details.name, 'web'); + expect(details.state, 'running'); + expect(details.labels['role'], 'frontend'); + transport.expectNoPending(); + }); + + test('containerExists returns false for missing container', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/containers/missing/exists', + statusCode: 404, + ); + + final client = PodmanClient(transport: transport); + final exists = await client.containerExists('missing'); + + expect(exists, isFalse); + transport.expectNoPending(); + }); + + test('supports lifecycle operations', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/web/stop', + queryParameters: const <String, List<String>>{ + 't': <String>['10'], + }, + statusCode: 204, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/web/start', + statusCode: 204, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/web/restart', + queryParameters: const <String, List<String>>{ + 't': <String>['5'], + }, + statusCode: 204, + ) + ..enqueue( + method: HttpMethod.delete, + path: '/v5.0.0/libpod/containers/web', + queryParameters: const <String, List<String>>{ + 'force': <String>['true'], + 'v': <String>['true'], + }, + statusCode: 204, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/prune', + statusCode: 200, + responseBody: const <String, Object?>{}, + ); + + final client = PodmanClient(transport: transport); + await client.stop('web', timeoutSeconds: 10); + await client.start('web'); + await client.restart('web', timeoutSeconds: 5); + await client.removeContainer('web', force: true, removeVolumes: true); + await client.pruneContainers(); + + transport.expectNoPending(); + }); + + test('supports logs query options and log polling', () async { + final since = DateTime.utc(2026, 4, 6, 10, 0, 0); + final until = DateTime.utc(2026, 4, 6, 11, 0, 0); + + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/containers/web/logs', + queryParameters: <String, List<String>>{ + 'stdout': <String>['true'], + 'stderr': <String>['false'], + 'timestamps': <String>['true'], + 'since': <String>[since.toIso8601String()], + 'until': <String>[until.toIso8601String()], + 'tail': <String>['25'], + }, + responseBody: 'log-one\n', + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/containers/web/logs', + queryParameters: <String, List<String>>{ + 'stdout': <String>['true'], + 'stderr': <String>['true'], + 'timestamps': <String>['true'], + 'since': <String>[since.toIso8601String()], + 'tail': <String>['10'], + }, + responseBody: 'log-two\n', + ); + + final client = PodmanClient(transport: transport); + final logs = await client.logs( + 'web', + tail: 25, + since: since, + until: until, + timestamps: true, + stdout: true, + stderr: false, + ); + expect(logs, 'log-one\n'); + + final chunk = await client + .watchLogs( + 'web', + since: since, + tail: 10, + pollInterval: const Duration(days: 1), + reconnect: false, + ) + .first; + expect(chunk, 'log-two\n'); + + transport.expectNoPending(); + }); + + test('throws on unexpected API status', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/containers/web/start', + statusCode: 500, + responseBody: const <String, Object?>{'message': 'boom'}, + ); + + final client = PodmanClient(transport: transport); + + await expectLater( + client.start('web'), + throwsA(isA<PodmanApiException>()), + ); + }); + }); +} diff --git a/test/podman_client_events_test.dart b/test/podman_client_events_test.dart new file mode 100644 index 0000000..354cf30 --- /dev/null +++ b/test/podman_client_events_test.dart @@ -0,0 +1,87 @@ +import 'package:podman/podman.dart'; +import 'package:test/test.dart'; + +import 'support/fake_podman_transport.dart'; + +void main() { + group('PodmanClient events API', () { + test('lists events with filters and since/until', () async { + final since = DateTime.fromMillisecondsSinceEpoch( + 1700000000000, + isUtc: true, + ); + final until = DateTime.fromMillisecondsSinceEpoch( + 1700000060000, + isUtc: true, + ); + + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/events', + queryParameters: const <String, List<String>>{ + 'stream': <String>['false'], + 'since': <String>['1700000000'], + 'until': <String>['1700000060'], + 'filters': <String>['type=container', 'event=start'], + }, + responseBody: + '{"Type":"container","Action":"start","status":"start","id":"c1","time":1700000001,"timeNano":1700000001000000000,"Actor":{"ID":"c1","Attributes":{"name":"web"}}}\n' + '{"Type":"container","Action":"stop","status":"stop","id":"c1","time":1700000059,"timeNano":1700000059000000000,"Actor":{"ID":"c1","Attributes":{"name":"web"}}}\n', + ); + + final client = PodmanClient(transport: transport); + final events = await client.listEvents( + since: since, + until: until, + filters: const <PodmanEventFilter>[ + PodmanEventFilter('type', 'container'), + PodmanEventFilter('event', 'start'), + ], + ); + + expect(events, hasLength(2)); + expect(events.first.type, 'container'); + expect(events.first.action, 'start'); + expect(events.first.name, 'web'); + transport.expectNoPending(); + }); + + test('watchEvents reconnects and emits events', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/events', + queryParameters: const <String, List<String>>{ + 'stream': <String>['false'], + }, + statusCode: 500, + responseBody: const <String, Object?>{'message': 'temporary'}, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/events', + queryParameters: const <String, List<String>>{ + 'stream': <String>['false'], + }, + responseBody: + '{"Type":"container","Action":"start","status":"start","id":"c1","time":1700000100,"timeNano":1700000100000000000,"Actor":{"ID":"c1","Attributes":{"name":"web"}}}\n', + ); + + final client = PodmanClient(transport: transport); + + final first = await client + .watchEvents( + reconnect: true, + reconnectDelay: Duration.zero, + pollInterval: const Duration(days: 1), + ) + .first + .timeout(const Duration(seconds: 2)); + + expect(first.id, 'c1'); + expect(first.action, 'start'); + transport.expectNoPending(); + }); + }); +} diff --git a/test/podman_client_images_test.dart b/test/podman_client_images_test.dart new file mode 100644 index 0000000..1d848f9 --- /dev/null +++ b/test/podman_client_images_test.dart @@ -0,0 +1,299 @@ +import 'package:podman/podman.dart'; +import 'package:test/test.dart'; + +import 'support/fake_podman_transport.dart'; + +void main() { + group('PodmanClient image API', () { + test('lists images', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/images/json', + queryParameters: const <String, List<String>>{ + 'all': <String>['false'], + }, + responseBody: <Object?>[ + <String, Object?>{ + 'Id': 'sha256:abc', + 'Repository': 'hello-world', + 'Tag': 'latest', + 'Digest': 'sha256:def', + 'Size': '123 MB', + }, + ], + ); + + final client = PodmanClient(transport: transport); + final images = await client.listImages(); + + expect(images, hasLength(1)); + expect(images.first.id, 'sha256:abc'); + expect(images.first.reference, 'hello-world:latest'); + transport.expectNoPending(); + }); + + test('pull returns response body', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/images/pull', + queryParameters: const <String, List<String>>{ + 'reference': <String>['hello-world:latest'], + 'quiet': <String>['true'], + }, + responseBody: 'pull complete', + ); + + final client = PodmanClient(transport: transport); + final output = await client.pull('hello-world:latest', quiet: true); + + expect(output, 'pull complete'); + transport.expectNoPending(); + }); + + test('image exists false on 404', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/images/missing%3Alatest/exists', + statusCode: 404, + ); + + final client = PodmanClient(transport: transport); + final exists = await client.imageExists('missing:latest'); + + expect(exists, isFalse); + transport.expectNoPending(); + }); + + test('supports remove, tag, and prune', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.delete, + path: '/v5.0.0/libpod/images/hello-world%3Alatest', + queryParameters: const <String, List<String>>{ + 'force': <String>['true'], + }, + statusCode: 200, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/images/hello-world%3Alatest/tag', + queryParameters: const <String, List<String>>{ + 'repo': <String>['local/hello-world'], + 'tag': <String>['test'], + }, + statusCode: 201, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/images/prune', + queryParameters: const <String, List<String>>{ + 'all': <String>['true'], + }, + statusCode: 200, + responseBody: const <Object?>[], + ); + + final client = PodmanClient(transport: transport); + await client.removeImage('hello-world:latest', force: true); + await client.tagImage('hello-world:latest', 'local/hello-world:test'); + await client.pruneImages(all: true); + + transport.expectNoPending(); + }); + + test( + 'supports inspect, history, tree, push, load/import/export, and batch remove', + () async { + final archiveBytes = <int>[1, 2, 3, 4]; + final exportBytes = <int>[9, 8, 7]; + + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/images/hello-world%3Alatest/json', + responseBody: const <String, Object?>{ + 'Id': 'sha256:abc', + 'Digest': 'sha256:def', + 'RepoTags': <String>['hello-world:latest'], + 'RepoDigests': <String>['hello-world@sha256:def'], + 'Created': '2026-04-01T00:00:00Z', + 'Size': 1234, + }, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/images/hello-world%3Alatest/history', + responseBody: <Object?>[ + <String, Object?>{ + 'Id': 'sha256:layer1', + 'Created': 1712361600, + 'CreatedBy': '/bin/sh -c echo hi', + 'Tags': <String>['hello-world:latest'], + 'Size': 42, + 'Comment': 'base layer', + }, + ], + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/images/hello-world%3Alatest/tree', + queryParameters: const <String, List<String>>{ + 'whatrequires': <String>['true'], + }, + responseBody: const <String, Object?>{ + 'Tree': 'hello-world:latest\n└── scratch', + }, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/images/hello-world%3Alatest/push', + queryParameters: const <String, List<String>>{ + 'destination': <String>['quay.io/groupware/hello-world:latest'], + 'retry': <String>['3'], + 'retryDelay': <String>['1s'], + 'tlsVerify': <String>['false'], + 'quiet': <String>['false'], + }, + responseBody: + '{"stream":"copying"}\n' + '{"manifestdigest":"sha256:push"}\n', + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/images/load', + body: archiveBytes, + responseBody: const <String, Object?>{ + 'Names': <String>['hello-world:latest'], + }, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/images/load', + queryParameters: const <String, List<String>>{ + 'path': <String>['/tmp/image.tar'], + }, + responseBody: const <String, Object?>{ + 'Names': <String>['hello-world:latest'], + }, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/images/import', + queryParameters: const <String, List<String>>{ + 'reference': <String>['quay.io/groupware/imported:latest'], + 'message': <String>['import image'], + 'changes': <String>['CMD ["/bin/sh"]'], + }, + body: archiveBytes, + responseBody: const <String, Object?>{'Id': 'sha256:imported'}, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/images/hello-world%3Alatest/get', + queryParameters: const <String, List<String>>{ + 'compress': <String>['true'], + 'format': <String>['docker-archive'], + }, + responseBody: exportBytes, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/images/export', + queryParameters: const <String, List<String>>{ + 'format': <String>['docker-archive'], + 'references': <String>['hello-world:latest', 'busybox:latest'], + }, + responseBody: exportBytes, + ) + ..enqueue( + method: HttpMethod.delete, + path: '/v5.0.0/libpod/images/remove', + queryParameters: const <String, List<String>>{ + 'images': <String>['hello-world:latest'], + 'ignore': <String>['true'], + }, + responseBody: const <String, Object?>{ + 'Deleted': <String>['sha256:def'], + 'Untagged': <String>['hello-world:latest'], + 'ExitCode': 0, + 'Errors': <String>[], + }, + ); + + final client = PodmanClient(transport: transport); + + final details = await client.inspectImage('hello-world:latest'); + expect(details.id, 'sha256:abc'); + + final history = await client.imageHistory('hello-world:latest'); + expect(history, hasLength(1)); + expect(history.first.id, 'sha256:layer1'); + + final tree = await client.imageTree( + 'hello-world:latest', + whatRequires: true, + ); + expect(tree.tree, contains('hello-world:latest')); + + final pushEvents = await client.pushImage( + 'hello-world:latest', + options: const ImagePushOptions( + destination: 'quay.io/groupware/hello-world:latest', + retry: 3, + retryDelay: '1s', + tlsVerify: false, + quiet: false, + ), + ); + expect(pushEvents, hasLength(2)); + expect(pushEvents.last.manifestDigest, 'sha256:push'); + + final loaded = await client.loadImages(archiveBytes); + expect(loaded.names, contains('hello-world:latest')); + + final loadedFromPath = await client.loadImagesFromPath( + '/tmp/image.tar', + ); + expect(loadedFromPath.names, contains('hello-world:latest')); + + final imported = await client.importImage( + options: const ImageImportOptions( + reference: 'quay.io/groupware/imported:latest', + message: 'import image', + changes: <String>['CMD ["/bin/sh"]'], + ), + archiveBytes: archiveBytes, + ); + expect(imported.id, 'sha256:imported'); + + final exported = await client.exportImage( + 'hello-world:latest', + options: const ImageExportOptions( + compress: true, + format: 'docker-archive', + ), + ); + expect(exported, exportBytes); + + final exportedMulti = await client.exportImages(const <String>[ + 'hello-world:latest', + 'busybox:latest', + ], options: const ImageExportOptions(format: 'docker-archive')); + expect(exportedMulti, exportBytes); + + final removed = await client.removeImages( + const ImageRemoveOptions( + images: <String>['hello-world:latest'], + ignore: true, + ), + ); + expect(removed.deleted, contains('sha256:def')); + + transport.expectNoPending(); + }, + ); + }); +} diff --git a/test/podman_client_kube_test.dart b/test/podman_client_kube_test.dart new file mode 100644 index 0000000..9e73d3d --- /dev/null +++ b/test/podman_client_kube_test.dart @@ -0,0 +1,111 @@ +import 'package:podman/podman.dart'; +import 'package:test/test.dart'; + +import 'support/fake_podman_transport.dart'; + +void main() { + group('PodmanClient kube/generate API', () { + test('supports play kube up/down', () async { + const yaml = ''' +apiVersion: v1 +kind: Pod +metadata: + name: gw +spec: + containers: + - name: app + image: docker.io/library/hello-world:latest +'''; + + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/play/kube', + queryParameters: const <String, List<String>>{ + 'replace': <String>['true'], + 'start': <String>['true'], + 'tlsVerify': <String>['false'], + }, + body: yaml, + statusCode: 200, + responseBody: const <String, Object?>{ + 'Pods': <String>['gw'], + 'Volumes': <String>[], + 'Secrets': <String>[], + 'ServiceContainerID': 'svc-1', + }, + ) + ..enqueue( + method: HttpMethod.delete, + path: '/v5.0.0/libpod/play/kube', + queryParameters: const <String, List<String>>{ + 'force': <String>['true'], + }, + body: yaml, + statusCode: 200, + responseBody: const <String, Object?>{ + 'Pods': <String>['gw'], + 'Volumes': <String>[], + 'Secrets': <String>[], + 'ServiceContainerID': '', + }, + ); + + final client = PodmanClient(transport: transport); + + final upReport = await client.playKube( + yaml, + options: const PlayKubeOptions( + replace: true, + start: true, + tlsVerify: false, + ), + ); + expect(upReport.pods, contains('gw')); + + final downReport = await client.playKubeDown(yaml, force: true); + expect(downReport.pods, contains('gw')); + + transport.expectNoPending(); + }); + + test('supports generate kube and generate systemd', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/generate/kube', + queryParameters: const <String, List<String>>{ + 'names': <String>['gw'], + 'service': <String>['true'], + }, + statusCode: 200, + responseBody: 'apiVersion: v1\nkind: Pod\nmetadata:\n name: gw\n', + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/generate/gw/systemd', + queryParameters: const <String, List<String>>{ + 'useName': <String>['true'], + }, + statusCode: 200, + responseBody: const <String, Object?>{ + 'container-gw.service': '[Unit]\nDescription=gw\n', + }, + ); + + final client = PodmanClient(transport: transport); + + final kubeYaml = await client.generateKube( + const GenerateKubeOptions(names: <String>['gw'], service: true), + ); + expect(kubeYaml, contains('kind: Pod')); + + final systemd = await client.generateSystemd( + 'gw', + options: const GenerateSystemdOptions(useName: true), + ); + expect(systemd.units.keys, contains('container-gw.service')); + transport.expectNoPending(); + }); + }); +} diff --git a/test/podman_client_local_test.dart b/test/podman_client_local_test.dart new file mode 100644 index 0000000..42ac03e --- /dev/null +++ b/test/podman_client_local_test.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +import 'package:podman/podman.dart'; +import 'package:test/test.dart'; + +import 'support/local_test_config.dart'; + +const String _helloWorldImage = 'docker.io/library/hello-world:latest'; + +void main() { + final skip = useLocalPodmanTests ? false : localTestsSkipReason; + + group('PodmanClient local API', tags: const <String>['local'], () { + test('reads version and info from local podman', skip: skip, () async { + final client = createLocalTestClient(); + addTearDown(client.close); + + final version = await client.version(); + final info = await client.info(); + + expect(version.serverVersion, isNotEmpty); + expect(info.hostOs, isNotEmpty); + }); + + test('pulls and runs hello-world', skip: skip, () async { + final client = createLocalTestClient(); + String? containerId; + final containerName = _uniqueContainerName(); + + try { + if (!await client.imageExists(_helloWorldImage)) { + await client.pull(_helloWorldImage, quiet: true); + } + + containerId = await client.run( + RunOptions(image: _helloWorldImage, name: containerName), + ); + + await _waitForContainerExit(client, containerId); + final logs = await client.logs(containerId, tail: 200); + expect(logs.toLowerCase(), contains('hello from')); + } finally { + final cleanupTarget = containerId ?? containerName; + await client.removeContainer( + cleanupTarget, + force: true, + removeVolumes: true, + ignoreMissing: true, + ); + await client.close(); + } + }); + }); +} + +Future<void> _waitForContainerExit( + PodmanClient client, + String container, { + Duration timeout = const Duration(seconds: 20), + Duration pollInterval = const Duration(milliseconds: 250), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + final details = await client.inspectContainer(container); + final state = details.state.toLowerCase(); + + if (state == 'exited' || state == 'stopped') { + return; + } + + await Future<void>.delayed(pollInterval); + } + + fail('Timed out waiting for container `$container` to exit.'); +} + +String _uniqueContainerName() => + 'podman_local_hello_world_${DateTime.now().millisecondsSinceEpoch}'; diff --git a/test/podman_client_manifests_test.dart b/test/podman_client_manifests_test.dart new file mode 100644 index 0000000..d3fc486 --- /dev/null +++ b/test/podman_client_manifests_test.dart @@ -0,0 +1,100 @@ +import 'package:podman/podman.dart'; +import 'package:test/test.dart'; + +import 'support/fake_podman_transport.dart'; + +void main() { + group('PodmanClient manifests API', () { + test('supports create, exists, inspect, push, and delete', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/manifests/groupware-stack', + queryParameters: const <String, List<String>>{ + 'images': <String>['groupware-api:latest', 'groupware-web:latest'], + 'all': <String>['true'], + 'annotation': <String>[ + 'org.opencontainers.image.title=groupware-stack', + ], + }, + statusCode: 201, + responseBody: const <String, Object?>{'Id': 'manifest-id-1'}, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/manifests/groupware-stack/exists', + statusCode: 204, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/manifests/groupware-stack/json', + queryParameters: const <String, List<String>>{ + 'tlsVerify': <String>['true'], + }, + responseBody: const <String, Object?>{ + 'schemaVersion': 2, + 'mediaType': 'application/vnd.oci.image.index.v1+json', + 'manifests': <Object?>[], + }, + ) + ..enqueue( + method: HttpMethod.post, + path: + '/v5.0.0/libpod/manifests/groupware-stack/registry/quay.io%2Fgroupware%2Fstack%3Alatest', + queryParameters: const <String, List<String>>{ + 'all': <String>['true'], + 'quiet': <String>['true'], + 'tlsVerify': <String>['false'], + }, + statusCode: 200, + responseBody: const <String, Object?>{'Id': 'sha256:manifest-digest'}, + ) + ..enqueue( + method: HttpMethod.delete, + path: '/v5.0.0/libpod/manifests/groupware-stack', + statusCode: 200, + responseBody: const <String, Object?>{ + 'Deleted': <String>['manifest-id-1'], + 'Untagged': <String>['localhost/groupware-stack:latest'], + 'ExitCode': 0, + 'Errors': <String>[], + }, + ); + + final client = PodmanClient(transport: transport); + + final created = await client.createManifest( + 'groupware-stack', + options: const ManifestCreateOptions( + images: <String>['groupware-api:latest', 'groupware-web:latest'], + all: true, + annotations: <String, String>{ + 'org.opencontainers.image.title': 'groupware-stack', + }, + ), + ); + expect(created.id, 'manifest-id-1'); + + final exists = await client.manifestExists('groupware-stack'); + expect(exists, isTrue); + + final details = await client.inspectManifest( + 'groupware-stack', + tlsVerify: true, + ); + expect(details.schemaVersion, 2); + expect(details.mediaType, 'application/vnd.oci.image.index.v1+json'); + + final pushed = await client.pushManifest( + 'groupware-stack', + 'quay.io/groupware/stack:latest', + tlsVerify: false, + ); + expect(pushed.id, 'sha256:manifest-digest'); + + final deleted = await client.deleteManifest('groupware-stack'); + expect(deleted.deleted, contains('manifest-id-1')); + transport.expectNoPending(); + }); + }); +} diff --git a/test/podman_client_networks_test.dart b/test/podman_client_networks_test.dart new file mode 100644 index 0000000..f347caf --- /dev/null +++ b/test/podman_client_networks_test.dart @@ -0,0 +1,218 @@ +import 'package:podman/podman.dart'; +import 'package:test/test.dart'; + +import 'support/fake_podman_transport.dart'; + +void main() { + group('PodmanClient network API', () { + test('checks whether a network exists', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/networks/groupware/exists', + statusCode: 204, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/networks/missing/exists', + statusCode: 404, + ); + + final client = PodmanClient(transport: transport); + + expect(await client.networkExists('groupware'), isTrue); + expect(await client.networkExists('missing'), isFalse); + transport.expectNoPending(); + }); + + test('lists networks', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/networks/json', + responseBody: <Object?>[ + <String, Object?>{ + 'id': 'net-1', + 'name': 'groupware', + 'driver': 'bridge', + 'internal': false, + 'dns_enabled': true, + 'subnets': <Object?>[ + <String, Object?>{ + 'subnet': '10.40.0.0/24', + 'gateway': '10.40.0.1', + }, + ], + 'labels': <String, String>{'stack': 'groupware'}, + }, + ], + ); + + final client = PodmanClient(transport: transport); + final networks = await client.listNetworks(); + + expect(networks, hasLength(1)); + expect(networks.first.id, 'net-1'); + expect(networks.first.name, 'groupware'); + expect(networks.first.driver, 'bridge'); + expect(networks.first.labels['stack'], 'groupware'); + expect(networks.first.subnets.first.subnet, '10.40.0.0/24'); + transport.expectNoPending(); + }); + + test('supports create, inspect, connect, disconnect, and remove', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/networks/create', + body: const <String, Object?>{ + 'name': 'groupware', + 'driver': 'bridge', + 'internal': false, + 'dns_enabled': true, + 'labels': <String, String>{'stack': 'groupware'}, + 'subnets': <Object?>[ + <String, Object?>{ + 'subnet': '10.40.0.0/24', + 'gateway': '10.40.0.1', + }, + ], + }, + statusCode: 200, + responseBody: <String, Object?>{ + 'id': 'net-1', + 'name': 'groupware', + 'driver': 'bridge', + 'internal': false, + 'dns_enabled': true, + 'labels': <String, String>{'stack': 'groupware'}, + 'subnets': <Object?>[ + <String, Object?>{ + 'subnet': '10.40.0.0/24', + 'gateway': '10.40.0.1', + }, + ], + }, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/networks/groupware/json', + responseBody: <String, Object?>{ + 'id': 'net-1', + 'name': 'groupware', + 'driver': 'bridge', + 'internal': false, + 'dns_enabled': true, + }, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/networks/groupware/connect', + body: const <String, Object?>{ + 'Container': 'orchestrator', + 'Aliases': <String>['orch'], + }, + statusCode: 200, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/networks/groupware/disconnect', + body: const <String, Object?>{ + 'Container': 'orchestrator', + 'Force': true, + }, + statusCode: 200, + ) + ..enqueue( + method: HttpMethod.delete, + path: '/v5.0.0/libpod/networks/groupware', + statusCode: 200, + ); + + final client = PodmanClient(transport: transport); + + final created = await client.createNetwork( + const NetworkCreateOptions( + name: 'groupware', + internal: false, + dnsEnabled: true, + labels: <String, String>{'stack': 'groupware'}, + subnet: '10.40.0.0/24', + gateway: '10.40.0.1', + ), + ); + expect(created.id, 'net-1'); + + final inspected = await client.inspectNetwork('groupware'); + expect(inspected.name, 'groupware'); + + await client.connectNetwork( + 'groupware', + const NetworkConnectOptions( + container: 'orchestrator', + aliases: <String>['orch'], + ), + ); + + await client.disconnectNetwork( + 'groupware', + container: 'orchestrator', + force: true, + ); + + await client.removeNetwork('groupware'); + transport.expectNoPending(); + }); + + test('supports network update and prune endpoints', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/networks/groupware/update', + body: const <String, Object?>{ + 'adddnsservers': <String>['1.1.1.1'], + 'removednsservers': <String>['9.9.9.9'], + }, + statusCode: 204, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/networks/prune', + queryParameters: const <String, List<String>>{ + 'filters': <String>['{"until":["24h"]}'], + }, + responseBody: const <Object?>[ + <String, Object?>{'Name': 'groupware-old'}, + <String, Object?>{ + 'Name': 'groupware-in-use', + 'Error': 'network is in use', + }, + ], + ); + + final client = PodmanClient(transport: transport); + + await client.updateNetwork( + 'groupware', + const NetworkUpdateOptions( + addDnsServers: <String>['1.1.1.1'], + removeDnsServers: <String>['9.9.9.9'], + ), + ); + + final reports = await client.pruneNetworks( + options: const NetworkPruneOptions( + filters: <String, List<String>>{ + 'until': <String>['24h'], + }, + ), + ); + + expect(reports, hasLength(2)); + expect(reports.first.name, 'groupware-old'); + expect(reports.last.error, contains('in use')); + + transport.expectNoPending(); + }); + }); +} diff --git a/test/podman_client_pods_test.dart b/test/podman_client_pods_test.dart new file mode 100644 index 0000000..b253e34 --- /dev/null +++ b/test/podman_client_pods_test.dart @@ -0,0 +1,285 @@ +import 'package:podman/podman.dart'; +import 'package:test/test.dart'; + +import 'support/fake_podman_transport.dart'; + +void main() { + group('PodmanClient pod API', () { + test('checks whether a pod exists', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/pods/groupware/exists', + statusCode: 204, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/pods/missing/exists', + statusCode: 404, + ); + + final client = PodmanClient(transport: transport); + + expect(await client.podExists('groupware'), isTrue); + expect(await client.podExists('missing'), isFalse); + transport.expectNoPending(); + }); + + test('lists pods', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/pods/json', + responseBody: <Object?>[ + <String, Object?>{ + 'Id': 'pod-1', + 'Name': 'groupware', + 'Status': 'Running', + 'Cgroup': 'user.slice', + 'NumberOfContainers': 3, + 'Labels': <String, String>{'stack': 'groupware'}, + }, + ], + ); + + final client = PodmanClient(transport: transport); + final pods = await client.listPods(); + + expect(pods, hasLength(1)); + expect(pods.first.id, 'pod-1'); + expect(pods.first.name, 'groupware'); + expect(pods.first.containers, 3); + transport.expectNoPending(); + }); + + test('supports create, inspect, start, stop, and remove', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/pods/create', + body: const <String, Object?>{ + 'name': 'groupware', + 'labels': <String, String>{'stack': 'groupware'}, + 'infra': true, + }, + statusCode: 201, + responseBody: const <String, Object?>{'Id': 'pod-1'}, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/pods/pod-1/json', + responseBody: const <String, Object?>{ + 'Id': 'pod-1', + 'Name': 'groupware', + 'State': 'Created', + 'Containers': <Object?>[], + 'Labels': <String, String>{'stack': 'groupware'}, + }, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/pods/groupware/json', + responseBody: const <String, Object?>{ + 'Id': 'pod-1', + 'Name': 'groupware', + 'State': 'Created', + 'Containers': <Object?>[], + }, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/pods/groupware/start', + statusCode: 200, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/pods/groupware/stop', + queryParameters: const <String, List<String>>{ + 't': <String>['3'], + }, + statusCode: 200, + ) + ..enqueue( + method: HttpMethod.delete, + path: '/v5.0.0/libpod/pods/groupware', + queryParameters: const <String, List<String>>{ + 'force': <String>['true'], + }, + statusCode: 200, + ); + + final client = PodmanClient(transport: transport); + + final created = await client.createPod( + const PodCreateOptions( + name: 'groupware', + labels: <String, String>{'stack': 'groupware'}, + ), + ); + expect(created.id, 'pod-1'); + + final inspected = await client.inspectPod('groupware'); + expect(inspected.name, 'groupware'); + + await client.startPod('groupware'); + await client.stopPod('groupware', timeoutSeconds: 3); + await client.removePod('groupware', force: true); + transport.expectNoPending(); + }); + + test('supports pod admin, top, stats, and prune endpoints', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/pods/groupware/kill', + queryParameters: const <String, List<String>>{ + 'signal': <String>['SIGTERM'], + }, + statusCode: 200, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/pods/groupware/pause', + statusCode: 200, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/pods/groupware/restart', + statusCode: 200, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/pods/groupware/unpause', + statusCode: 200, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/pods/groupware/top', + queryParameters: const <String, List<String>>{ + 'ps_args': <String>['aux'], + }, + responseBody: const <String, Object?>{ + 'Titles': <String>['PID', 'USER', 'COMMAND'], + 'Processes': <Object?>[ + <Object?>['1', 'root', '/pause'], + ], + }, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/pods/stats', + queryParameters: const <String, List<String>>{ + 'namesOrIDs': <String>['groupware'], + }, + responseBody: const <Object?>[ + <String, Object?>{ + 'Pod': 'pod-1', + 'CID': 'ctr-1', + 'Name': 'groupware', + 'CPU': '2.5%', + 'MemUsage': '12MiB / 1GiB', + 'MemUsageBytes': '12582912 / 1073741824', + 'Mem': '1.2%', + 'NetIO': '5kB / 2kB', + 'BlockIO': '0B / 0B', + 'PIDS': '4', + }, + ], + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/pods/prune', + responseBody: const <Object?>[ + <String, Object?>{'Id': 'pod-old'}, + ], + ); + + final client = PodmanClient(transport: transport); + + await client.killPod('groupware', signal: 'SIGTERM'); + await client.pausePod('groupware'); + await client.restartPod('groupware'); + await client.unpausePod('groupware'); + + final top = await client.topPod( + 'groupware', + options: const PodTopOptions(psArgs: 'aux'), + ); + expect(top.titles, <String>['PID', 'USER', 'COMMAND']); + expect(top.processes, <List<String>>[ + <String>['1', 'root', '/pause'], + ]); + + final stats = await client.podStats( + options: const PodStatsOptions(namesOrIds: <String>['groupware']), + ); + expect(stats, hasLength(1)); + expect(stats.single.podId, 'pod-1'); + expect(stats.single.cpuPercent, '2.5%'); + + final pruned = await client.prunePods(); + expect(pruned, hasLength(1)); + expect(pruned.single.id, 'pod-old'); + + transport.expectNoPending(); + }); + + test('watchPodStats and watchPodTop emit polling snapshots', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/pods/stats', + queryParameters: const <String, List<String>>{ + 'namesOrIDs': <String>['groupware'], + }, + responseBody: const <Object?>[ + <String, Object?>{ + 'Pod': 'pod-1', + 'CID': 'ctr-1', + 'Name': 'groupware', + 'CPU': '1.0%', + 'MemUsage': '10MiB / 1GiB', + 'MemUsageBytes': '10485760 / 1073741824', + 'Mem': '1.0%', + 'NetIO': '1kB / 1kB', + 'BlockIO': '0B / 0B', + 'PIDS': '3', + }, + ], + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/pods/groupware/top', + responseBody: const <String, Object?>{ + 'Titles': <String>['PID', 'CMD'], + 'Processes': <Object?>[ + <Object?>['1', '/pause'], + ], + }, + ); + + final client = PodmanClient(transport: transport); + + final stats = await client + .watchPodStats( + options: const PodStatsOptions(namesOrIds: <String>['groupware']), + pollInterval: const Duration(milliseconds: 1), + reconnect: false, + ) + .first; + expect(stats, hasLength(1)); + expect(stats.single.podId, 'pod-1'); + + final top = await client + .watchPodTop( + 'groupware', + pollInterval: const Duration(milliseconds: 1), + reconnect: false, + ) + .first; + expect(top.processes, hasLength(1)); + + transport.expectNoPending(); + }); + }); +} diff --git a/test/podman_client_secrets_test.dart b/test/podman_client_secrets_test.dart new file mode 100644 index 0000000..f6239b4 --- /dev/null +++ b/test/podman_client_secrets_test.dart @@ -0,0 +1,98 @@ +import 'package:podman/podman.dart'; +import 'package:test/test.dart'; + +import 'support/fake_podman_transport.dart'; + +void main() { + group('PodmanClient secrets API', () { + test('supports create, list, inspect, exists, and remove', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/secrets/create', + queryParameters: const <String, List<String>>{ + 'name': <String>['jwt-signing-key'], + 'driver': <String>['file'], + 'driveropts': <String>['mode=0400'], + 'labels': <String>['service=auth'], + }, + body: 'super-secret', + statusCode: 200, + responseBody: const <String, Object?>{'ID': 'secret-123'}, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/secrets/json', + responseBody: <Object?>[ + <String, Object?>{ + 'ID': 'secret-123', + 'CreatedAt': '2026-01-01T00:00:00Z', + 'UpdatedAt': '2026-01-01T00:00:00Z', + 'Spec': <String, Object?>{ + 'Name': 'jwt-signing-key', + 'Driver': <String, Object?>{'Name': 'file'}, + 'Labels': <String, String>{'service': 'auth'}, + }, + }, + ], + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/secrets/jwt-signing-key/json', + queryParameters: const <String, List<String>>{ + 'showsecret': <String>['true'], + }, + responseBody: const <String, Object?>{ + 'ID': 'secret-123', + 'SecretData': 'super-secret', + 'Spec': <String, Object?>{ + 'Name': 'jwt-signing-key', + 'Driver': <String, Object?>{'Name': 'file'}, + 'Labels': <String, String>{'service': 'auth'}, + }, + }, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/secrets/jwt-signing-key/exists', + statusCode: 204, + ) + ..enqueue( + method: HttpMethod.delete, + path: '/v5.0.0/libpod/secrets/jwt-signing-key', + statusCode: 204, + ); + + final client = PodmanClient(transport: transport); + + final created = await client.createSecret( + const SecretCreateOptions( + name: 'jwt-signing-key', + data: 'super-secret', + driver: 'file', + driverOptions: <String, String>{'mode': '0400'}, + labels: <String, String>{'service': 'auth'}, + ), + ); + expect(created.id, 'secret-123'); + + final secrets = await client.listSecrets(); + expect(secrets, hasLength(1)); + expect(secrets.first.name, 'jwt-signing-key'); + expect(secrets.first.driver, 'file'); + + final details = await client.inspectSecret( + 'jwt-signing-key', + showSecret: true, + ); + expect(details.secretData, 'super-secret'); + expect(details.labels['service'], 'auth'); + + final exists = await client.secretExists('jwt-signing-key'); + expect(exists, isTrue); + + await client.removeSecret('jwt-signing-key'); + transport.expectNoPending(); + }); + }); +} diff --git a/test/podman_client_system_test.dart b/test/podman_client_system_test.dart new file mode 100644 index 0000000..407c5c1 --- /dev/null +++ b/test/podman_client_system_test.dart @@ -0,0 +1,203 @@ +import 'package:podman/podman.dart'; +import 'package:test/test.dart'; + +import 'support/fake_podman_transport.dart'; + +void main() { + group('PodmanClient system API', () { + test('parses version output', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/version', + responseBody: <String, Object?>{ + 'Client': <String, Object?>{'Version': '5.2.2'}, + 'Server': <String, Object?>{'Version': '5.2.1'}, + }, + ); + + final client = PodmanClient(transport: transport); + final version = await client.version(); + + expect(version.clientVersion, '5.2.2'); + expect(version.serverVersion, '5.2.1'); + transport.expectNoPending(); + }); + + test('parses info output', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/info', + responseBody: <String, Object?>{ + 'host': <String, Object?>{'os': 'linux', 'arch': 'amd64'}, + }, + ); + + final client = PodmanClient(transport: transport); + final info = await client.info(); + + expect(info.hostOs, 'linux'); + expect(info.hostArch, 'amd64'); + transport.expectNoPending(); + }); + + test('supports system df/check/prune', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/system/df', + responseBody: const <String, Object?>{ + 'ImagesSize': 12345, + 'Images': <Object?>[ + <String, Object?>{ + 'Repository': 'docker.io/library/hello-world', + 'Tag': 'latest', + 'ImageID': 'sha256:image1', + 'Created': '2026-01-01T00:00:00Z', + 'Size': 111, + 'SharedSize': 0, + 'UniqueSize': 111, + 'Containers': 1, + }, + ], + 'Containers': <Object?>[ + <String, Object?>{ + 'ContainerID': 'container-1', + 'Image': 'docker.io/library/hello-world:latest', + 'Command': <String>['/hello'], + 'LocalVolumes': 0, + 'Size': 222, + 'RWSize': 12, + 'Created': '2026-01-01T00:01:00Z', + 'Status': 'exited', + 'Names': 'hello', + }, + ], + 'Volumes': <Object?>[ + <String, Object?>{ + 'VolumeName': 'gv-storage', + 'Links': 1, + 'Size': 333, + 'ReclaimableSize': 111, + }, + ], + }, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/system/check', + queryParameters: const <String, List<String>>{ + 'quick': <String>['true'], + 'repair': <String>['true'], + 'repair_lossy': <String>['true'], + 'unreferenced_layer_max_age': <String>['12h'], + }, + responseBody: const <String, Object?>{ + 'Errors': true, + 'Layers': <String, Object?>{ + 'layer-1': <String>['missing digest'], + }, + 'ROLayers': <String, Object?>{}, + 'RemovedLayers': <String>['layer-old'], + 'Images': <String, Object?>{ + 'image-1': <String>['dangling data'], + }, + 'ROImages': <String, Object?>{}, + 'RemovedImages': <String, Object?>{ + 'image-1': <String>['localhost/image:old'], + }, + 'Containers': <String, Object?>{ + 'ctr-1': <String>['broken mount'], + }, + 'RemovedContainers': <String, String>{'ctr-1': 'old-container'}, + }, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/system/prune', + queryParameters: const <String, List<String>>{ + 'all': <String>['true'], + 'volumes': <String>['true'], + 'external': <String>['true'], + 'build': <String>['true'], + 'filters': <String>['{"label":["managed=true"]}'], + }, + responseBody: const <String, Object?>{ + 'PodPruneReport': <Object?>[ + <String, Object?>{'Id': 'pod-1'}, + ], + 'ContainerPruneReports': <Object?>[ + <String, Object?>{'Id': 'ctr-1', 'Size': 1024}, + ], + 'ImagePruneReports': <Object?>[ + <String, Object?>{'Id': 'img-1', 'Size': 2048}, + ], + 'NetworkPruneReports': <Object?>[ + <String, Object?>{'Name': 'net-1'}, + ], + 'VolumePruneReports': <Object?>[ + <String, Object?>{'Id': 'vol-1', 'Size': 4096}, + ], + 'ReclaimedSpace': 8192, + }, + ); + + final client = PodmanClient(transport: transport); + + final df = await client.systemDf(); + expect(df.imagesSize, 12345); + expect(df.images, hasLength(1)); + expect(df.containers, hasLength(1)); + expect(df.volumes, hasLength(1)); + expect(df.images.first.imageId, 'sha256:image1'); + expect(df.containers.first.containerId, 'container-1'); + expect(df.volumes.first.volumeName, 'gv-storage'); + + final check = await client.systemCheck( + options: const SystemCheckOptions( + quick: true, + repair: true, + repairLossy: true, + unreferencedLayerMaxAge: '12h', + ), + ); + expect(check.errors, isTrue); + expect(check.layers['layer-1'], contains('missing digest')); + expect(check.removedContainers['ctr-1'], 'old-container'); + + final prune = await client.systemPrune( + options: const SystemPruneOptions( + all: true, + volumes: true, + external: true, + build: true, + filters: <String, List<String>>{ + 'label': <String>['managed=true'], + }, + ), + ); + expect(prune.podIds, contains('pod-1')); + expect(prune.containerIds, contains('ctr-1')); + expect(prune.imageIds, contains('img-1')); + expect(prune.networkNames, contains('net-1')); + expect(prune.volumeIds, contains('vol-1')); + expect(prune.reclaimedSpace, 8192); + + transport.expectNoPending(); + }); + + test('throws parse exception on invalid JSON', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/version', + responseBody: '{invalid-json', + ); + + final client = PodmanClient(transport: transport); + + await expectLater(client.version(), throwsA(isA<PodmanParseException>())); + }); + }); +} diff --git a/test/podman_client_volumes_test.dart b/test/podman_client_volumes_test.dart new file mode 100644 index 0000000..08d9d98 --- /dev/null +++ b/test/podman_client_volumes_test.dart @@ -0,0 +1,169 @@ +import 'package:podman/podman.dart'; +import 'package:test/test.dart'; + +import 'support/fake_podman_transport.dart'; + +void main() { + group('PodmanClient volume API', () { + test('lists volumes', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/volumes/json', + responseBody: <Object?>[ + <String, Object?>{ + 'Name': 'data', + 'Driver': 'local', + 'Mountpoint': '/var/lib/containers/storage/volumes/data/_data', + 'CreatedAt': '2026-01-01T00:00:00Z', + 'Scope': 'local', + 'MountCount': 1, + 'Labels': <String, String>{'service': 'storage'}, + 'Options': <String, String>{'o': 'uid=1000'}, + }, + ], + ); + + final client = PodmanClient(transport: transport); + final volumes = await client.listVolumes(); + + expect(volumes, hasLength(1)); + expect(volumes.first.name, 'data'); + expect(volumes.first.mountCount, 1); + expect(volumes.first.labels['service'], 'storage'); + transport.expectNoPending(); + }); + + test('supports filtered list and exists', () async { + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/volumes/json', + queryParameters: const <String, List<String>>{ + 'filters': <String>['{"label":["stack=groupware"]}'], + }, + responseBody: const <Object?>[], + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/volumes/groupware-data/exists', + statusCode: 204, + ); + + final client = PodmanClient(transport: transport); + final volumes = await client.listVolumes( + options: const VolumeListOptions( + filters: <String, List<String>>{ + 'label': <String>['stack=groupware'], + }, + ), + ); + final exists = await client.volumeExists('groupware-data'); + + expect(volumes, isEmpty); + expect(exists, isTrue); + transport.expectNoPending(); + }); + + test('supports create, inspect, remove, and prune', () async { + final archiveBytes = <int>[1, 2, 3]; + final exportedBytes = <int>[9, 8, 7]; + + final transport = FakePodmanTransport() + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/volumes/create', + body: const <String, Object?>{ + 'Name': 'groupware-data', + 'Driver': 'local', + 'Labels': <String, String>{'stack': 'groupware'}, + 'Options': <String, String>{'o': 'uid=1000'}, + }, + statusCode: 201, + responseBody: const <String, Object?>{ + 'Name': 'groupware-data', + 'Driver': 'local', + 'Mountpoint': '/volumes/groupware-data', + 'Scope': 'local', + 'MountCount': 0, + 'Labels': <String, String>{'stack': 'groupware'}, + 'Options': <String, String>{'o': 'uid=1000'}, + }, + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/volumes/groupware-data/json', + responseBody: const <String, Object?>{ + 'Name': 'groupware-data', + 'Driver': 'local', + 'Mountpoint': '/volumes/groupware-data', + 'Scope': 'local', + 'MountCount': 0, + }, + ) + ..enqueue( + method: HttpMethod.delete, + path: '/v5.0.0/libpod/volumes/groupware-data', + queryParameters: const <String, List<String>>{ + 'force': <String>['true'], + }, + statusCode: 204, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/volumes/prune', + queryParameters: const <String, List<String>>{ + 'filters': <String>['{"label":["stack=groupware"]}'], + }, + statusCode: 200, + responseBody: const <Object?>[ + <String, Object?>{'Id': 'groupware-data', 'Size': 1024}, + ], + ) + ..enqueue( + method: HttpMethod.get, + path: '/v5.0.0/libpod/volumes/groupware-data/export', + statusCode: 200, + responseBody: exportedBytes, + ) + ..enqueue( + method: HttpMethod.post, + path: '/v5.0.0/libpod/volumes/groupware-data/import', + statusCode: 204, + body: archiveBytes, + ); + + final client = PodmanClient(transport: transport); + + final created = await client.createVolume( + const VolumeCreateOptions( + name: 'groupware-data', + labels: <String, String>{'stack': 'groupware'}, + options: <String, String>{'o': 'uid=1000'}, + ), + ); + expect(created.name, 'groupware-data'); + + final inspected = await client.inspectVolume('groupware-data'); + expect(inspected.driver, 'local'); + + await client.removeVolume('groupware-data', force: true); + final pruned = await client.pruneVolumes( + options: const VolumePruneOptions( + filters: <String, List<String>>{ + 'label': <String>['stack=groupware'], + }, + ), + ); + expect(pruned, hasLength(1)); + expect(pruned.first.size, 1024); + + final exported = await client.exportVolume('groupware-data'); + expect(exported, exportedBytes); + + await client.importVolume('groupware-data', archiveBytes: archiveBytes); + + transport.expectNoPending(); + }); + }); +} diff --git a/test/run_options_test.dart b/test/run_options_test.dart new file mode 100644 index 0000000..30bf16e --- /dev/null +++ b/test/run_options_test.dart @@ -0,0 +1,132 @@ +import 'package:podman/podman.dart'; +import 'package:test/test.dart'; + +void main() { + group('RunOptions', () { + test('builds minimal create body', () { + const options = RunOptions(image: 'hello-world:latest'); + + expect(options.toCreateBody(), const <String, Object?>{ + 'Image': 'hello-world:latest', + }); + }); + + test('builds rich create body deterministically', () { + const options = RunOptions( + image: 'busybox:latest', + name: 'sample', + removeWhenStopped: true, + network: 'groupware', + hostname: 'svc-1', + entrypoint: '/bin/sh', + user: '1000:1000', + workingDirectory: '/workspace', + restartPolicy: 'on-failure', + environment: <String, String>{'B': '2', 'A': '1'}, + labels: <String, String>{'tier': 'backend', 'service': 'sample'}, + ports: <PortBinding>[PortBinding.publish(8080, 80)], + mounts: <MountBinding>[ + MountBinding.bind( + source: '/host/path', + target: '/data', + readOnly: true, + ), + ], + command: <String>['echo', 'ok'], + ); + + expect(options.toCreateBody(), const <String, Object?>{ + 'Image': 'busybox:latest', + 'Cmd': <String>['echo', 'ok'], + 'Entrypoint': <String>['/bin/sh'], + 'Env': <String>['A=1', 'B=2'], + 'Labels': <String, String>{'tier': 'backend', 'service': 'sample'}, + 'Hostname': 'svc-1', + 'User': '1000:1000', + 'WorkingDir': '/workspace', + 'ExposedPorts': <String, Object?>{'80/tcp': <String, Object?>{}}, + 'HostConfig': <String, Object?>{ + 'AutoRemove': true, + 'NetworkMode': 'groupware', + 'RestartPolicy': <String, Object?>{'Name': 'on-failure'}, + 'PortBindings': <String, Object?>{ + '80/tcp': <Object?>[ + <String, String>{'HostPort': '8080'}, + ], + }, + 'Mounts': <Object?>[ + <String, Object?>{ + 'Type': 'bind', + 'Source': '/host/path', + 'Target': '/data', + 'ReadOnly': true, + }, + ], + }, + }); + }); + }); + + group('PortBinding', () { + test('apiKey serializes container port + protocol', () { + const binding = PortBinding.expose(443, protocol: 'tcp'); + expect(binding.apiKey, '443/tcp'); + expect(binding.hasHostBinding, isFalse); + }); + + test('serializes host binding entry', () { + const binding = PortBinding.publish( + 8443, + 443, + protocol: 'tcp', + hostIp: '127.0.0.1', + ); + + expect(binding.apiKey, '443/tcp'); + expect(binding.hasHostBinding, isTrue); + expect(binding.toApiBindingJson(), const <String, String>{ + 'HostIp': '127.0.0.1', + 'HostPort': '8443', + }); + }); + }); + + group('MountBinding', () { + test('serializes bind mount to API payload', () { + const mount = MountBinding.bind( + source: '/srv/data', + target: '/data', + readOnly: true, + ); + + expect(mount.toApiJson(), const <String, Object?>{ + 'Type': 'bind', + 'Source': '/srv/data', + 'Target': '/data', + 'ReadOnly': true, + }); + }); + + test('serializes volume mount to API payload', () { + const mount = MountBinding.volume( + source: 'named-volume', + target: '/data', + ); + expect(mount.toApiJson(), const <String, Object?>{ + 'Type': 'volume', + 'Source': 'named-volume', + 'Target': '/data', + 'ReadOnly': false, + }); + }); + + test('serializes tmpfs mount to API payload', () { + const mount = MountBinding.tmpfs(target: '/tmp'); + expect(mount.toApiJson(), const <String, Object?>{ + 'Type': 'tmpfs', + 'Target': '/tmp', + 'ReadOnly': false, + }); + }); + }); +} diff --git a/test/support/fake_podman_transport.dart b/test/support/fake_podman_transport.dart new file mode 100644 index 0000000..aa5b46e --- /dev/null +++ b/test/support/fake_podman_transport.dart @@ -0,0 +1,178 @@ +import 'dart:convert'; + +import 'package:podman/podman.dart'; + +final class FakePodmanTransport implements PodmanTransport { + final List<PodmanTransportRequest> sentRequests = <PodmanTransportRequest>[]; + final List<_ExpectedExchange> _queue = <_ExpectedExchange>[]; + + void enqueue({ + required HttpMethod method, + required String path, + Map<String, List<String>> queryParameters = const <String, List<String>>{}, + Object? body, + int statusCode = 200, + Map<String, List<String>> headers = const <String, List<String>>{}, + Object? responseBody, + }) { + _queue.add( + _ExpectedExchange( + expectedRequest: PodmanTransportRequest( + method: method, + path: path, + queryParameters: queryParameters, + body: body, + ), + response: PodmanTransportResponse( + statusCode: statusCode, + headers: headers, + bodyBytes: _encodeResponseBody(responseBody), + ), + ), + ); + } + + @override + Future<PodmanTransportResponse> send(PodmanTransportRequest request) async { + sentRequests.add(request); + + if (_queue.isEmpty) { + throw StateError( + 'Unexpected request with no queued response: ' + '${request.method.name.toUpperCase()} ${request.path}', + ); + } + + final expected = _queue.removeAt(0); + _assertRequestMatches(expected.expectedRequest, request); + return expected.response; + } + + @override + Future<void> close() async {} + + void expectNoPending() { + if (_queue.isNotEmpty) { + final pending = _queue + .map( + (item) => + '${item.expectedRequest.method.name.toUpperCase()} ' + '${item.expectedRequest.path}', + ) + .join('\n'); + throw StateError('Pending expected requests:\n$pending'); + } + } + + void _assertRequestMatches( + PodmanTransportRequest expected, + PodmanTransportRequest actual, + ) { + if (expected.method != actual.method || expected.path != actual.path) { + throw StateError( + 'Unexpected request line.\n' + 'Expected: ${expected.method.name.toUpperCase()} ${expected.path}\n' + 'Actual: ${actual.method.name.toUpperCase()} ${actual.path}', + ); + } + + if (!_deepEquals(expected.queryParameters, actual.queryParameters)) { + throw StateError( + 'Unexpected query parameters.\n' + 'Expected: ${expected.queryParameters}\n' + 'Actual: ${actual.queryParameters}', + ); + } + + if (!_deepEquals(expected.body, actual.body)) { + throw StateError( + 'Unexpected request body.\n' + 'Expected: ${_canonicalJson(expected.body)}\n' + 'Actual: ${_canonicalJson(actual.body)}', + ); + } + } + + List<int> _encodeResponseBody(Object? body) { + if (body == null) { + return const <int>[]; + } + if (body is String) { + return utf8.encode(body); + } + if (body is List<int>) { + return body; + } + return utf8.encode(jsonEncode(body)); + } + + bool _deepEquals(Object? left, Object? right) { + if (left == null || right == null) { + return left == right; + } + + if (left is Map && right is Map) { + if (left.length != right.length) { + return false; + } + for (final key in left.keys) { + if (!right.containsKey(key)) { + return false; + } + if (!_deepEquals(left[key], right[key])) { + return false; + } + } + return true; + } + + if (left is List && right is List) { + if (left.length != right.length) { + return false; + } + for (var i = 0; i < left.length; i++) { + if (!_deepEquals(left[i], right[i])) { + return false; + } + } + return true; + } + + return left == right; + } + + String _canonicalJson(Object? value) { + if (value == null) { + return 'null'; + } + final normalized = _normalize(value); + return jsonEncode(normalized); + } + + Object? _normalize(Object? value) { + if (value is Map) { + final keys = value.keys.map((key) => key.toString()).toList()..sort(); + final output = <String, Object?>{}; + for (final key in keys) { + output[key] = _normalize(value[key]); + } + return output; + } + + if (value is List) { + return value.map(_normalize).toList(growable: false); + } + + return value; + } +} + +final class _ExpectedExchange { + const _ExpectedExchange({ + required this.expectedRequest, + required this.response, + }); + + final PodmanTransportRequest expectedRequest; + final PodmanTransportResponse response; +} diff --git a/test/support/local_test_config.dart b/test/support/local_test_config.dart new file mode 100644 index 0000000..3b9206c --- /dev/null +++ b/test/support/local_test_config.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'package:podman/podman.dart'; + +const String localBackendValue = 'local'; + +const String _backendEnvKey = 'PODMAN_TEST_BACKEND'; +const String _localFlagEnvKey = 'PODMAN_LOCAL_TESTS'; +const String _socketEnvKey = 'PODMAN_TEST_SOCKET'; + +/// Whether tests should run against a real Podman socket. +bool get useLocalPodmanTests { + final backend = Platform.environment[_backendEnvKey]; + if (backend != null && backend.isNotEmpty) { + return backend.toLowerCase() == localBackendValue; + } + + final flag = Platform.environment[_localFlagEnvKey]; + if (flag == null || flag.isEmpty) { + return false; + } + + final normalized = flag.toLowerCase(); + return normalized == '1' || normalized == 'true' || normalized == 'yes'; +} + +/// Skip reason shown when local tests are disabled. +String get localTestsSkipReason => + 'Set $_backendEnvKey=$localBackendValue (or $_localFlagEnvKey=1) ' + 'to run local Podman integration tests.'; + +/// Creates a client for local Podman tests. +PodmanClient createLocalTestClient() { + final socketPath = Platform.environment[_socketEnvKey]; + if (socketPath != null && socketPath.isNotEmpty) { + return PodmanClient(socketPath: socketPath); + } + return PodmanClient(); +}