From 9f6284ba6f27a30b653977a2fd85d4a1d12a40e2 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Mon, 27 Jul 2026 01:32:21 +0200 Subject: [PATCH] (feature): add powershell client and e2e --- .cursor/maksit-skills.json | 3 +- .cursor/rules/maksit-skills.mdc | 1 + .editorconfig | 118 ++ .gitignore | 1 + AGENTS.md | 1 + CHANGELOG.md | 37 + CONTRIBUTING.md | 87 +- README.md | 32 +- assets/badges/coverage-branches.svg | 21 - assets/badges/coverage-lines.svg | 21 - assets/badges/coverage-methods.svg | 21 - .../Build/BuildCmdlets.cs | 186 +++ .../ConnectDisconnectCmdlets.cs | 30 + .../Containers/ContainersCmdlets.cs | 1026 +++++++++++++++++ .../Exec/ExecCmdlets.cs | 192 +++ .../Generate/GenerateCmdlets.cs | 122 ++ .../Images/ImagesCmdlets.cs | 537 +++++++++ .../MaksIT.PodmanClientDotNet.PowerShell.psd1 | 125 ++ .../Manifests/ManifestsCmdlets.cs | 164 +++ .../Networks/NetworksCmdlets.cs | 147 +++ .../PodmanClient.PowerShell.csproj | 33 + .../PodmanCmdletBase.cs | 111 ++ .../PodmanConnectionState.cs | 38 + .../Pods/PodsCmdlets.cs | 276 +++++ src/PodmanClient.PowerShell/README.md | 21 + .../System/SystemCmdlets.cs | 135 +++ .../Volumes/VolumesCmdlets.cs | 108 ++ .../Abstractions/IPodmanContainersClient.cs | 7 +- .../Abstractions/IPodmanImagesClient.cs | 6 +- .../Abstractions/IPodmanPodsClient.cs | 4 +- .../Abstractions/IPodmanSystemClient.cs | 2 +- .../Abstractions/IPodmanVolumesClient.cs | 2 +- .../Dtos/Common/PruneReportDto.cs | 19 +- .../Dtos/Container/ContainerChangesDto.cs | 14 +- .../Dtos/Container/ContainerInspectDto.cs | 11 +- .../Dtos/Container/ContainerListEntryDto.cs | 8 +- .../Dtos/Container/ContainerMountDto.cs | 8 +- .../Dtos/Container/ContainerStatsDto.cs | 89 +- .../Dtos/Container/MountedContainerDto.cs | 14 +- src/PodmanClient/Dtos/Exec/ExecModelsDto.cs | 23 +- .../Dtos/Image/ImageChangesDto.cs | 14 +- src/PodmanClient/Dtos/Image/ImageDeleteDto.cs | 9 +- .../Dtos/Image/ImageListEntryDto.cs | 17 +- src/PodmanClient/Dtos/Image/ImageTreeDto.cs | 14 +- .../Dtos/Manifest/ManifestModelsDto.cs | 34 +- src/PodmanClient/Dtos/Pod/PodModelsDto.cs | 56 +- src/PodmanClient/Dtos/System/InfoDto.cs | 108 +- .../Dtos/System/LibpodVersionDto.cs | 36 +- src/PodmanClient/Dtos/System/SystemDfDto.cs | 5 +- .../IPodmanClientConfiguration.cs | 3 +- .../Internal/PodmanHttpResults.cs | 5 +- .../Internal/PodmanNdjsonStreams.cs | 5 +- src/PodmanClient/Models/AutoUserNsOptions.cs | 2 +- src/PodmanClient/Models/BindOptions.cs | 2 +- src/PodmanClient/Models/BlockIO.cs | 2 +- src/PodmanClient/Models/CPU.cs | 2 +- .../Container/CreateContainerRequest.cs | 2 +- .../Container/CreateContainerResponse.cs | 2 +- .../Container/DeleteContainerResponse.cs | 2 +- src/PodmanClient/Models/DriverConfig.cs | 2 +- src/PodmanClient/Models/ErrorResponse.cs | 2 +- .../Models/Exec/CreateExecRequest.cs | 2 +- .../Models/Exec/CreateExecResponse.cs | 2 +- .../Models/Exec/InspectExecResponse.cs | 26 +- .../Models/Exec/StartExecRequest.cs | 2 +- src/PodmanClient/Models/HugepageLimit.cs | 2 +- src/PodmanClient/Models/IDMapping.cs | 2 +- src/PodmanClient/Models/IDMappingOptions.cs | 2 +- .../Models/Image/PullImageResponse.cs | 37 +- src/PodmanClient/Models/ImageVolume.cs | 2 +- src/PodmanClient/Models/IntelRdt.cs | 2 +- src/PodmanClient/Models/LinuxDevice.cs | 2 +- src/PodmanClient/Models/LinuxDeviceCgroup.cs | 2 +- src/PodmanClient/Models/LinuxIntelRdt.cs | 2 +- src/PodmanClient/Models/LinuxPersonality.cs | 2 +- src/PodmanClient/Models/LinuxResources.cs | 2 +- src/PodmanClient/Models/LogConfigLibpod.cs | 2 +- src/PodmanClient/Models/Memory.cs | 2 +- src/PodmanClient/Models/Mount.cs | 2 +- src/PodmanClient/Models/NamedVolume.cs | 2 +- src/PodmanClient/Models/Namespace.cs | 2 +- src/PodmanClient/Models/NetworkPriority.cs | 2 +- src/PodmanClient/Models/NetworkSettings.cs | 2 +- src/PodmanClient/Models/OverlayVolume.cs | 2 +- src/PodmanClient/Models/POSIXRlimit.cs | 2 +- src/PodmanClient/Models/Pids.cs | 2 +- src/PodmanClient/Models/PortMapping.cs | 2 +- src/PodmanClient/Models/ProgressDetail.cs | 2 +- src/PodmanClient/Models/RdmaResource.cs | 2 +- .../Models/Schema2HealthConfig.cs | 2 +- src/PodmanClient/Models/SecretProp.cs | 2 +- .../Models/StartupHealthConfig.cs | 2 +- src/PodmanClient/Models/ThrottleDevice.cs | 2 +- src/PodmanClient/Models/TmpfsOptions.cs | 2 +- src/PodmanClient/Models/VolumeOptions.cs | 2 +- src/PodmanClient/Models/WeightDevice.cs | 2 +- src/PodmanClient/PodmanClient.Container.cs | 9 +- .../PodmanClient.Containers.Api.cs | 65 +- src/PodmanClient/PodmanClient.Exec.cs | 7 +- src/PodmanClient/PodmanClient.Generate.cs | 3 +- src/PodmanClient/PodmanClient.Http.cs | 1 - src/PodmanClient/PodmanClient.Images.Api.cs | 17 +- src/PodmanClient/PodmanClient.Manifests.cs | 34 +- src/PodmanClient/PodmanClient.Pods.cs | 8 +- src/PodmanClient/PodmanClient.Streaming.cs | 6 +- src/PodmanClient/PodmanClient.System.cs | 14 +- src/PodmanClient/PodmanClient.Volumes.cs | 4 +- src/PodmanClient/PodmanClient.cs | 7 +- src/PodmanClient/PodmanClientDotNet.csproj | 2 +- src/PodmanClient/PodmanJsonContext.cs | 17 +- src/PodmanClientDotNet.Tests/Archives/Tar.cs | 43 - .../InspectExecResponseDtoTests.cs | 40 + .../PodmanClientContainersTests.cs | 126 -- .../PodmanClientExecTests.cs | 127 -- .../PodmanClientImagesTests.cs | 38 - .../PodmanClientStreamingIntegrationTests.cs | 128 -- .../PodmanClientTestFixture.cs | 42 - .../Streaming/PodmanNdjsonStreamsTests.cs | 2 - src/PodmanClientDotNet.slnx | 1 + src/e2e-tests/Podman.E2E.Common.ps1 | 77 ++ src/e2e-tests/Test-PodmanE2E.bat | 18 + src/e2e-tests/Test-PodmanE2E.ps1 | 172 +++ .../scenarios/Scenario-01-System.ps1 | 40 + .../scenarios/Scenario-02-Images.ps1 | 139 +++ .../Scenario-03-ContainersLifecycle.ps1 | 229 ++++ .../Scenario-04-ContainersArchiveAttach.ps1 | 98 ++ src/e2e-tests/scenarios/Scenario-05-Exec.ps1 | 76 ++ .../scenarios/Scenario-06-Volumes.ps1 | 48 + .../scenarios/Scenario-07-Networks.ps1 | 73 ++ src/e2e-tests/scenarios/Scenario-08-Pods.ps1 | 138 +++ src/e2e-tests/scenarios/Scenario-09-Build.ps1 | 52 + .../scenarios/Scenario-10-Manifests.ps1 | 63 + .../scenarios/Scenario-11-Generate.ps1 | 94 ++ utils/Invoke-ReleasePackage.bat | 5 +- utils/Invoke-TestEngine.bat | 5 +- .../engines/release/Invoke-ReleasePackage.ps1 | 44 +- utils/engines/release/scriptSettings.json | 9 +- utils/engines/test/scriptSettings.json | 14 +- utils/modules/Engine/EngineContext.psm1 | 85 +- utils/modules/Engine/PluginSupport.psm1 | 378 +++++- utils/modules/Engine/ReleaseSupport.psm1 | 29 +- utils/modules/Engine/TestSupport.psm1 | 6 +- utils/modules/ExternalCommandSupport.psm1 | 102 ++ utils/modules/ScriptConfig.psm1 | 120 +- utils/modules/TestRunner.psm1 | 401 +++++-- .../DiscoverDotNetPackageArtifacts.psm1 | 69 ++ .../plugins/DotNet/DotNetArtifactSupport.psm1 | 63 + utils/plugins/DotNet/DotNetDockerPush.psm1 | 245 ---- utils/plugins/DotNet/DotNetHelmPush.psm1 | 181 --- utils/plugins/DotNet/DotNetNuGet.psm1 | 34 +- utils/plugins/DotNet/DotNetTest.psm1 | 85 +- utils/plugins/Npm/NpmPack.psm1 | 137 +++ utils/plugins/Npm/NpmPackageSupport.psm1 | 62 + utils/plugins/Npm/NpmPublish.psm1 | 58 +- utils/plugins/Platform/CleanupArtifacts.psm1 | 122 ++ utils/plugins/Platform/CoverageBadges.psm1 | 142 ++- .../plugins/Platform/FileReleaseVersion.psm1 | 41 + utils/plugins/Platform/GitHub.psm1 | 75 +- utils/plugins/Platform/PesterTest.psm1 | 238 ++++ .../plugins/Platform/ReleasePublishGuard.psm1 | 7 +- utils/tools/Enable-ModelsNullable.ps1 | 34 - utils/tools/Polish-PodmanClientSources.ps1 | 117 -- .../Update-RepoUtils/Update-RepoUtils.ps1 | 5 + 163 files changed, 7525 insertions(+), 1700 deletions(-) create mode 100644 .editorconfig delete mode 100644 assets/badges/coverage-branches.svg delete mode 100644 assets/badges/coverage-lines.svg delete mode 100644 assets/badges/coverage-methods.svg create mode 100644 src/PodmanClient.PowerShell/Build/BuildCmdlets.cs create mode 100644 src/PodmanClient.PowerShell/ConnectDisconnectCmdlets.cs create mode 100644 src/PodmanClient.PowerShell/Containers/ContainersCmdlets.cs create mode 100644 src/PodmanClient.PowerShell/Exec/ExecCmdlets.cs create mode 100644 src/PodmanClient.PowerShell/Generate/GenerateCmdlets.cs create mode 100644 src/PodmanClient.PowerShell/Images/ImagesCmdlets.cs create mode 100644 src/PodmanClient.PowerShell/MaksIT.PodmanClientDotNet.PowerShell.psd1 create mode 100644 src/PodmanClient.PowerShell/Manifests/ManifestsCmdlets.cs create mode 100644 src/PodmanClient.PowerShell/Networks/NetworksCmdlets.cs create mode 100644 src/PodmanClient.PowerShell/PodmanClient.PowerShell.csproj create mode 100644 src/PodmanClient.PowerShell/PodmanCmdletBase.cs create mode 100644 src/PodmanClient.PowerShell/PodmanConnectionState.cs create mode 100644 src/PodmanClient.PowerShell/Pods/PodsCmdlets.cs create mode 100644 src/PodmanClient.PowerShell/README.md create mode 100644 src/PodmanClient.PowerShell/System/SystemCmdlets.cs create mode 100644 src/PodmanClient.PowerShell/Volumes/VolumesCmdlets.cs delete mode 100644 src/PodmanClientDotNet.Tests/Archives/Tar.cs create mode 100644 src/PodmanClientDotNet.Tests/InspectExecResponseDtoTests.cs delete mode 100644 src/PodmanClientDotNet.Tests/PodmanClientContainersTests.cs delete mode 100644 src/PodmanClientDotNet.Tests/PodmanClientExecTests.cs delete mode 100644 src/PodmanClientDotNet.Tests/PodmanClientImagesTests.cs delete mode 100644 src/PodmanClientDotNet.Tests/PodmanClientStreamingIntegrationTests.cs delete mode 100644 src/PodmanClientDotNet.Tests/PodmanClientTestFixture.cs create mode 100644 src/e2e-tests/Podman.E2E.Common.ps1 create mode 100644 src/e2e-tests/Test-PodmanE2E.bat create mode 100644 src/e2e-tests/Test-PodmanE2E.ps1 create mode 100644 src/e2e-tests/scenarios/Scenario-01-System.ps1 create mode 100644 src/e2e-tests/scenarios/Scenario-02-Images.ps1 create mode 100644 src/e2e-tests/scenarios/Scenario-03-ContainersLifecycle.ps1 create mode 100644 src/e2e-tests/scenarios/Scenario-04-ContainersArchiveAttach.ps1 create mode 100644 src/e2e-tests/scenarios/Scenario-05-Exec.ps1 create mode 100644 src/e2e-tests/scenarios/Scenario-06-Volumes.ps1 create mode 100644 src/e2e-tests/scenarios/Scenario-07-Networks.ps1 create mode 100644 src/e2e-tests/scenarios/Scenario-08-Pods.ps1 create mode 100644 src/e2e-tests/scenarios/Scenario-09-Build.ps1 create mode 100644 src/e2e-tests/scenarios/Scenario-10-Manifests.ps1 create mode 100644 src/e2e-tests/scenarios/Scenario-11-Generate.ps1 create mode 100644 utils/modules/ExternalCommandSupport.psm1 create mode 100644 utils/plugins/DotNet/DiscoverDotNetPackageArtifacts.psm1 create mode 100644 utils/plugins/DotNet/DotNetArtifactSupport.psm1 delete mode 100644 utils/plugins/DotNet/DotNetDockerPush.psm1 delete mode 100644 utils/plugins/DotNet/DotNetHelmPush.psm1 create mode 100644 utils/plugins/Npm/NpmPack.psm1 create mode 100644 utils/plugins/Npm/NpmPackageSupport.psm1 create mode 100644 utils/plugins/Platform/CleanupArtifacts.psm1 create mode 100644 utils/plugins/Platform/FileReleaseVersion.psm1 create mode 100644 utils/plugins/Platform/PesterTest.psm1 delete mode 100644 utils/tools/Enable-ModelsNullable.ps1 delete mode 100644 utils/tools/Polish-PodmanClientSources.ps1 diff --git a/.cursor/maksit-skills.json b/.cursor/maksit-skills.json index 5ff1424..00e4e81 100644 --- a/.cursor/maksit-skills.json +++ b/.cursor/maksit-skills.json @@ -4,6 +4,7 @@ "skills": [ "common/csharp", "common/maksit-repo-maintenance", - "local-ollama" + "local-ollama", + "gh-cli" ] } diff --git a/.cursor/rules/maksit-skills.mdc b/.cursor/rules/maksit-skills.mdc index fc8b2ad..ec613e0 100644 --- a/.cursor/rules/maksit-skills.mdc +++ b/.cursor/rules/maksit-skills.mdc @@ -9,5 +9,6 @@ alwaysApply: true 1. `E:\Users\maksym\source\repos\private\homelab\ai\skills\common\csharp\SKILL.md` 2. `E:\Users\maksym\source\repos\private\homelab\ai\skills\common\maksit-repo-maintenance\SKILL.md` 3. `E:\Users\maksym\source\repos\private\homelab\ai\skills\local-ollama\SKILL.md` — local Ollama offload (`@local-ollama`) +4. `E:\Users\maksym\source\repos\private\homelab\ai\skills\gh-cli\SKILL.md` — git/gh commits & PRs (`/gh-cli`, `@gh-cli`) Manifest: `.cursor/maksit-skills.json`. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9d3e7b2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,118 @@ +# EditorConfig: https://editorconfig.org +# Shared code style for VS Code, Cursor, Visual Studio, Rider + +root = true + +[*] +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space + +# ========================= +# C# +# ========================= +[*.cs] +indent_size = 2 + +# K&R braces (opening brace on same line) +csharp_new_line_before_open_brace = none + +# keep else/catch/finally on new line +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true + +# indentation +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current + +# spacing +csharp_space_after_cast = false +csharp_space_after_dot = false +csharp_space_after_semicolon_in_for_statement = true +csharp_space_before_semicolon_in_for_statement = false + +# Fluent / chained method calls (ReSharper compatibility) +resharper_csharp_continuous_indent_multiplier = 1 +resharper_continuous_indent_multiplier = 1 +resharper_csharp_align_multiline_calls_chain = false +resharper_align_multiline_calls_chain = false +resharper_csharp_outdent_dots = false +resharper_outdent_dots = false + +# Two blank lines between last using and namespace +resharper_csharp_blank_lines_between_using_and_namespace = 2 +resharper_blank_lines_between_using_and_namespace = 2 + +# Usings: System first; blank line between System and other groups (Roslyn) +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +resharper_sort_usings_system_first = true + +# Remove unnecessary using directives (dotnet format style --diagnostics IDE0005) +dotnet_diagnostic.IDE0005.severity = warning + +# File-scoped namespace declarations (IDE0160) +csharp_style_namespace_declarations = file_scoped:warning +dotnet_diagnostic.IDE0160.severity = warning + +# Omit braces on single-line if/else/for/foreach/while/using (IDE0011) +csharp_prefer_braces = when_multiline:none +dotnet_diagnostic.IDE0011.severity = none + +# Prefer 'var' over explicit type (IDE0008) +csharp_style_var_for_built_in_types = true:none +csharp_style_var_when_type_is_apparent = true:none +csharp_style_var_elsewhere = true:none +dotnet_diagnostic.IDE0008.severity = none + +# Prefer if-return over ternary for conditional returns (IDE0046) — multi-line guards stay readable +dotnet_style_prefer_conditional_expression_over_return = false:none +dotnet_diagnostic.IDE0046.severity = none + +# Expression-bodied members: prefer => over { return ...; } (IDE0022–IDE0027) +csharp_style_expression_bodied_methods = true:warning +csharp_style_expression_bodied_properties = true:warning +csharp_style_expression_bodied_accessors = true:warning +csharp_style_expression_bodied_indexers = true:warning +csharp_style_expression_bodied_operators = true:warning +csharp_style_expression_bodied_lambdas = true:none +csharp_style_expression_bodied_local_functions = false:none +csharp_style_expression_bodied_constructors = false:none +dotnet_diagnostic.IDE0022.severity = warning +dotnet_diagnostic.IDE0023.severity = warning +dotnet_diagnostic.IDE0024.severity = warning +dotnet_diagnostic.IDE0025.severity = warning +dotnet_diagnostic.IDE0026.severity = warning +dotnet_diagnostic.IDE0027.severity = warning + +# => on signature line; expression indented on next line (no blank line after =>) +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:warning +dotnet_diagnostic.IDE2006.severity = warning +resharper_csharp_wrap_before_arrow_with_expressions = false +resharper_wrap_before_arrow_with_expressions = false + + +# ========================= +# TypeScript / JavaScript +# ========================= +[*.{ts,tsx,js,jsx}] +indent_size = 2 + + +# ========================= +# JSON / YAML +# ========================= +[*.{json,yml,yaml}] +indent_size = 2 + + +# ========================= +# Markdown +# ========================= +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index f1994ed..f0420bd 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ bld/ # MSTest test Results [Tt]est[Rr]esult*/ +test-results/ [Bb]uild[Ll]og.* # NUNIT diff --git a/AGENTS.md b/AGENTS.md index 4347939..9a83168 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,5 +5,6 @@ | csharp | [SKILL.md](E:\Users\maksym\source\repos\private\homelab\ai\skills\common\csharp\SKILL.md) | | maksit-repo-maintenance | [SKILL.md](E:\Users\maksym\source\repos\private\homelab\ai\skills\common\maksit-repo-maintenance\SKILL.md) | | local-ollama | [SKILL.md](E:\Users\maksym\source\repos\private\homelab\ai\skills\local-ollama\SKILL.md) | +| gh-cli | [SKILL.md](E:\Users\maksym\source\repos\private\homelab\ai\skills\gh-cli\SKILL.md) | Manifest: `.cursor/maksit-skills.json`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d3ec04..8854442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [1.3.0] - 2026-07-27 + +### Added + +- **PowerShell module** `MaksIT.PodmanClientDotNet.PowerShell` wrapping the full `IPodmanClient` surface (`Connect-Podman` and domain cmdlets). +- **PowerShell E2E** harness under `src/e2e-tests/` (requires `PODMAN_TEST_URL`) covering system, images, containers, exec, volumes, networks, pods, build, manifests, and generate. Validated against **Podman 5.4.0**. + +### Changed + +- **Default `ApiVersion`**: `v5.4.0` (libpod path). Docker-compat `v1.41` is not used — network endpoints reject it. +- **Manifests (libpod v4+)**: create via `POST /libpod/manifests/{name}`; add via `PUT` modify body; push via `POST .../registry/{destination}` (replaces deprecated v3 `/create`, `/add`, `/push`). +- **Pod stats**: `GetPodsStatsAsync` returns `List` (libpod array), not a dictionary wrapper. + +### Fixed + +- **Ping** (`/_ping`): treat Podman's plain-text `OK` body as success instead of JSON-deserializing it (which threw `JsonException`). +- **Mount container**: treat plain filesystem path body as `ContainerMountDto.Path`. +- **Wait container**: accept bare exit-code integer responses from libpod. +- **System DTOs** (`LibpodVersionDto`, `InfoDto`, `SystemDfDto`): align with live libpod JSON shapes (e.g. version `Platform` object, host `distribution` object, numeric memory fields). +- **Image DTOs**: `RepoTags`/`RepoDigests` as string arrays; image tree `{Tree}`; image changes as path/kind entries; delete/remove returns a single `ImageDeleteDto` object (not an array). +- **Prune APIs**: image/container/volume/pod prune return a list of `PruneReportEntryDto`; system prune returns `SystemPruneReportDto`. +- **Container list/inspect/stats/changes/mounted DTOs**: align with live libpod JSON (including multi-container stats wrapper). +- **Container inspect**: `Config.StopSignal` is a string (e.g. `SIGTERM`), not `Int64`. +- **Pod DTOs**: inspect/list `Containers` as object summaries. + +### Removed + +- C# xUnit live Integration tests (`Category=Integration`); replaced by PowerShell E2E scenarios. + +## [1.2.1] - 2026-07-10 + +### Fixed + +- **Inspect exec** deserialization: `ProcessConfig` is now typed as an object (`InspectExecProcessDto`) matching the Podman libpod API, instead of `string` (which threw `JsonException` when reading exit codes after exec). + ## [1.2.0] - 2026-07-02 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 302af15..2980398 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,8 @@ Thank you for contributing. This repo follows MaksIT conventions (see `AGENTS.md - .NET 10 SDK - Git -- Optional: reachable Podman API for integration tests (`PODMAN_TEST_URL` or `PODMAN_INTEGRATION_URL`) +- PowerShell 7 hosted on **.NET 10** (for the PowerShell module and E2E) +- Optional: reachable Podman REST API for live E2E (`PODMAN_TEST_URL`) ### Build @@ -16,7 +17,7 @@ Thank you for contributing. This repo follows MaksIT conventions (see `AGENTS.md dotnet build src/PodmanClientDotNet.slnx ``` -### Tests +### Unit tests **RepoUtils test engine** (coverage + badges): @@ -30,7 +31,80 @@ utils/Invoke-TestEngine.bat dotnet test src/PodmanClientDotNet.Tests/PodmanClientDotNet.Tests.csproj ``` -When coverage changes and the test engine runs **CoverageBadges**, commit updated SVGs under `assets/badges/` (cited in `README.md`). +Coverage badges in `README.md` are rewritten by the test engine (`CoverageBadges` with `badgeFormat: shields` and `readmePath` in `utils/engines/test/scriptSettings.json`) using `img.shields.io` URLs. Commit the updated README when coverage changes. + +### Integration / E2E tests (Podman API VM) + +Live API coverage is **PowerShell E2E** under `src/e2e-tests/` (not part of `Invoke-TestEngine`). It builds and imports `MaksIT.PodmanClientDotNet.PowerShell`, then runs domain scenarios against a real Podman API. + +**Validated target:** [Podman](https://podman.io/) **5.4.0** (libpod path default `ApiVersion` = `v5.4.0`). Older engines that expose at least API `4.0.0` may work; Docker-compat path `v1.41` is not used (network endpoints reject it). + +#### 1. Expose Podman on a Linux VM (e.g. Alma) + +```bash +podman version # expect 5.4.x for this repo's E2E baseline + +# Bind all interfaces (not only 127.0.0.1) +podman system service --time=0 tcp://0.0.0.0:8080 +``` + +On the VM: + +```bash +curl -s http://127.0.0.1:8080/v5.4.0/_ping # expect OK +ss -tlnp | grep 8080 # expect 0.0.0.0:8080 (or LAN IP) +sudo firewall-cmd --add-port=8080/tcp --permanent && sudo firewall-cmd --reload +podman pull alpine:latest # scenarios need registry access +``` + +Optional systemd unit (lab): + +```ini +[Unit] +Description=Podman API service +After=network.target + +[Service] +ExecStart=/usr/bin/podman system service --time=0 tcp://0.0.0.0:8080 +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +Unauthenticated TCP API is lab-only — restrict firewall to your developer machine when possible. + +#### 2. Run E2E from Windows + +```powershell +Invoke-WebRequest http://:8080/v5.4.0/_ping # expect 200 / OK +$env:PODMAN_TEST_URL = "http://:8080" # must include http:// or https:// +.\src\e2e-tests\Test-PodmanE2E.bat +# or: +pwsh -File .\src\e2e-tests\Test-PodmanE2E.ps1 -Scenario 'System','Images' +``` + +`PODMAN_TEST_URL` must be an absolute URI. Bare `host:port` causes `UriFormatException` in the .NET client. + +Scenarios cover system, images, containers, exec, volumes, networks, pods, build, manifests, and generate (full PowerShell cmdlet surface). Filter with `-Scenario`. + +#### Troubleshooting + +1. **Pull fails** — VM cannot reach a registry; pre-pull `alpine:latest` on the VM. +2. **Container create/start fails** — confirm `podman run --rm alpine:latest echo ok` as the same user running `system service`. +3. **Attach/exec session failures** — hijack opens a second TCP connection to the same host:port; allow it in the firewall; do not put the API behind a proxy that strips `Upgrade: tcp`. +4. **pwsh / .NET 10** — binary module requires PowerShell hosted on .NET 10. +5. **`version is not supported` on networks** — use libpod path `v4.0.0+` (default `v5.4.0`), not Docker-compat `v1.41`. + +### PowerShell module + +```powershell +dotnet build src/PodmanClient.PowerShell/PodmanClient.PowerShell.csproj +Import-Module .\src\PodmanClient.PowerShell\bin\Debug\net10.0\MaksIT.PodmanClientDotNet.PowerShell.psd1 -Force +Connect-Podman -BaseAddress $env:PODMAN_TEST_URL +``` + +See [src/PodmanClient.PowerShell/README.md](src/PodmanClient.PowerShell/README.md). ## Commit message format @@ -49,15 +123,14 @@ Types: `(feature):`, `(bugfix):`, `(refactor):`, `(perf):`, `(test):`, `(docs):` - **MaksIT.Results** for API outcomes; **System.Text.Json** source generation via `PodmanJsonContext` for JSON serialization (AOT/trim-safe). - File-scoped namespaces and same-line braces; **Models/** use nullable reference types (`string?`, `List?`, …) for optional JSON fields. - XML documentation on public types (DTOs, interfaces, entry types). Method-level docs on large interfaces are optional (`CS1591` suppressed). -- Model layout helpers: `utils/tools/Polish-PodmanClientSources.ps1`, `utils/tools/Enable-ModelsNullable.ps1`. ## Pull requests -1. Build and tests pass. +1. Build and unit tests pass; run E2E when changing the client or PowerShell surface if a Podman API is available. 2. Update **README.md** / **CHANGELOG.md** when behavior or public API changes. -3. Refresh **`assets/badges/*.svg`** when coverage badges change. +3. If coverage changed: ensure **README.md** shields.io badge lines were updated by the test engine. 4. Keep diffs scoped. ## Versioning -[Semantic Versioning](https://semver.org): bump `Version` in `src/PodmanClient/PodmanClientDotNet.csproj` with **CHANGELOG.md** for releases. Use `utils/Invoke-ReleasePackage.bat` when releasing. +[Semantic Versioning](https://semver.org): bump `Version` in `src/PodmanClient/PodmanClientDotNet.csproj` with **CHANGELOG.md** for releases. Use `utils\Invoke-ReleasePackage-Single.bat` when releasing. diff --git a/README.md b/README.md index c23e7e5..a6f38f7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # PodmanClient.DotNet -![Line Coverage](assets/badges/coverage-lines.svg) ![Branch Coverage](assets/badges/coverage-branches.svg) ![Method Coverage](assets/badges/coverage-methods.svg) +![Line Coverage](https://img.shields.io/badge/Line%20Coverage-6.2%25-red) +![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-14.5%25-orange) +![Method Coverage](https://img.shields.io/badge/Method%20Coverage-4.4%25-red) ## Description @@ -37,7 +39,7 @@ dotnet add package PodmanClient.DotNet { "PodmanClient": { "ServerUrl": "http://localhost:8080", - "ApiVersion": "v1.41", + "ApiVersion": "v5.4.0", "TimeoutMinutes": 5 } } @@ -50,7 +52,7 @@ using MaksIT.PodmanClientDotNet.Extensions; // Host-owned options type (not shipped in this package) public sealed class PodmanClientOptions : IPodmanClientConfiguration { public string ServerUrl { get; set; } = string.Empty; - public string ApiVersion { get; set; } = "v1.41"; + public string ApiVersion { get; set; } = "v5.4.0"; public int TimeoutMinutes { get; set; } = 60; } @@ -162,16 +164,28 @@ Register with `AddPodmanClient` or construct `PodmanClient` manually. Methods re API responses are typed under `Dtos/` (for example `ContainerInspectDto`, `ImageInspectDto`, `InfoDto`). Request/spec payloads remain in `Models/`. -## Tests +## PowerShell module -Unit tests cover multiplex framing, attach sessions, NDJSON progress, and a local hijack mock server. Integration tests require a reachable Podman API: +Binary module wrapping the full `IPodmanClient` surface — see [src/PodmanClient.PowerShell/README.md](src/PodmanClient.PowerShell/README.md). -```shell -$env:PODMAN_TEST_URL = "http://localhost:8080" -dotnet test src/PodmanClientDotNet.Tests/PodmanClientDotNet.Tests.csproj +```powershell +dotnet build src/PodmanClient.PowerShell/PodmanClient.PowerShell.csproj +Import-Module .\src\PodmanClient.PowerShell\bin\Debug\net10.0\MaksIT.PodmanClientDotNet.PowerShell.psd1 -Force +Connect-Podman -BaseAddress 'http://192.168.x.x:8080' ``` -Without `PODMAN_TEST_URL` (or `PODMAN_INTEGRATION_URL`), integration tests are skipped automatically. Filter them in CI with `--filter "Category!=Integration"`. +## Tests + +Unit tests cover multiplex framing, attach sessions, NDJSON progress, and a local hijack mock server (`dotnet test` / `utils/Invoke-TestEngine.bat`). + +Live API E2E uses the PowerShell module against a reachable **Podman 5.4.0** API (baseline): + +```powershell +$env:PODMAN_TEST_URL = "http://192.168.x.x:8080" +.\src\e2e-tests\Test-PodmanE2E.bat +``` + +Default client `ApiVersion` is `v5.4.0`. VM setup, firewall, and troubleshooting: [CONTRIBUTING.md](CONTRIBUTING.md). **Note:** Full-duplex attach/exec sessions use a raw TCP hijack connection and do not flow through `HttpClient` delegating handlers (proxy, client certificates, etc.). Configure network access accordingly. diff --git a/assets/badges/coverage-branches.svg b/assets/badges/coverage-branches.svg deleted file mode 100644 index 82cc3ce..0000000 --- a/assets/badges/coverage-branches.svg +++ /dev/null @@ -1,21 +0,0 @@ - - Branch Coverage: 14.2% - - - - - - - - - - - - - - - Branch Coverage - - 14.2% - - diff --git a/assets/badges/coverage-lines.svg b/assets/badges/coverage-lines.svg deleted file mode 100644 index fb09936..0000000 --- a/assets/badges/coverage-lines.svg +++ /dev/null @@ -1,21 +0,0 @@ - - Line Coverage: 5% - - - - - - - - - - - - - - - Line Coverage - - 5% - - diff --git a/assets/badges/coverage-methods.svg b/assets/badges/coverage-methods.svg deleted file mode 100644 index c140257..0000000 --- a/assets/badges/coverage-methods.svg +++ /dev/null @@ -1,21 +0,0 @@ - - Method Coverage: 3.4% - - - - - - - - - - - - - - - Method Coverage - - 3.4% - - diff --git a/src/PodmanClient.PowerShell/Build/BuildCmdlets.cs b/src/PodmanClient.PowerShell/Build/BuildCmdlets.cs new file mode 100644 index 0000000..a7f7562 --- /dev/null +++ b/src/PodmanClient.PowerShell/Build/BuildCmdlets.cs @@ -0,0 +1,186 @@ +using System.Management.Automation; + +using MaksIT.PodmanClientDotNet.Dtos.Build; +using MaksIT.PodmanClientDotNet.Streaming; + + +namespace MaksIT.PodmanClientDotNet.PowerShell; + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanBuildImage")] +[OutputType(typeof(BuildReportDto))] +public sealed class InvokePodmanBuildImageCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Dockerfile { get; set; } = null!; + + [Parameter] + public string? ContextPath { get; set; } + + [Parameter] + public Stream? ContextStream { get; set; } + + [Parameter] + public SwitchParameter Pull { get; set; } + + [Parameter] + public SwitchParameter Rm { get; set; } = true; + + [Parameter] + public SwitchParameter ForceRm { get; set; } + + [Parameter] + public SwitchParameter NoCache { get; set; } + + [Parameter] + public string? Remote { get; set; } + + [Parameter] + [Alias("t")] + public string? Tag { get; set; } + + [Parameter] + public string? Platform { get; set; } + + [Parameter] + public string? BuildArgs { get; set; } + + [Parameter] + public string? Labels { get; set; } + + protected override void ProcessRecord() { + Stream? owned = null; + try { + var client = RequireClient(); + var context = ResolveContext(ref owned); + var result = client.BuildImageAsync( + Dockerfile, + context, + Pull.IsPresent, + Rm.IsPresent, + ForceRm.IsPresent, + NoCache.IsPresent, + Remote, + Tag, + Platform, + BuildArgs, + Labels + ).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + finally { + owned?.Dispose(); + } + } + + private Stream? ResolveContext(ref Stream? owned) { + if (ContextStream is not null) + return ContextStream; + + if (string.IsNullOrWhiteSpace(ContextPath)) + return null; + + owned = File.OpenRead(ContextPath); + return owned; + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanBuildImageProgress")] +[OutputType(typeof(BuildProgressLineDto))] +[OutputType(typeof(IPodmanProgressSession))] +public sealed class InvokePodmanBuildImageProgressCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Dockerfile { get; set; } = null!; + + [Parameter] + public string? ContextPath { get; set; } + + [Parameter] + public Stream? ContextStream { get; set; } + + [Parameter] + public SwitchParameter Pull { get; set; } + + [Parameter] + public SwitchParameter Rm { get; set; } = true; + + [Parameter] + public SwitchParameter ForceRm { get; set; } + + [Parameter] + public SwitchParameter NoCache { get; set; } + + [Parameter] + public string? Remote { get; set; } + + [Parameter] + [Alias("t")] + public string? Tag { get; set; } + + [Parameter] + public string? Platform { get; set; } + + [Parameter] + public string? BuildArgs { get; set; } + + [Parameter] + public string? Labels { get; set; } + + [Parameter] + public SwitchParameter Wait { get; set; } = true; + + protected override void ProcessRecord() { + Stream? owned = null; + try { + var client = RequireClient(); + var context = ResolveContext(ref owned); + var result = client.BuildImageWithProgressAsync( + Dockerfile, + context, + Pull.IsPresent, + Rm.IsPresent, + ForceRm.IsPresent, + NoCache.IsPresent, + Remote, + Tag, + Platform, + BuildArgs, + Labels + ).GetAwaiter().GetResult(); + + if (!Wait.IsPresent) { + WritePodmanResult(result); + return; + } + + if (!result.IsSuccess) { + WritePodmanResult(result); + return; + } + + if (result.Value is null) + return; + + var items = CollectProgress(result.Value); + WriteObject(items, true); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + finally { + owned?.Dispose(); + } + } + + private Stream? ResolveContext(ref Stream? owned) { + if (ContextStream is not null) + return ContextStream; + + if (string.IsNullOrWhiteSpace(ContextPath)) + return null; + + owned = File.OpenRead(ContextPath); + return owned; + } +} diff --git a/src/PodmanClient.PowerShell/ConnectDisconnectCmdlets.cs b/src/PodmanClient.PowerShell/ConnectDisconnectCmdlets.cs new file mode 100644 index 0000000..19f7b7f --- /dev/null +++ b/src/PodmanClient.PowerShell/ConnectDisconnectCmdlets.cs @@ -0,0 +1,30 @@ +using System.Management.Automation; + +namespace MaksIT.PodmanClientDotNet.PowerShell; + +[Cmdlet(VerbsCommunications.Connect, "Podman")] +[OutputType(typeof(void))] +public sealed class ConnectPodmanCmdlet : PSCmdlet { + [Parameter(Mandatory = true, Position = 0)] + public string BaseAddress { get; set; } = null!; + + [Parameter] + public int TimeoutMinutes { get; set; } = 60; + + [Parameter] + public string? ApiVersion { get; set; } + + protected override void ProcessRecord() { + PodmanConnectionState.SetConnection(BaseAddress, TimeoutMinutes, ApiVersion); + WriteVerbose($"Connected to Podman at {BaseAddress}"); + } +} + +[Cmdlet(VerbsCommunications.Disconnect, "Podman")] +[OutputType(typeof(void))] +public sealed class DisconnectPodmanCmdlet : PSCmdlet { + protected override void ProcessRecord() { + PodmanConnectionState.ClearConnection(); + WriteVerbose("Disconnected from Podman"); + } +} diff --git a/src/PodmanClient.PowerShell/Containers/ContainersCmdlets.cs b/src/PodmanClient.PowerShell/Containers/ContainersCmdlets.cs new file mode 100644 index 0000000..76a3d1d --- /dev/null +++ b/src/PodmanClient.PowerShell/Containers/ContainersCmdlets.cs @@ -0,0 +1,1026 @@ +using System.Collections; +using System.Management.Automation; + +using MaksIT.PodmanClientDotNet.Dtos.Common; +using MaksIT.PodmanClientDotNet.Dtos.Container; +using MaksIT.PodmanClientDotNet.Streaming; + + +namespace MaksIT.PodmanClientDotNet.PowerShell; + +[Cmdlet(VerbsCommon.New, "PodmanContainer")] +[OutputType(typeof(CreateContainerResponseDto))] +public sealed class NewPodmanContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Name { get; set; } = null!; + + [Parameter(Mandatory = true, Position = 1)] + public string Image { get; set; } = null!; + + [Parameter] + public string[]? Command { get; set; } + + [Parameter] + public Hashtable? Env { get; set; } + + [Parameter] + public SwitchParameter Remove { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var command = Command is null ? null : new List(Command); + var env = ToStringDictionary(Env); + bool? remove = Remove.IsPresent ? true : null; + var result = client.CreateContainerAsync(Name, Image, command, env, remove).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } + + private static Dictionary? ToStringDictionary(Hashtable? table) { + if (table is null) + return null; + + var dict = new Dictionary(table.Count); + foreach (DictionaryEntry entry in table) + dict[entry.Key?.ToString() ?? string.Empty] = entry.Value?.ToString() ?? string.Empty; + + return dict; + } +} + +[Cmdlet(VerbsLifecycle.Start, "PodmanContainer")] +[OutputType(typeof(void))] +public sealed class StartPodmanContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + [Parameter] + public string DetachKeys { get; set; } = "ctrl-p,ctrl-q"; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.StartContainerAsync(Name, DetachKeys).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Stop, "PodmanContainer")] +[OutputType(typeof(void))] +public sealed class StopPodmanContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + [Parameter] + public int Timeout { get; set; } = 10; + + [Parameter] + public SwitchParameter IgnoreAlreadyStopped { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.StopContainerAsync(Name, Timeout, IgnoreAlreadyStopped).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Remove, "PodmanContainer")] +[OutputType(typeof(DeleteContainerResponseDto))] +public sealed class RemovePodmanContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + [Parameter] + public SwitchParameter Force { get; set; } + + [Parameter] + public SwitchParameter DeleteVolumes { get; set; } + + [Parameter] + public SwitchParameter Depend { get; set; } + + [Parameter] + public SwitchParameter Ignore { get; set; } + + [Parameter] + public int Timeout { get; set; } = 10; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + if (Force) { + var forceResult = client.ForceDeleteContainerAsync(Name, DeleteVolumes, Timeout).GetAwaiter().GetResult(); + WritePodmanResult(forceResult); + return; + } + + var result = client.DeleteContainerAsync(Name, Depend, Ignore, Timeout).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanExtractArchive")] +[OutputType(typeof(void))] +public sealed class InvokePodmanExtractArchiveCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + [Alias("Name", "Id")] + public string ContainerId { get; set; } = null!; + + [Parameter(Mandatory = true, Position = 1)] + public string Path { get; set; } = null!; + + [Parameter(ParameterSetName = "FilePath")] + public string? FilePath { get; set; } + + [Parameter(ParameterSetName = "InputStream")] + public Stream? InputStream { get; set; } + + [Parameter] + public SwitchParameter Pause { get; set; } = true; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var ownsStream = InputStream is null; + var stream = OpenInputStream(FilePath, InputStream); + try { + var result = client.ExtractArchiveToContainerAsync(ContainerId, stream, Path, Pause).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + finally { + if (ownsStream) + stream.Dispose(); + } + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanContainerList")] +[OutputType(typeof(ContainerListEntryDto))] +public sealed class GetPodmanContainerListCmdlet : PodmanCmdletBase { + [Parameter] + public SwitchParameter All { get; set; } + + [Parameter] + public int? Limit { get; set; } + + [Parameter] + public SwitchParameter Size { get; set; } + + [Parameter] + public SwitchParameter Sync { get; set; } + + [Parameter] + public string? Filters { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.ListContainersAsync(All, Limit, Size, Sync, Filters).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanContainer")] +[OutputType(typeof(ContainerInspectDto))] +public sealed class GetPodmanContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.InspectContainerAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsDiagnostic.Test, "PodmanContainer")] +[OutputType(typeof(bool))] +public sealed class TestPodmanContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.ContainerExistsAsync(Name).GetAwaiter().GetResult(); + WriteObject(result.IsSuccess); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Restart, "PodmanContainer")] +[OutputType(typeof(void))] +public sealed class RestartPodmanContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + [Parameter] + public int Timeout { get; set; } = 10; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.RestartContainerAsync(Name, Timeout).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet("Kill", "PodmanContainer")] +[OutputType(typeof(void))] +public sealed class KillPodmanContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + [Parameter] + public string Signal { get; set; } = "TERM"; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.KillContainerAsync(Name, Signal).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Suspend, "PodmanContainer")] +[OutputType(typeof(void))] +public sealed class SuspendPodmanContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.PauseContainerAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Resume, "PodmanContainer")] +[OutputType(typeof(void))] +public sealed class ResumePodmanContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.UnpauseContainerAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Wait, "PodmanContainer")] +[OutputType(typeof(ContainerWaitDto))] +public sealed class WaitPodmanContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + [Parameter] + public string? Condition { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.WaitContainerAsync(Name, Condition).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanContainerLog")] +[OutputType(typeof(Stream), typeof(string))] +public sealed class GetPodmanContainerLogCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + [Parameter] + public SwitchParameter Follow { get; set; } + + [Parameter] + public SwitchParameter Stdout { get; set; } = true; + + [Parameter] + public SwitchParameter Stderr { get; set; } = true; + + [Parameter] + public SwitchParameter Timestamps { get; set; } + + [Parameter] + public string? Since { get; set; } + + [Parameter] + public string? Until { get; set; } + + [Parameter] + public string? Tail { get; set; } + + [Parameter] + public string? OutFile { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.GetContainerLogsAsync( + Name, + Follow, + Stdout, + Stderr, + Timestamps, + Since, + Until, + Tail).GetAwaiter().GetResult(); + WritePodmanStream(result, OutFile); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanContainerStat")] +[OutputType(typeof(ContainerStatsDto))] +public sealed class GetPodmanContainerStatCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + [Parameter] + public SwitchParameter Stream { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.GetContainerStatsAsync(Name, Stream).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanContainerStatBatch")] +[OutputType(typeof(ContainersStatsResponseDto))] +public sealed class GetPodmanContainerStatBatchCmdlet : PodmanCmdletBase { + [Parameter] + public string[]? Containers { get; set; } + + [Parameter] + public SwitchParameter Stream { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.GetContainersStatsAsync(Containers, Stream).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanPruneContainer")] +[OutputType(typeof(PruneReportEntryDto))] +public sealed class InvokePodmanPruneContainerCmdlet : PodmanCmdletBase { + [Parameter] + public string? Filters { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.PruneContainersAsync(Filters).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Rename, "PodmanContainer")] +[OutputType(typeof(void))] +public sealed class RenamePodmanContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + [Parameter(Mandatory = true, Position = 1)] + public string NewName { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.RenameContainerAsync(Name, NewName).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsData.Initialize, "PodmanContainer")] +[OutputType(typeof(void))] +public sealed class InitializePodmanContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.InitContainerAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet("Checkpoint", "PodmanContainer")] +[OutputType(typeof(Stream), typeof(string))] +public sealed class CheckpointPodmanContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + [Parameter] + public SwitchParameter Keep { get; set; } + + [Parameter] + public SwitchParameter LeaveRunning { get; set; } + + [Parameter] + public SwitchParameter TcpEstablished { get; set; } + + [Parameter] + public SwitchParameter Export { get; set; } + + [Parameter] + public SwitchParameter IgnoreRootFS { get; set; } + + [Parameter] + public string? OutFile { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.CheckpointContainerAsync( + Name, + Keep, + LeaveRunning, + TcpEstablished, + Export, + IgnoreRootFS).GetAwaiter().GetResult(); + WritePodmanStream(result, OutFile); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsData.Restore, "PodmanContainer")] +[OutputType(typeof(void))] +public sealed class RestorePodmanContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + [Parameter] + public string? ImportPath { get; set; } + + [Parameter] + public SwitchParameter Keep { get; set; } + + [Parameter] + public SwitchParameter LeaveRunning { get; set; } + + [Parameter] + public SwitchParameter TcpEstablished { get; set; } + + [Parameter] + public SwitchParameter IgnoreRootFS { get; set; } + + [Parameter] + public SwitchParameter IgnoreStaticIP { get; set; } + + [Parameter] + public SwitchParameter IgnoreStaticMAC { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.RestoreContainerAsync( + Name, + ImportPath, + Keep, + LeaveRunning, + TcpEstablished, + IgnoreRootFS, + IgnoreStaticIP, + IgnoreStaticMAC).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsData.Mount, "PodmanContainer")] +[OutputType(typeof(ContainerMountDto))] +public sealed class MountPodmanContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.MountContainerAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsData.Dismount, "PodmanContainer")] +[OutputType(typeof(void))] +public sealed class DismountPodmanContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.UnmountContainerAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsData.Export, "PodmanContainer")] +[OutputType(typeof(Stream), typeof(string))] +public sealed class ExportPodmanContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + [Parameter] + public string? OutFile { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.ExportContainerAsync(Name).GetAwaiter().GetResult(); + WritePodmanStream(result, OutFile); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanContainerArchive")] +[OutputType(typeof(Stream), typeof(string))] +public sealed class GetPodmanContainerArchiveCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + [Parameter(Mandatory = true, Position = 1)] + public string Path { get; set; } = null!; + + [Parameter] + public string? OutFile { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.GetContainerArchiveAsync(Name, Path).GetAwaiter().GetResult(); + WritePodmanStream(result, OutFile); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Set, "PodmanContainerArchive")] +[OutputType(typeof(void))] +public sealed class SetPodmanContainerArchiveCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + [Alias("Name", "Id")] + public string ContainerId { get; set; } = null!; + + [Parameter(Mandatory = true, Position = 1)] + public string Path { get; set; } = null!; + + [Parameter] + public string? FilePath { get; set; } + + [Parameter] + public Stream? InputStream { get; set; } + + [Parameter] + public SwitchParameter Pause { get; set; } = true; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var ownsStream = InputStream is null; + var stream = OpenInputStream(FilePath, InputStream); + try { + var result = client.PutContainerArchiveAsync(ContainerId, stream, Path, Pause).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + finally { + if (ownsStream) + stream.Dispose(); + } + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanPutContainerArchive")] +[OutputType(typeof(void))] +public sealed class InvokePodmanPutContainerArchiveCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + [Alias("Name", "Id")] + public string ContainerId { get; set; } = null!; + + [Parameter(Mandatory = true, Position = 1)] + public string Path { get; set; } = null!; + + [Parameter] + public string? FilePath { get; set; } + + [Parameter] + public Stream? InputStream { get; set; } + + [Parameter] + public SwitchParameter Pause { get; set; } = true; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var ownsStream = InputStream is null; + var stream = OpenInputStream(FilePath, InputStream); + try { + var result = client.PutContainerArchiveAsync(ContainerId, stream, Path, Pause).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + finally { + if (ownsStream) + stream.Dispose(); + } + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanContainerAttach")] +[OutputType(typeof(Stream), typeof(string))] +public sealed class InvokePodmanContainerAttachCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + [Parameter] + public SwitchParameter Logs { get; set; } + + [Parameter] + public SwitchParameter Stream { get; set; } = true; + + [Parameter] + public SwitchParameter Stdout { get; set; } = true; + + [Parameter] + public SwitchParameter Stderr { get; set; } = true; + + [Parameter] + public SwitchParameter Stdin { get; set; } + + [Parameter] + public string? DetachKeys { get; set; } + + [Parameter] + public string? OutFile { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.AttachContainerAsync( + Name, + Logs, + Stream, + Stdout, + Stderr, + Stdin, + DetachKeys).GetAwaiter().GetResult(); + WritePodmanStream(result, OutFile); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanContainerSession")] +[OutputType(typeof(string), typeof(IPodmanAttachSession))] +public sealed class InvokePodmanContainerSessionCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + [Parameter] + public SwitchParameter Logs { get; set; } + + [Parameter] + public SwitchParameter Stream { get; set; } = true; + + [Parameter] + public SwitchParameter Stdout { get; set; } = true; + + [Parameter] + public SwitchParameter Stderr { get; set; } = true; + + [Parameter] + public SwitchParameter Stdin { get; set; } = true; + + [Parameter] + public SwitchParameter Tty { get; set; } + + [Parameter] + public string? DetachKeys { get; set; } + + [Parameter] + public bool CollectOutput { get; set; } = true; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.AttachContainerSessionAsync( + Name, + Logs, + Stream, + Stdout, + Stderr, + Stdin, + Tty, + DetachKeys).GetAwaiter().GetResult(); + + if (!result.IsSuccess) { + WritePodmanResult(result); + return; + } + + if (result.Value is null) + return; + + if (!CollectOutput) { + WriteObject(result.Value); + return; + } + + try { + WriteObject(CollectAttachOutput(result.Value)); + } + finally { + result.Value.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanContainerChange")] +[OutputType(typeof(ContainerChangesDto))] +public sealed class GetPodmanContainerChangeCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.GetContainerChangesAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanCommitContainer")] +[OutputType(typeof(ContainerCommitDto))] +public sealed class InvokePodmanCommitContainerCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("Name", "ContainerId", "Id")] + public string Container { get; set; } = null!; + + [Parameter] + public string? Repo { get; set; } + + [Parameter] + public string? Tag { get; set; } + + [Parameter] + public string? Comment { get; set; } + + [Parameter] + public string? Author { get; set; } + + [Parameter] + public SwitchParameter Pause { get; set; } = true; + + [Parameter] + public string[]? Changes { get; set; } + + [Parameter] + public string? Format { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.CommitContainerAsync( + Container, + Repo, + Tag, + Comment, + Author, + Pause, + Changes, + Format).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanContainerHealthCheck")] +[OutputType(typeof(ContainerHealthCheckDto))] +public sealed class InvokePodmanContainerHealthCheckCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.HealthCheckContainerAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanMountedContainer")] +[OutputType(typeof(Dictionary))] +public sealed class GetPodmanMountedContainerCmdlet : PodmanCmdletBase { + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.GetMountedContainersAsync().GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanContainerProcess")] +[OutputType(typeof(ContainerTopDto))] +public sealed class GetPodmanContainerProcessCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + [Parameter] + public string PsArgs { get; set; } = "-ef"; + + [Parameter] + public SwitchParameter Stream { get; set; } = true; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.TopContainerAsync(Name, PsArgs, Stream).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanContainerTop")] +[OutputType(typeof(ContainerTopDto))] +public sealed class GetPodmanContainerTopCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("ContainerId", "Id")] + public string Name { get; set; } = null!; + + [Parameter] + public string PsArgs { get; set; } = "-ef"; + + [Parameter] + public SwitchParameter Stream { get; set; } = true; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.TopContainerAsync(Name, PsArgs, Stream).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} diff --git a/src/PodmanClient.PowerShell/Exec/ExecCmdlets.cs b/src/PodmanClient.PowerShell/Exec/ExecCmdlets.cs new file mode 100644 index 0000000..b40b9bc --- /dev/null +++ b/src/PodmanClient.PowerShell/Exec/ExecCmdlets.cs @@ -0,0 +1,192 @@ +using System.Management.Automation; + +using MaksIT.PodmanClientDotNet.Dtos.Exec; +using MaksIT.PodmanClientDotNet.Streaming; + + +namespace MaksIT.PodmanClientDotNet.PowerShell; + +[Cmdlet(VerbsCommon.New, "PodmanExec")] +[OutputType(typeof(CreateExecResponseDto))] +public sealed class NewPodmanExecCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string ContainerName { get; set; } = null!; + + [Parameter(Mandatory = true, Position = 1)] + public string[] Cmd { get; set; } = null!; + + [Parameter] + public bool AttachStderr { get; set; } = true; + + [Parameter] + public SwitchParameter AttachStdin { get; set; } + + [Parameter] + public bool AttachStdout { get; set; } = true; + + [Parameter] + public string? DetachKeys { get; set; } + + [Parameter] + public string[]? Env { get; set; } + + [Parameter] + public SwitchParameter Privileged { get; set; } + + [Parameter] + public SwitchParameter Tty { get; set; } + + [Parameter] + public string? User { get; set; } + + [Parameter] + public string? WorkingDir { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.CreateExecAsync( + ContainerName, + Cmd, + AttachStderr, + AttachStdin.IsPresent, + AttachStdout, + DetachKeys, + Env, + Privileged.IsPresent, + Tty.IsPresent, + User, + WorkingDir).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Start, "PodmanExec")] +public sealed class StartPodmanExecCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string ExecId { get; set; } = null!; + + [Parameter] + public SwitchParameter Detach { get; set; } + + [Parameter] + public SwitchParameter Tty { get; set; } + + [Parameter] + public int? Height { get; set; } + + [Parameter] + public int? Width { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.StartExecAsync(ExecId, Detach.IsPresent, Tty.IsPresent, Height, Width) + .GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanExec")] +[OutputType(typeof(InspectExecResponseDto))] +public sealed class GetPodmanExecCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string ExecId { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.InspectExecAsync(ExecId).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Resize, "PodmanExec")] +public sealed class ResizePodmanExecCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string ExecId { get; set; } = null!; + + [Parameter(Mandatory = true)] + public int Height { get; set; } + + [Parameter(Mandatory = true)] + public int Width { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.ResizeExecAsync(ExecId, Height, Width).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanExecSession")] +[OutputType(typeof(string))] +[OutputType(typeof(IPodmanAttachSession))] +public sealed class InvokePodmanExecSessionCmdlet : PodmanCmdletBase { + public InvokePodmanExecSessionCmdlet() { + CollectOutput = true; + } + + [Parameter(Mandatory = true, Position = 0)] + public string ExecId { get; set; } = null!; + + [Parameter] + public SwitchParameter Tty { get; set; } + + [Parameter] + public int? Height { get; set; } + + [Parameter] + public int? Width { get; set; } + + [Parameter] + public SwitchParameter CollectOutput { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.StartExecSessionAsync(ExecId, Tty.IsPresent, Height, Width) + .GetAwaiter().GetResult(); + + if (!CollectOutput) { + WritePodmanResult(result); + return; + } + + if (!result.IsSuccess) { + WritePodmanResult(result); + return; + } + + if (result.Value is null) + return; + + try { + WriteObject(CollectAttachOutput(result.Value)); + } + finally { + result.Value.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} diff --git a/src/PodmanClient.PowerShell/Generate/GenerateCmdlets.cs b/src/PodmanClient.PowerShell/Generate/GenerateCmdlets.cs new file mode 100644 index 0000000..e449656 --- /dev/null +++ b/src/PodmanClient.PowerShell/Generate/GenerateCmdlets.cs @@ -0,0 +1,122 @@ +using System.Management.Automation; + +using MaksIT.PodmanClientDotNet.Dtos.Generate; + + +namespace MaksIT.PodmanClientDotNet.PowerShell; + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanGenerateSystemd")] +[OutputType(typeof(GenerateSystemdDto))] +public sealed class InvokePodmanGenerateSystemdCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Name { get; set; } = null!; + + [Parameter] + public SwitchParameter UseName { get; set; } + + [Parameter] + public SwitchParameter CreateNew { get; set; } + + [Parameter] + public int? RestartSec { get; set; } + + [Parameter] + public string? RestartPolicy { get; set; } + + [Parameter] + public string? ContainerPrefix { get; set; } + + [Parameter] + public string? PodPrefix { get; set; } + + [Parameter] + public string? Separator { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.GenerateSystemdAsync( + Name, + UseName.IsPresent, + CreateNew.IsPresent, + RestartSec, + RestartPolicy, + ContainerPrefix, + PodPrefix, + Separator + ).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanGenerateKube")] +[OutputType(typeof(string))] +public sealed class InvokePodmanGenerateKubeCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true)] + public string[] Name { get; set; } = null!; + + [Parameter] + public SwitchParameter Service { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.GenerateKubeAsync(Name, Service.IsPresent).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanPlayKube", DefaultParameterSetName = "Path")] +[OutputType(typeof(PlayKubeReportDto))] +public sealed class InvokePodmanPlayKubeCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, ParameterSetName = "Path", Position = 0)] + public string? Path { get; set; } + + [Parameter(Mandatory = true, ParameterSetName = "InputStream")] + public Stream? InputStream { get; set; } + + [Parameter] + public string? Network { get; set; } + + [Parameter] + public SwitchParameter TlsVerify { get; set; } = true; + + [Parameter] + public SwitchParameter Start { get; set; } = true; + + [Parameter] + public string? LogDriver { get; set; } + + protected override void ProcessRecord() { + Stream? owned = null; + try { + var client = RequireClient(); + var yaml = OpenInputStream(Path, InputStream); + if (!ReferenceEquals(yaml, InputStream)) + owned = yaml; + + var result = client.PlayKubeAsync( + yaml, + Network, + TlsVerify.IsPresent, + Start.IsPresent, + LogDriver + ).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + finally { + owned?.Dispose(); + } + } +} diff --git a/src/PodmanClient.PowerShell/Images/ImagesCmdlets.cs b/src/PodmanClient.PowerShell/Images/ImagesCmdlets.cs new file mode 100644 index 0000000..e389593 --- /dev/null +++ b/src/PodmanClient.PowerShell/Images/ImagesCmdlets.cs @@ -0,0 +1,537 @@ +using System.Management.Automation; + +using MaksIT.PodmanClientDotNet.Dtos.Common; +using MaksIT.PodmanClientDotNet.Dtos.Image; +using MaksIT.PodmanClientDotNet.Streaming; + + +namespace MaksIT.PodmanClientDotNet.PowerShell; + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanPullImage")] +public sealed class InvokePodmanPullImageCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Reference { get; set; } = null!; + + [Parameter] + public bool TlsVerify { get; set; } = true; + + [Parameter] + public SwitchParameter Quiet { get; set; } + + [Parameter] + public string Policy { get; set; } = "always"; + + [Parameter] + public string? Arch { get; set; } + + [Parameter] + public string? Os { get; set; } + + [Parameter] + public string? Variant { get; set; } + + [Parameter] + public SwitchParameter AllTags { get; set; } + + [Parameter] + public string? AuthHeader { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.PullImageAsync( + Reference, + TlsVerify, + Quiet.IsPresent, + Policy, + Arch, + Os, + Variant, + AllTags.IsPresent, + AuthHeader).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanTagImage")] +public sealed class InvokePodmanTagImageCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Image { get; set; } = null!; + + [Parameter(Mandatory = true, Position = 1)] + public string Repo { get; set; } = null!; + + [Parameter(Mandatory = true, Position = 2)] + public string Tag { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.TagImageAsync(Image, Repo, Tag).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanImageList")] +[OutputType(typeof(ImageListEntryDto))] +public sealed class GetPodmanImageListCmdlet : PodmanCmdletBase { + [Parameter] + public SwitchParameter All { get; set; } + + [Parameter] + public string? Filters { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.ListImagesAsync(All.IsPresent, Filters).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanImage")] +[OutputType(typeof(ImageInspectDto))] +public sealed class GetPodmanImageCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.InspectImageAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsDiagnostic.Test, "PodmanImage")] +[OutputType(typeof(bool))] +public sealed class TestPodmanImageCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.ImageExistsAsync(Name).GetAwaiter().GetResult(); + WriteObject(result.IsSuccess); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Remove, "PodmanImage")] +[OutputType(typeof(ImageDeleteDto))] +public sealed class RemovePodmanImageCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Name { get; set; } = null!; + + [Parameter] + public SwitchParameter Force { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.DeleteImageAsync(Name, Force.IsPresent).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Remove, "PodmanImageBatch")] +[OutputType(typeof(ImageDeleteDto))] +public sealed class RemovePodmanImageBatchCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true)] + public string[] Image { get; set; } = null!; + + [Parameter] + public SwitchParameter All { get; set; } + + [Parameter] + public SwitchParameter Force { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.RemoveImagesAsync(Image, All.IsPresent, Force.IsPresent).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanPruneImage")] +[OutputType(typeof(PruneReportEntryDto))] +public sealed class InvokePodmanPruneImageCmdlet : PodmanCmdletBase { + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.PruneImagesAsync().GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Search, "PodmanImage")] +[OutputType(typeof(ImageSearchResultDto))] +public sealed class SearchPodmanImageCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Term { get; set; } = null!; + + [Parameter] + public int? Limit { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.SearchImagesAsync(Term, Limit).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanPushImage")] +public sealed class InvokePodmanPushImageCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Name { get; set; } = null!; + + [Parameter] + public string? Destination { get; set; } + + [Parameter] + public bool TlsVerify { get; set; } = true; + + [Parameter] + public SwitchParameter Compress { get; set; } + + [Parameter] + public string? AuthHeader { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.PushImageAsync(Name, Destination, TlsVerify, Compress.IsPresent, AuthHeader) + .GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanUntagImage")] +public sealed class InvokePodmanUntagImageCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Name { get; set; } = null!; + + [Parameter] + public string? Repo { get; set; } + + [Parameter] + public string? Tag { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.UntagImageAsync(Name, Repo, Tag).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanImageHistory")] +[OutputType(typeof(ImageHistoryEntryDto))] +public sealed class GetPodmanImageHistoryCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.GetImageHistoryAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanImageTree")] +[OutputType(typeof(ImageTreeDto))] +public sealed class GetPodmanImageTreeCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.GetImageTreeAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanImageChange")] +[OutputType(typeof(ImageChangesDto))] +public sealed class GetPodmanImageChangeCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.GetImageChangesAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsData.Import, "PodmanImage")] +[OutputType(typeof(ImageImportDto))] +public sealed class ImportPodmanImageCmdlet : PodmanCmdletBase { + [Parameter] + public string? Path { get; set; } + + [Parameter] + public Stream? InputStream { get; set; } + + [Parameter] + public string? Changes { get; set; } + + [Parameter] + public string? Message { get; set; } + + [Parameter] + public string? Reference { get; set; } + + [Parameter] + public string? Url { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + Stream? stream = null; + var ownsStream = false; + if (InputStream is not null || !string.IsNullOrWhiteSpace(Path)) { + stream = OpenInputStream(Path, InputStream); + ownsStream = InputStream is null; + } + + try { + var result = client.ImportImageAsync(stream, Changes, Message, Reference, Url) + .GetAwaiter().GetResult(); + WritePodmanResult(result); + } + finally { + if (ownsStream) + stream?.Dispose(); + } + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsData.Import, "PodmanImageArchive")] +[OutputType(typeof(ImageLoadDto))] +public sealed class ImportPodmanImageArchiveCmdlet : PodmanCmdletBase { + [Parameter] + public string? Path { get; set; } + + [Parameter] + public Stream? InputStream { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var stream = OpenInputStream(Path, InputStream); + var ownsStream = InputStream is null; + try { + var result = client.LoadImageAsync(stream).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + finally { + if (ownsStream) + stream.Dispose(); + } + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsData.Export, "PodmanImage")] +[OutputType(typeof(Stream))] +[OutputType(typeof(string))] +public sealed class ExportPodmanImageCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true)] + public string[] Reference { get; set; } = null!; + + [Parameter] + public string? Format { get; set; } + + [Parameter] + public SwitchParameter Compress { get; set; } + + [Parameter] + public string? OutFile { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.ExportImagesAsync(Reference, Format, Compress.IsPresent).GetAwaiter().GetResult(); + WritePodmanStream(result, OutFile); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsData.Save, "PodmanImage")] +[OutputType(typeof(Stream))] +[OutputType(typeof(string))] +public sealed class SavePodmanImageCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Name { get; set; } = null!; + + [Parameter] + public string? Format { get; set; } + + [Parameter] + public SwitchParameter Compress { get; set; } + + [Parameter] + public string? OutFile { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.GetImageAsync(Name, Format, Compress.IsPresent).GetAwaiter().GetResult(); + WritePodmanStream(result, OutFile); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanPullImageProgress")] +[OutputType(typeof(PullImageResponseDto))] +[OutputType(typeof(IPodmanProgressSession))] +public sealed class InvokePodmanPullImageProgressCmdlet : PodmanCmdletBase { + public InvokePodmanPullImageProgressCmdlet() { + Wait = true; + } + + [Parameter(Mandatory = true, Position = 0)] + public string Reference { get; set; } = null!; + + [Parameter] + public bool TlsVerify { get; set; } = true; + + [Parameter] + public SwitchParameter Quiet { get; set; } + + [Parameter] + public string Policy { get; set; } = "always"; + + [Parameter] + public string? Arch { get; set; } + + [Parameter] + public string? Os { get; set; } + + [Parameter] + public string? Variant { get; set; } + + [Parameter] + public SwitchParameter AllTags { get; set; } + + [Parameter] + public string? AuthHeader { get; set; } + + [Parameter] + public SwitchParameter Wait { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.PullImageWithProgressAsync( + Reference, + TlsVerify, + Quiet.IsPresent, + Policy, + Arch, + Os, + Variant, + AllTags.IsPresent, + AuthHeader).GetAwaiter().GetResult(); + + if (!Wait) { + WritePodmanResult(result); + return; + } + + if (!result.IsSuccess) { + WritePodmanResult(result); + return; + } + + if (result.Value is null) + return; + + try { + foreach (var item in CollectProgress(result.Value)) + WriteObject(item); + } + finally { + result.Value.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} diff --git a/src/PodmanClient.PowerShell/MaksIT.PodmanClientDotNet.PowerShell.psd1 b/src/PodmanClient.PowerShell/MaksIT.PodmanClientDotNet.PowerShell.psd1 new file mode 100644 index 0000000..0c057a0 --- /dev/null +++ b/src/PodmanClient.PowerShell/MaksIT.PodmanClientDotNet.PowerShell.psd1 @@ -0,0 +1,125 @@ +@{ + RootModule = 'MaksIT.PodmanClientDotNet.PowerShell.dll' + ModuleVersion = '1.3.0' + GUID = 'b7e4c2a1-9f3d-4e8b-a6c5-1d2e3f4a5b6c' + Author = 'Maksym Sadovnychyy' + CompanyName = 'MAKS-IT' + Description = 'PowerShell cmdlets for PodmanClient.DotNet. Use Connect-Podman -BaseAddress, then domain cmdlets mirroring IPodmanClient.' + PowerShellVersion = '7.0' + RequiredModules = @() + RequiredAssemblies = @('MaksIT.PodmanClientDotNet.PowerShell.dll') + FunctionsToExport = @() + CmdletsToExport = @( + 'Add-PodmanManifest', + 'Checkpoint-PodmanContainer', + 'Connect-Podman', + 'Connect-PodmanNetwork', + 'Disconnect-Podman', + 'Disconnect-PodmanNetwork', + 'Dismount-PodmanContainer', + 'Export-PodmanContainer', + 'Export-PodmanImage', + 'Get-PodmanContainer', + 'Get-PodmanContainerArchive', + 'Get-PodmanContainerChange', + 'Get-PodmanContainerList', + 'Get-PodmanContainerLog', + 'Get-PodmanContainerProcess', + 'Get-PodmanContainerStat', + 'Get-PodmanContainerStatBatch', + 'Get-PodmanContainerTop', + 'Get-PodmanEvent', + 'Get-PodmanExec', + 'Get-PodmanImage', + 'Get-PodmanImageChange', + 'Get-PodmanImageHistory', + 'Get-PodmanImageList', + 'Get-PodmanImageTree', + 'Get-PodmanInfo', + 'Get-PodmanManifest', + 'Get-PodmanMountedContainer', + 'Get-PodmanNetwork', + 'Get-PodmanNetworkList', + 'Get-PodmanPod', + 'Get-PodmanPodList', + 'Get-PodmanPodStat', + 'Get-PodmanPodTop', + 'Get-PodmanSystemDiskUsage', + 'Get-PodmanVersion', + 'Get-PodmanVolume', + 'Get-PodmanVolumeList', + 'Import-PodmanImage', + 'Import-PodmanImageArchive', + 'Initialize-PodmanContainer', + 'Invoke-PodmanBuildImage', + 'Invoke-PodmanBuildImageProgress', + 'Invoke-PodmanCommitContainer', + 'Invoke-PodmanContainerAttach', + 'Invoke-PodmanContainerHealthCheck', + 'Invoke-PodmanContainerSession', + 'Invoke-PodmanExecSession', + 'Invoke-PodmanExtractArchive', + 'Invoke-PodmanGenerateKube', + 'Invoke-PodmanGenerateSystemd', + 'Invoke-PodmanPlayKube', + 'Invoke-PodmanPruneContainer', + 'Invoke-PodmanPruneImage', + 'Invoke-PodmanPrunePod', + 'Invoke-PodmanPruneSystem', + 'Invoke-PodmanPruneVolume', + 'Invoke-PodmanPullImage', + 'Invoke-PodmanPullImageProgress', + 'Invoke-PodmanPushImage', + 'Invoke-PodmanPushManifest', + 'Invoke-PodmanPutContainerArchive', + 'Invoke-PodmanTagImage', + 'Invoke-PodmanUntagImage', + 'Kill-PodmanContainer', + 'Kill-PodmanPod', + 'Mount-PodmanContainer', + 'New-PodmanContainer', + 'New-PodmanExec', + 'New-PodmanManifest', + 'New-PodmanNetwork', + 'New-PodmanPod', + 'New-PodmanVolume', + 'Publish-PodmanManifest', + 'Remove-PodmanContainer', + 'Remove-PodmanImage', + 'Remove-PodmanImageBatch', + 'Remove-PodmanManifest', + 'Remove-PodmanNetwork', + 'Remove-PodmanPod', + 'Remove-PodmanVolume', + 'Rename-PodmanContainer', + 'Resize-PodmanExec', + 'Restart-PodmanContainer', + 'Restart-PodmanPod', + 'Restore-PodmanContainer', + 'Resume-PodmanContainer', + 'Resume-PodmanPod', + 'Save-PodmanImage', + 'Search-PodmanImage', + 'Set-PodmanContainerArchive', + 'Start-PodmanContainer', + 'Start-PodmanExec', + 'Start-PodmanPod', + 'Stop-PodmanContainer', + 'Stop-PodmanPod', + 'Suspend-PodmanContainer', + 'Suspend-PodmanPod', + 'Test-PodmanConnection', + 'Test-PodmanContainer', + 'Test-PodmanImage', + 'Test-PodmanPod', + 'Wait-PodmanContainer' + ) + VariablesToExport = @() + AliasesToExport = @() + PrivateData = @{ + PSData = @{ + Tags = @('Podman', 'Container', 'MaksIT', 'API') + ProjectUri = 'https://github.com/MAKS-IT-COM/podman-client-dotnet' + } + } +} diff --git a/src/PodmanClient.PowerShell/Manifests/ManifestsCmdlets.cs b/src/PodmanClient.PowerShell/Manifests/ManifestsCmdlets.cs new file mode 100644 index 0000000..509f3f7 --- /dev/null +++ b/src/PodmanClient.PowerShell/Manifests/ManifestsCmdlets.cs @@ -0,0 +1,164 @@ +using System.Management.Automation; + +using MaksIT.PodmanClientDotNet.Dtos.Manifest; + + +namespace MaksIT.PodmanClientDotNet.PowerShell; + +[Cmdlet(VerbsCommon.New, "PodmanManifest")] +[OutputType(typeof(ManifestCreateDto))] +public sealed class NewPodmanManifestCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Name { get; set; } = null!; + + [Parameter] + public string? Image { get; set; } + + [Parameter] + public SwitchParameter All { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.CreateManifestAsync(Name, Image, All.IsPresent).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Remove, "PodmanManifest", SupportsShouldProcess = true)] +[OutputType(typeof(void))] +public sealed class RemovePodmanManifestCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Name { get; set; } = null!; + + [Parameter] + public string? Digest { get; set; } + + protected override void ProcessRecord() { + try { + if (!ShouldProcess(Name, "Remove Podman manifest")) + return; + + var client = RequireClient(); + var result = client.DeleteManifestAsync(Name, Digest).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanManifest")] +[OutputType(typeof(ManifestInspectDto))] +public sealed class GetPodmanManifestCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.InspectManifestAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Add, "PodmanManifest")] +[OutputType(typeof(void))] +public sealed class AddPodmanManifestCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Name { get; set; } = null!; + + [Parameter(Position = 1)] + public string? Image { get; set; } + + [Parameter] + public SwitchParameter All { get; set; } + + [Parameter] + public string? Operation { get; set; } + + [Parameter] + public ManifestAddRequestDto? Request { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var request = Request ?? new ManifestAddRequestDto { + Images = string.IsNullOrWhiteSpace(Image) ? null : [Image], + Image = Image, + All = All.IsPresent, + Operation = Operation ?? "update", + }; + if (Request is null && (request.Images is null || request.Images.Count == 0) && string.IsNullOrWhiteSpace(request.Image)) + throw new ArgumentException("Specify -Image or -Request."); + + if (request.Images is null && !string.IsNullOrWhiteSpace(request.Image)) + request.Images = [request.Image]; + if (string.IsNullOrWhiteSpace(request.Operation)) + request.Operation = "update"; + + var result = client.AddToManifestAsync(Name, request).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsData.Publish, "PodmanManifest")] +[OutputType(typeof(void))] +public sealed class PublishPodmanManifestCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Name { get; set; } = null!; + + [Parameter(Mandatory = true, Position = 1)] + public string Destination { get; set; } = null!; + + [Parameter] + public SwitchParameter All { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.PushManifestAsync(Name, Destination, All.IsPresent).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanPushManifest")] +[OutputType(typeof(void))] +public sealed class InvokePodmanPushManifestCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Name { get; set; } = null!; + + [Parameter(Mandatory = true, Position = 1)] + public string Destination { get; set; } = null!; + + [Parameter] + public SwitchParameter All { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.PushManifestAsync(Name, Destination, All.IsPresent).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} diff --git a/src/PodmanClient.PowerShell/Networks/NetworksCmdlets.cs b/src/PodmanClient.PowerShell/Networks/NetworksCmdlets.cs new file mode 100644 index 0000000..c3018af --- /dev/null +++ b/src/PodmanClient.PowerShell/Networks/NetworksCmdlets.cs @@ -0,0 +1,147 @@ +using System.Management.Automation; + +using MaksIT.PodmanClientDotNet.Dtos.Network; +using MaksIT.PodmanClientDotNet.Models.Network; + + +namespace MaksIT.PodmanClientDotNet.PowerShell; + +[Cmdlet(VerbsCommon.New, "PodmanNetwork")] +[OutputType(typeof(NetworkListEntryDto))] +public sealed class NewPodmanNetworkCmdlet : PodmanCmdletBase { + [Parameter(Position = 0)] + public string? Name { get; set; } + + [Parameter] + public NetworkCreateRequest? Request { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var request = Request ?? new NetworkCreateRequest(); + if (!string.IsNullOrWhiteSpace(Name)) + request.Name = Name; + + var result = client.CreateNetworkAsync(request).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanNetworkList")] +[OutputType(typeof(NetworkListEntryDto))] +public sealed class GetPodmanNetworkListCmdlet : PodmanCmdletBase { + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.ListNetworksAsync().GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanNetwork")] +[OutputType(typeof(NetworkInspectDto))] +public sealed class GetPodmanNetworkCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.InspectNetworkAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Remove, "PodmanNetwork", SupportsShouldProcess = true)] +[OutputType(typeof(void))] +public sealed class RemovePodmanNetworkCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + if (!ShouldProcess(Name, "Remove Podman network")) + return; + + var client = RequireClient(); + var result = client.DeleteNetworkAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommunications.Connect, "PodmanNetwork")] +[OutputType(typeof(void))] +public sealed class ConnectPodmanNetworkCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Name { get; set; } = null!; + + [Parameter(Position = 1)] + public string? Container { get; set; } + + [Parameter] + public NetworkConnectRequest? Request { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var request = Request ?? new NetworkConnectRequest(); + if (!string.IsNullOrWhiteSpace(Container)) + request.Container = Container; + + var result = client.ConnectNetworkAsync(Name, request).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommunications.Disconnect, "PodmanNetwork")] +[OutputType(typeof(void))] +public sealed class DisconnectPodmanNetworkCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0)] + public string Name { get; set; } = null!; + + [Parameter(Position = 1)] + public string? Container { get; set; } + + [Parameter] + public SwitchParameter Force { get; set; } + + [Parameter] + public NetworkDisconnectRequest? Request { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var request = Request ?? new NetworkDisconnectRequest(); + if (!string.IsNullOrWhiteSpace(Container)) + request.Container = Container; + if (Force.IsPresent) + request.Force = true; + + var result = client.DisconnectNetworkAsync(Name, request).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} diff --git a/src/PodmanClient.PowerShell/PodmanClient.PowerShell.csproj b/src/PodmanClient.PowerShell/PodmanClient.PowerShell.csproj new file mode 100644 index 0000000..ebaab68 --- /dev/null +++ b/src/PodmanClient.PowerShell/PodmanClient.PowerShell.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + MaksIT.PodmanClientDotNet.PowerShell + MaksIT.PodmanClientDotNet.PowerShell + PowerShell module with cmdlets for PodmanClient.DotNet. + 1.3.0 + Maksym Sadovnychyy + MAKS-IT + true + true + $(NoWarn);CS1591 + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/src/PodmanClient.PowerShell/PodmanCmdletBase.cs b/src/PodmanClient.PowerShell/PodmanCmdletBase.cs new file mode 100644 index 0000000..a657265 --- /dev/null +++ b/src/PodmanClient.PowerShell/PodmanCmdletBase.cs @@ -0,0 +1,111 @@ +using System.Management.Automation; +using System.Text; + +using MaksIT.PodmanClientDotNet.Streaming; +using MaksIT.Results; + +namespace MaksIT.PodmanClientDotNet.PowerShell; + +public abstract class PodmanCmdletBase : PSCmdlet { + protected IPodmanClient RequireClient() { + var client = PodmanConnectionState.Client; + if (client is null) { + ThrowTerminatingError(new ErrorRecord( + new InvalidOperationException("Not connected. Run Connect-Podman -BaseAddress first."), + "NotConnected", + ErrorCategory.InvalidOperation, + null)); + } + + return client; + } + + protected void WritePodmanResult(Result result) { + if (result.IsSuccess) + return; + + WriteError(new ErrorRecord( + new InvalidOperationException(string.Join("; ", result.Messages)), + "PodmanApiError", + ErrorCategory.InvalidOperation, + null)); + } + + protected void WritePodmanResult(Result result) { + if (!result.IsSuccess) { + WriteError(new ErrorRecord( + new InvalidOperationException(string.Join("; ", result.Messages)), + "PodmanApiError", + ErrorCategory.InvalidOperation, + null)); + return; + } + + if (result.Value is null) + return; + + // Avoid PowerShell enumerating list results onto the host unexpectedly. + if (result.Value is System.Collections.IEnumerable and not string and not System.Collections.IDictionary) + WriteObject(result.Value, enumerateCollection: false); + else + WriteObject(result.Value); + } + + protected void WritePodmanStream(Result result, string? outFile) { + if (!result.IsSuccess) { + WriteError(new ErrorRecord( + new InvalidOperationException(string.Join("; ", result.Messages)), + "PodmanApiError", + ErrorCategory.InvalidOperation, + null)); + return; + } + + if (result.Value is null) + return; + + if (!string.IsNullOrWhiteSpace(outFile)) { + using (result.Value) + using (var fs = File.Create(outFile)) + result.Value.CopyTo(fs); + WriteObject(outFile); + return; + } + + WriteObject(result.Value); + } + + protected static Stream OpenInputStream(string? path, Stream? inputStream) { + if (inputStream is not null) + return inputStream; + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Specify -Path or -InputStream."); + return File.OpenRead(path); + } + + protected static string CollectAttachOutput(IPodmanAttachSession session) { + var output = new StringBuilder(); + while (true) { + var frame = session.ReadFrameAsync().GetAwaiter().GetResult(); + if (frame is null) + break; + output.Append(Encoding.UTF8.GetString(frame.Data)); + } + + return output.ToString(); + } + + protected static List CollectProgress(IPodmanProgressSession session) { + var items = new List(); + var enumerator = session.ReadProgressAsync().GetAsyncEnumerator(); + try { + while (enumerator.MoveNextAsync().AsTask().GetAwaiter().GetResult()) + items.Add(enumerator.Current); + } + finally { + enumerator.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + + return items; + } +} diff --git a/src/PodmanClient.PowerShell/PodmanConnectionState.cs b/src/PodmanClient.PowerShell/PodmanConnectionState.cs new file mode 100644 index 0000000..e13bff0 --- /dev/null +++ b/src/PodmanClient.PowerShell/PodmanConnectionState.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace MaksIT.PodmanClientDotNet.PowerShell; + +/// Holds the current Podman client for the PowerShell session. +internal static class PodmanConnectionState { + private static readonly Lock Lock = new(); + private static HttpClient? _httpClient; + + public static IPodmanClient? Client { get; private set; } + + public static void SetConnection(string baseAddress, int timeoutMinutes = 60, string? apiVersion = null) { + lock (Lock) { + _httpClient?.Dispose(); + _httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(Math.Max(1, timeoutMinutes)) }; + var configuration = new PodmanClientSessionConfiguration { + ServerUrl = baseAddress, + ApiVersion = string.IsNullOrWhiteSpace(apiVersion) ? "v5.4.0" : apiVersion, + TimeoutMinutes = timeoutMinutes + }; + Client = new PodmanClient(_httpClient, NullLogger.Instance, configuration); + } + } + + public static void ClearConnection() { + lock (Lock) { + Client = null; + _httpClient?.Dispose(); + _httpClient = null; + } + } + + private sealed class PodmanClientSessionConfiguration : IPodmanClientConfiguration { + public string ServerUrl { get; set; } = ""; + public string ApiVersion { get; set; } = "v5.4.0"; + public int TimeoutMinutes { get; set; } = 60; + } +} diff --git a/src/PodmanClient.PowerShell/Pods/PodsCmdlets.cs b/src/PodmanClient.PowerShell/Pods/PodsCmdlets.cs new file mode 100644 index 0000000..a95825e --- /dev/null +++ b/src/PodmanClient.PowerShell/Pods/PodsCmdlets.cs @@ -0,0 +1,276 @@ +using System.Management.Automation; + +using MaksIT.PodmanClientDotNet.Dtos.Common; +using MaksIT.PodmanClientDotNet.Dtos.Pod; +using MaksIT.PodmanClientDotNet.Models.Pod; + + +namespace MaksIT.PodmanClientDotNet.PowerShell; + +[Cmdlet(VerbsCommon.New, "PodmanPod")] +[OutputType(typeof(PodListEntryDto))] +public sealed class NewPodmanPodCmdlet : PodmanCmdletBase { + [Parameter(Position = 0)] + public string? Name { get; set; } + + [Parameter] + public PodCreateRequest? Request { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var request = Request ?? new PodCreateRequest(); + if (!string.IsNullOrWhiteSpace(Name)) + request.Name = Name; + + var result = client.CreatePodAsync(request).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanPodList")] +[OutputType(typeof(PodListEntryDto))] +public sealed class GetPodmanPodListCmdlet : PodmanCmdletBase { + [Parameter] + public SwitchParameter All { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.ListPodsAsync(All.IsPresent).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanPod")] +[OutputType(typeof(PodInspectDto))] +public sealed class GetPodmanPodCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.InspectPodAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsDiagnostic.Test, "PodmanPod")] +[OutputType(typeof(bool))] +public sealed class TestPodmanPodCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.PodExistsAsync(Name).GetAwaiter().GetResult(); + WriteObject(result.IsSuccess); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Remove, "PodmanPod", SupportsShouldProcess = true)] +[OutputType(typeof(void))] +public sealed class RemovePodmanPodCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Name { get; set; } = null!; + + [Parameter] + public SwitchParameter Force { get; set; } + + protected override void ProcessRecord() { + try { + if (!ShouldProcess(Name, "Remove Podman pod")) + return; + + var client = RequireClient(); + var result = client.DeletePodAsync(Name, Force.IsPresent).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Start, "PodmanPod")] +[OutputType(typeof(void))] +public sealed class StartPodmanPodCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.StartPodAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Stop, "PodmanPod")] +[OutputType(typeof(void))] +public sealed class StopPodmanPodCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Name { get; set; } = null!; + + [Parameter] + public int Timeout { get; set; } = 10; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.StopPodAsync(Name, Timeout).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Restart, "PodmanPod")] +[OutputType(typeof(void))] +public sealed class RestartPodmanPodCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Name { get; set; } = null!; + + [Parameter] + public int Timeout { get; set; } = 10; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.RestartPodAsync(Name, Timeout).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet("Kill", "PodmanPod")] +[OutputType(typeof(void))] +public sealed class KillPodmanPodCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Name { get; set; } = null!; + + [Parameter] + public string? Signal { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.KillPodAsync(Name, Signal).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Suspend, "PodmanPod")] +[OutputType(typeof(void))] +public sealed class SuspendPodmanPodCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.PausePodAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Resume, "PodmanPod")] +[OutputType(typeof(void))] +public sealed class ResumePodmanPodCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.UnpausePodAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanPrunePod")] +[OutputType(typeof(PruneReportEntryDto))] +public sealed class InvokePodmanPrunePodCmdlet : PodmanCmdletBase { + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.PrunePodsAsync().GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanPodTop")] +[OutputType(typeof(PodTopDto))] +public sealed class GetPodmanPodTopCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.TopPodAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanPodStat")] +[OutputType(typeof(PodStatsDto))] +public sealed class GetPodmanPodStatCmdlet : PodmanCmdletBase { + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.GetPodsStatsAsync().GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} diff --git a/src/PodmanClient.PowerShell/README.md b/src/PodmanClient.PowerShell/README.md new file mode 100644 index 0000000..7a35ceb --- /dev/null +++ b/src/PodmanClient.PowerShell/README.md @@ -0,0 +1,21 @@ +# MaksIT.PodmanClientDotNet.PowerShell + +Binary PowerShell module wrapping **PodmanClient.DotNet** (`IPodmanClient`). + +## Requirements + +- PowerShell 7 hosted on **.NET 10** +- Reachable Podman REST API (`podman system service`) — E2E baseline **Podman 5.4.0** (`Connect-Podman` default `ApiVersion` = `v5.4.0`) + +## Quick start + +```powershell +dotnet build src/PodmanClient.PowerShell/PodmanClient.PowerShell.csproj +Import-Module .\src\PodmanClient.PowerShell\bin\Debug\net10.0\MaksIT.PodmanClientDotNet.PowerShell.psd1 -Force +Connect-Podman -BaseAddress 'http://192.168.2.128:8080' -ApiVersion 'v5.4.0' +Test-PodmanConnection +Get-PodmanVersion +Disconnect-Podman +``` + +Cmdlets mirror the .NET client domains (system, images, containers, exec, volumes, networks, pods, build, manifests, generate). See `CmdletsToExport` in the `.psd1` and live E2E under `src/e2e-tests/`. diff --git a/src/PodmanClient.PowerShell/System/SystemCmdlets.cs b/src/PodmanClient.PowerShell/System/SystemCmdlets.cs new file mode 100644 index 0000000..7e58442 --- /dev/null +++ b/src/PodmanClient.PowerShell/System/SystemCmdlets.cs @@ -0,0 +1,135 @@ +using System.Management.Automation; + +using MaksIT.PodmanClientDotNet.Dtos.Common; +using MaksIT.PodmanClientDotNet.Dtos.System; + + +namespace MaksIT.PodmanClientDotNet.PowerShell; + +[Cmdlet(VerbsDiagnostic.Test, "PodmanConnection")] +[OutputType(typeof(LibpodPingDto))] +public sealed class TestPodmanConnectionCmdlet : PodmanCmdletBase { + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.PingAsync().GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanVersion")] +[OutputType(typeof(LibpodVersionDto))] +public sealed class GetPodmanVersionCmdlet : PodmanCmdletBase { + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.GetVersionAsync().GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanInfo")] +[OutputType(typeof(InfoDto))] +public sealed class GetPodmanInfoCmdlet : PodmanCmdletBase { + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.GetInfoAsync().GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanSystemDiskUsage")] +[OutputType(typeof(SystemDfDto))] +public sealed class GetPodmanSystemDiskUsageCmdlet : PodmanCmdletBase { + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.GetSystemDiskUsageAsync().GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanPruneSystem")] +[OutputType(typeof(SystemPruneReportDto))] +public sealed class InvokePodmanPruneSystemCmdlet : PodmanCmdletBase { + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.PruneSystemAsync().GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanEvent")] +[OutputType(typeof(Stream))] +[OutputType(typeof(string))] +public sealed class GetPodmanEventCmdlet : PodmanCmdletBase { + [Parameter] + public string? OutFile { get; set; } + + /// Max time to read from the events stream when is set (stream is otherwise open-ended). + [Parameter] + public int ReadTimeoutSeconds { get; set; } = 2; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.GetEventsAsync().GetAwaiter().GetResult(); + if (!result.IsSuccess) { + WritePodmanResult(result); + return; + } + + if (result.Value is null) + return; + + if (string.IsNullOrWhiteSpace(OutFile)) { + WriteObject(result.Value); + return; + } + + using (result.Value) + using (var fs = File.Create(OutFile)) { + var buffer = new byte[8192]; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(Math.Max(1, ReadTimeoutSeconds))); + try { + while (true) { + var read = result.Value.ReadAsync(buffer.AsMemory(0, buffer.Length), cts.Token).AsTask().GetAwaiter().GetResult(); + if (read <= 0) + break; + fs.Write(buffer, 0, read); + } + } + catch (OperationCanceledException) { + // Timed sample of the open-ended events stream. + } + } + + WriteObject(OutFile); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} diff --git a/src/PodmanClient.PowerShell/Volumes/VolumesCmdlets.cs b/src/PodmanClient.PowerShell/Volumes/VolumesCmdlets.cs new file mode 100644 index 0000000..7435926 --- /dev/null +++ b/src/PodmanClient.PowerShell/Volumes/VolumesCmdlets.cs @@ -0,0 +1,108 @@ +using System.Management.Automation; + +using MaksIT.PodmanClientDotNet.Dtos.Common; +using MaksIT.PodmanClientDotNet.Dtos.Volume; +using MaksIT.PodmanClientDotNet.Models.Volume; + + +namespace MaksIT.PodmanClientDotNet.PowerShell; + +[Cmdlet(VerbsCommon.New, "PodmanVolume")] +[OutputType(typeof(VolumeInspectResponseDto))] +public sealed class NewPodmanVolumeCmdlet : PodmanCmdletBase { + [Parameter(Position = 0)] + public string? Name { get; set; } + + [Parameter] + public string? Driver { get; set; } + + [Parameter] + public CreateVolumeRequest? Request { get; set; } + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var request = Request ?? new CreateVolumeRequest { + Name = Name, + Driver = Driver, + }; + var result = client.CreateVolumeAsync(request).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanVolumeList")] +[OutputType(typeof(VolumeListEntryDto))] +public sealed class GetPodmanVolumeListCmdlet : PodmanCmdletBase { + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.ListVolumesAsync().GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Get, "PodmanVolume")] +[OutputType(typeof(VolumeInspectResponseDto))] +public sealed class GetPodmanVolumeCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Name { get; set; } = null!; + + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.InspectVolumeAsync(Name).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsCommon.Remove, "PodmanVolume", SupportsShouldProcess = true)] +[OutputType(typeof(void))] +public sealed class RemovePodmanVolumeCmdlet : PodmanCmdletBase { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + public string Name { get; set; } = null!; + + [Parameter] + public SwitchParameter Force { get; set; } + + protected override void ProcessRecord() { + try { + if (!ShouldProcess(Name, "Remove Podman volume")) + return; + + var client = RequireClient(); + var result = client.DeleteVolumeAsync(Name, Force.IsPresent).GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} + +[Cmdlet(VerbsLifecycle.Invoke, "PodmanPruneVolume")] +[OutputType(typeof(PruneReportEntryDto))] +public sealed class InvokePodmanPruneVolumeCmdlet : PodmanCmdletBase { + protected override void ProcessRecord() { + try { + var client = RequireClient(); + var result = client.PruneVolumesAsync().GetAwaiter().GetResult(); + WritePodmanResult(result); + } + catch (Exception ex) { + WriteError(new ErrorRecord(ex, "PodmanApiError", ErrorCategory.NotSpecified, null)); + } + } +} diff --git a/src/PodmanClient/Abstractions/IPodmanContainersClient.cs b/src/PodmanClient/Abstractions/IPodmanContainersClient.cs index f27e50e..e34802e 100644 --- a/src/PodmanClient/Abstractions/IPodmanContainersClient.cs +++ b/src/PodmanClient/Abstractions/IPodmanContainersClient.cs @@ -1,7 +1,6 @@ using MaksIT.PodmanClientDotNet.Dtos.Common; using MaksIT.PodmanClientDotNet.Dtos.Container; using MaksIT.PodmanClientDotNet.Models; -using MaksIT.PodmanClientDotNet.Models.Container; using MaksIT.PodmanClientDotNet.Streaming; using MaksIT.Results; @@ -163,13 +162,13 @@ public interface IPodmanContainersClient { ); Task> GetContainerStatsAsync(string name, bool stream = false, CancellationToken cancellationToken = default); - Task?>> GetContainersStatsAsync( + Task> GetContainersStatsAsync( IEnumerable? containers = null, bool stream = false, CancellationToken cancellationToken = default ); - Task> PruneContainersAsync(string? filters = null, CancellationToken cancellationToken = default); + Task?>> PruneContainersAsync(string? filters = null, CancellationToken cancellationToken = default); Task RenameContainerAsync(string name, string newName, CancellationToken cancellationToken = default); Task InitContainerAsync(string name, CancellationToken cancellationToken = default); Task> CheckpointContainerAsync( @@ -239,6 +238,6 @@ public interface IPodmanContainersClient { ); Task> HealthCheckContainerAsync(string name, CancellationToken cancellationToken = default); - Task> GetMountedContainersAsync(CancellationToken cancellationToken = default); + Task>?>> GetMountedContainersAsync(CancellationToken cancellationToken = default); Task> TopContainerAsync(string name, string psArgs = "-ef", bool stream = true, CancellationToken cancellationToken = default); } diff --git a/src/PodmanClient/Abstractions/IPodmanImagesClient.cs b/src/PodmanClient/Abstractions/IPodmanImagesClient.cs index e97afd0..7233854 100644 --- a/src/PodmanClient/Abstractions/IPodmanImagesClient.cs +++ b/src/PodmanClient/Abstractions/IPodmanImagesClient.cs @@ -29,15 +29,15 @@ public interface IPodmanImagesClient { Task> InspectImageAsync(string name, CancellationToken cancellationToken = default); Task ImageExistsAsync(string name, CancellationToken cancellationToken = default); - Task> DeleteImageAsync(string name, bool force = false, CancellationToken cancellationToken = default); - Task> RemoveImagesAsync( + Task> DeleteImageAsync(string name, bool force = false, CancellationToken cancellationToken = default); + Task> RemoveImagesAsync( IEnumerable images, bool all = false, bool force = false, CancellationToken cancellationToken = default ); - Task> PruneImagesAsync(CancellationToken cancellationToken = default); + Task?>> PruneImagesAsync(CancellationToken cancellationToken = default); Task?>> SearchImagesAsync(string term, int? limit = null, CancellationToken cancellationToken = default); Task PushImageAsync( string name, diff --git a/src/PodmanClient/Abstractions/IPodmanPodsClient.cs b/src/PodmanClient/Abstractions/IPodmanPodsClient.cs index 1fd9e39..eb5d1b6 100644 --- a/src/PodmanClient/Abstractions/IPodmanPodsClient.cs +++ b/src/PodmanClient/Abstractions/IPodmanPodsClient.cs @@ -18,7 +18,7 @@ public interface IPodmanPodsClient { Task KillPodAsync(string name, string? signal = null, CancellationToken cancellationToken = default); Task PausePodAsync(string name, CancellationToken cancellationToken = default); Task UnpausePodAsync(string name, CancellationToken cancellationToken = default); - Task> PrunePodsAsync(CancellationToken cancellationToken = default); + Task?>> PrunePodsAsync(CancellationToken cancellationToken = default); Task> TopPodAsync(string name, CancellationToken cancellationToken = default); - Task> GetPodsStatsAsync(CancellationToken cancellationToken = default); + Task?>> GetPodsStatsAsync(CancellationToken cancellationToken = default); } diff --git a/src/PodmanClient/Abstractions/IPodmanSystemClient.cs b/src/PodmanClient/Abstractions/IPodmanSystemClient.cs index e0df109..433919b 100644 --- a/src/PodmanClient/Abstractions/IPodmanSystemClient.cs +++ b/src/PodmanClient/Abstractions/IPodmanSystemClient.cs @@ -10,6 +10,6 @@ public interface IPodmanSystemClient { Task> GetVersionAsync(CancellationToken cancellationToken = default); Task> GetInfoAsync(CancellationToken cancellationToken = default); Task> GetSystemDiskUsageAsync(CancellationToken cancellationToken = default); - Task> PruneSystemAsync(CancellationToken cancellationToken = default); + Task> PruneSystemAsync(CancellationToken cancellationToken = default); Task> GetEventsAsync(CancellationToken cancellationToken = default); } diff --git a/src/PodmanClient/Abstractions/IPodmanVolumesClient.cs b/src/PodmanClient/Abstractions/IPodmanVolumesClient.cs index bb1b12c..7499806 100644 --- a/src/PodmanClient/Abstractions/IPodmanVolumesClient.cs +++ b/src/PodmanClient/Abstractions/IPodmanVolumesClient.cs @@ -11,5 +11,5 @@ public interface IPodmanVolumesClient { Task?>> ListVolumesAsync(CancellationToken cancellationToken = default); Task> InspectVolumeAsync(string name, CancellationToken cancellationToken = default); Task DeleteVolumeAsync(string name, bool force = false, CancellationToken cancellationToken = default); - Task> PruneVolumesAsync(CancellationToken cancellationToken = default); + Task?>> PruneVolumesAsync(CancellationToken cancellationToken = default); } diff --git a/src/PodmanClient/Dtos/Common/PruneReportDto.cs b/src/PodmanClient/Dtos/Common/PruneReportDto.cs index b167cdf..7065eec 100644 --- a/src/PodmanClient/Dtos/Common/PruneReportDto.cs +++ b/src/PodmanClient/Dtos/Common/PruneReportDto.cs @@ -1,6 +1,13 @@ namespace MaksIT.PodmanClientDotNet.Dtos.Common; -/// Prune operation report (containers, images, volumes, pods, networks). +/// Single entry from libpod prune endpoints that return a JSON array. +public sealed class PruneReportEntryDto { + public string? Id { get; set; } + public long Size { get; set; } + public string? Err { get; set; } +} + +/// Legacy aggregate prune fields (kept for compatibility where applicable). public sealed class PruneReportDto { public string[]? Id { get; set; } public string[]? IdDeleted { get; set; } @@ -12,3 +19,13 @@ public sealed class PruneReportDto { public string[]? ImagesDeleted { get; set; } public string[]? ContainersDeleted { get; set; } } + +/// Response from POST /libpod/system/prune. +public sealed class SystemPruneReportDto { + public List? PodPruneReport { get; set; } + public List? ContainerPruneReports { get; set; } + public List? ImagePruneReports { get; set; } + public List? NetworkPruneReports { get; set; } + public List? VolumePruneReports { get; set; } + public long ReclaimedSpace { get; set; } +} diff --git a/src/PodmanClient/Dtos/Container/ContainerChangesDto.cs b/src/PodmanClient/Dtos/Container/ContainerChangesDto.cs index 210c9a2..5219e57 100644 --- a/src/PodmanClient/Dtos/Container/ContainerChangesDto.cs +++ b/src/PodmanClient/Dtos/Container/ContainerChangesDto.cs @@ -1,5 +1,15 @@ namespace MaksIT.PodmanClientDotNet.Dtos.Container; -/// Podman returns a JSON array of filesystem change paths. -public sealed class ContainerChangesDto : List { +/// +/// Single filesystem change entry from container changes. +/// +public sealed class ContainerChangeEntryDto { + public string? Path { get; set; } + public int Kind { get; set; } +} + +/// +/// Podman returns a JSON array of path/kind change objects. +/// +public sealed class ContainerChangesDto : List { } diff --git a/src/PodmanClient/Dtos/Container/ContainerInspectDto.cs b/src/PodmanClient/Dtos/Container/ContainerInspectDto.cs index 7e0339d..c5b853b 100644 --- a/src/PodmanClient/Dtos/Container/ContainerInspectDto.cs +++ b/src/PodmanClient/Dtos/Container/ContainerInspectDto.cs @@ -1,8 +1,8 @@ namespace MaksIT.PodmanClientDotNet.Dtos.Container; + /// /// Deserialized Podman libpod API payload (Container Inspect). /// - public sealed class ContainerInspectDto { public string? Id { get; set; } public string? Name { get; set; } @@ -21,12 +21,12 @@ public sealed class ContainerInspectDto { public string? Driver { get; set; } public string? OCIConfigPath { get; set; } public string? OCIRuntime { get; set; } - public long Created { get; set; } + public string? Created { get; set; } } + /// /// Deserialized Podman libpod API payload (Container State). /// - public sealed class ContainerStateDto { public string? Status { get; set; } public bool Running { get; set; } @@ -39,12 +39,11 @@ public sealed class ContainerStateDto { public string? Error { get; set; } public string? StartedAt { get; set; } public string? FinishedAt { get; set; } - public string? Health { get; set; } } + /// /// Deserialized Podman libpod API payload (Container Config). /// - public sealed class ContainerConfigDto { public string? Hostname { get; set; } public string? Domainname { get; set; } @@ -52,7 +51,7 @@ public sealed class ContainerConfigDto { public bool AttachStdin { get; set; } public bool AttachStdout { get; set; } public bool AttachStderr { get; set; } - public string? Tty { get; set; } + public bool Tty { get; set; } public bool OpenStdin { get; set; } public bool StdinOnce { get; set; } public string[]? Env { get; set; } diff --git a/src/PodmanClient/Dtos/Container/ContainerListEntryDto.cs b/src/PodmanClient/Dtos/Container/ContainerListEntryDto.cs index 4b81973..1c193f8 100644 --- a/src/PodmanClient/Dtos/Container/ContainerListEntryDto.cs +++ b/src/PodmanClient/Dtos/Container/ContainerListEntryDto.cs @@ -1,18 +1,20 @@ namespace MaksIT.PodmanClientDotNet.Dtos.Container; + /// /// Deserialized Podman libpod API payload (Container List Entry). /// - public sealed class ContainerListEntryDto { public string? Id { get; set; } public string[]? Names { get; set; } public string? Image { get; set; } public string? ImageID { get; set; } - public string? Command { get; set; } - public long Created { get; set; } + public string[]? Command { get; set; } + public string? Created { get; set; } public string? State { get; set; } public string? Status { get; set; } public string? Pod { get; set; } public string? PodName { get; set; } public bool AutoRemove { get; set; } + public long Pid { get; set; } + public string[]? Networks { get; set; } } diff --git a/src/PodmanClient/Dtos/Container/ContainerMountDto.cs b/src/PodmanClient/Dtos/Container/ContainerMountDto.cs index e4b3636..447b289 100644 --- a/src/PodmanClient/Dtos/Container/ContainerMountDto.cs +++ b/src/PodmanClient/Dtos/Container/ContainerMountDto.cs @@ -1,8 +1,8 @@ namespace MaksIT.PodmanClientDotNet.Dtos.Container; -/// -/// Deserialized Podman libpod API payload (Container Mount). -/// +/// +/// Result of mounting a container's root filesystem (libpod returns a plain path string). +/// public sealed class ContainerMountDto { - public string? Id { get; set; } + public string? Path { get; set; } } diff --git a/src/PodmanClient/Dtos/Container/ContainerStatsDto.cs b/src/PodmanClient/Dtos/Container/ContainerStatsDto.cs index eae1810..8f70993 100644 --- a/src/PodmanClient/Dtos/Container/ContainerStatsDto.cs +++ b/src/PodmanClient/Dtos/Container/ContainerStatsDto.cs @@ -1,41 +1,96 @@ namespace MaksIT.PodmanClientDotNet.Dtos.Container; + /// /// Deserialized Podman libpod API payload (Container Stats). /// - public sealed class ContainerStatsDto { public string? Name { get; set; } public string? Id { get; set; } - public ContainerStatsCpuDto? CpuStats { get; set; } - public ContainerStatsMemoryDto? MemoryStats { get; set; } - public ContainerStatsNetworkDto[]? Networks { get; set; } public string? Read { get; set; } public string? Preread { get; set; } + public ContainerStatsCpuBlockDto? CpuStats { get; set; } + public ContainerStatsCpuBlockDto? PrecpuStats { get; set; } + public ContainerStatsMemoryDto? MemoryStats { get; set; } + public Dictionary? Networks { get; set; } + public ContainerStatsPidsDto? PidsStats { get; set; } + public int NumProcs { get; set; } } -/// -/// Deserialized Podman libpod API payload (Container Stats Cpu). -/// -public sealed class ContainerStatsCpuDto { +/// +/// CPU stats block from container stats. +/// +public sealed class ContainerStatsCpuBlockDto { + public ContainerStatsCpuUsageDto? CpuUsage { get; set; } + public ulong SystemCpuUsage { get; set; } + public int OnlineCpus { get; set; } + public double Cpu { get; set; } +} + +/// +/// Nested CPU usage counters. +/// +public sealed class ContainerStatsCpuUsageDto { public ulong TotalUsage { get; set; } - public ulong SystemUsage { get; set; } - public ulong KernelMode { get; set; } - public ulong UserMode { get; set; } + public ulong UsageInKernelmode { get; set; } + public ulong UsageInUsermode { get; set; } } -/// -/// Deserialized Podman libpod API payload (Container Stats Memory). -/// +/// +/// Memory stats from container stats. +/// public sealed class ContainerStatsMemoryDto { public ulong Usage { get; set; } public ulong MaxUsage { get; set; } public ulong Limit { get; set; } } -/// -/// Deserialized Podman libpod API payload (Container Stats Network). -/// +/// +/// Per-interface network counters. +/// public sealed class ContainerStatsNetworkDto { public ulong RxBytes { get; set; } + public ulong RxPackets { get; set; } + public ulong RxErrors { get; set; } + public ulong RxDropped { get; set; } public ulong TxBytes { get; set; } + public ulong TxPackets { get; set; } + public ulong TxErrors { get; set; } + public ulong TxDropped { get; set; } +} + +/// +/// PID stats from container stats. +/// +public sealed class ContainerStatsPidsDto { + public long Current { get; set; } +} + +/// +/// Response from GET /libpod/containers/stats (multi-container). +/// +public sealed class ContainersStatsResponseDto { + public string? Error { get; set; } + public List? Stats { get; set; } +} + +/// +/// Libpod multi-container stats entry. +/// +public sealed class ContainerLibpodStatsDto { + public double AvgCPU { get; set; } + public string? ContainerID { get; set; } + public string? Name { get; set; } + public double CPU { get; set; } + public long CPUNano { get; set; } + public long CPUSystemNano { get; set; } + public long SystemNano { get; set; } + public long MemUsage { get; set; } + public long MemLimit { get; set; } + public double MemPerc { get; set; } + public Dictionary? Network { get; set; } + public long BlockInput { get; set; } + public long BlockOutput { get; set; } + public long PIDs { get; set; } + public long UpTime { get; set; } + public long Duration { get; set; } } diff --git a/src/PodmanClient/Dtos/Container/MountedContainerDto.cs b/src/PodmanClient/Dtos/Container/MountedContainerDto.cs index e4d6ba2..67779c2 100644 --- a/src/PodmanClient/Dtos/Container/MountedContainerDto.cs +++ b/src/PodmanClient/Dtos/Container/MountedContainerDto.cs @@ -1,17 +1,7 @@ namespace MaksIT.PodmanClientDotNet.Dtos.Container; -/// -/// Deserialized Podman libpod API payload (Mounted Container). -/// -public sealed class MountedContainerDto { - public string? Id { get; set; } - public string? Name { get; set; } - public string? Mountpoint { get; set; } -} /// -/// Deserialized Podman libpod API payload (Mounted Containers response). +/// Libpod showmounted returns a JSON array of single-entry maps (container id → mount path). /// - -public sealed class MountedContainersResponseDto { - public List? Containers { get; set; } +public sealed class MountedContainersResponseDto : List> { } diff --git a/src/PodmanClient/Dtos/Exec/ExecModelsDto.cs b/src/PodmanClient/Dtos/Exec/ExecModelsDto.cs index d120bc6..ef3d6d6 100644 --- a/src/PodmanClient/Dtos/Exec/ExecModelsDto.cs +++ b/src/PodmanClient/Dtos/Exec/ExecModelsDto.cs @@ -7,11 +7,30 @@ public sealed class CreateExecResponseDto { public string? Id { get; set; } } /// +/// Deserialized Podman libpod API payload (Inspect Exec process config). +/// + +public sealed class InspectExecProcessDto { + public string[]? Arguments { get; set; } + public string? Entrypoint { get; set; } + public bool Privileged { get; set; } + public bool Tty { get; set; } + public string? User { get; set; } +} +/// /// Deserialized Podman libpod API payload (Inspect Exec response). /// public sealed class InspectExecResponseDto { - public bool Running { get; set; } + public bool CanRemove { get; set; } + public string? ContainerID { get; set; } + public string? DetachKeys { get; set; } public int ExitCode { get; set; } - public string? ProcessConfig { get; set; } + public string? ID { get; set; } + public bool OpenStderr { get; set; } + public bool OpenStdin { get; set; } + public bool OpenStdout { get; set; } + public bool Running { get; set; } + public int Pid { get; set; } + public InspectExecProcessDto? ProcessConfig { get; set; } } diff --git a/src/PodmanClient/Dtos/Image/ImageChangesDto.cs b/src/PodmanClient/Dtos/Image/ImageChangesDto.cs index f2fc0cc..c872206 100644 --- a/src/PodmanClient/Dtos/Image/ImageChangesDto.cs +++ b/src/PodmanClient/Dtos/Image/ImageChangesDto.cs @@ -1,5 +1,15 @@ namespace MaksIT.PodmanClientDotNet.Dtos.Image; -/// Podman returns a JSON array of filesystem change paths. -public sealed class ImageChangesDto : List { +/// +/// Single filesystem change entry from image/container changes. +/// +public sealed class ImageChangeEntryDto { + public string? Path { get; set; } + public int Kind { get; set; } +} + +/// +/// Podman returns a JSON array of path/kind change objects. +/// +public sealed class ImageChangesDto : List { } diff --git a/src/PodmanClient/Dtos/Image/ImageDeleteDto.cs b/src/PodmanClient/Dtos/Image/ImageDeleteDto.cs index 06d9947..1a50f8b 100644 --- a/src/PodmanClient/Dtos/Image/ImageDeleteDto.cs +++ b/src/PodmanClient/Dtos/Image/ImageDeleteDto.cs @@ -1,10 +1,11 @@ namespace MaksIT.PodmanClientDotNet.Dtos.Image; -/// -/// Deserialized Podman libpod API payload (Image Delete). -/// +/// +/// Deserialized Podman libpod API payload (Image Delete / Remove). +/// public sealed class ImageDeleteDto { - public string? Deleted { get; set; } + public string[]? Deleted { get; set; } public string[]? Untagged { get; set; } public int ExitCode { get; set; } + public string[]? Errors { get; set; } } diff --git a/src/PodmanClient/Dtos/Image/ImageListEntryDto.cs b/src/PodmanClient/Dtos/Image/ImageListEntryDto.cs index 82a8b94..6cea628 100644 --- a/src/PodmanClient/Dtos/Image/ImageListEntryDto.cs +++ b/src/PodmanClient/Dtos/Image/ImageListEntryDto.cs @@ -1,23 +1,30 @@ namespace MaksIT.PodmanClientDotNet.Dtos.Image; + /// /// Deserialized Podman libpod API payload (Image List Entry). /// - public sealed class ImageListEntryDto { public string? Id { get; set; } + public string? ParentId { get; set; } + public string[]? RepoTags { get; set; } + public string[]? RepoDigests { get; set; } public string[]? Names { get; set; } + public string[]? History { get; set; } public string? Digest { get; set; } public long Created { get; set; } public long Size { get; set; } public long SharedSize { get; set; } - public string? ParentId { get; set; } - public string? RepoTags { get; set; } - public string? RepoDigests { get; set; } + public long VirtualSize { get; set; } + public long Containers { get; set; } + public string? Arch { get; set; } + public string? Os { get; set; } + public bool IsManifestList { get; set; } + public Dictionary? Labels { get; set; } } + /// /// Deserialized Podman libpod API payload (Image Search Result). /// - public sealed class ImageSearchResultDto { public string? Name { get; set; } public string? Description { get; set; } diff --git a/src/PodmanClient/Dtos/Image/ImageTreeDto.cs b/src/PodmanClient/Dtos/Image/ImageTreeDto.cs index e5b0bbc..9af1dbd 100644 --- a/src/PodmanClient/Dtos/Image/ImageTreeDto.cs +++ b/src/PodmanClient/Dtos/Image/ImageTreeDto.cs @@ -1,18 +1,8 @@ namespace MaksIT.PodmanClientDotNet.Dtos.Image; + /// /// Deserialized Podman libpod API payload (Image Tree). /// - public sealed class ImageTreeDto { - public string? Id { get; set; } - public ImageTreeLayerDto[]? Layers { get; set; } -} -/// -/// Deserialized Podman libpod API payload (Image Tree Layer). -/// - -public sealed class ImageTreeLayerDto { - public string? Id { get; set; } - public string? Parent { get; set; } - public string[]? Tags { get; set; } + public string? Tree { get; set; } } diff --git a/src/PodmanClient/Dtos/Manifest/ManifestModelsDto.cs b/src/PodmanClient/Dtos/Manifest/ManifestModelsDto.cs index 69117ef..87f6509 100644 --- a/src/PodmanClient/Dtos/Manifest/ManifestModelsDto.cs +++ b/src/PodmanClient/Dtos/Manifest/ManifestModelsDto.cs @@ -1,36 +1,50 @@ namespace MaksIT.PodmanClientDotNet.Dtos.Manifest; + /// /// Deserialized Podman libpod API payload (Manifest Create). /// - public sealed class ManifestCreateDto { public string? Id { get; set; } } + /// /// Deserialized Podman libpod API payload (Manifest Inspect). /// - public sealed class ManifestInspectDto { + public int? SchemaVersion { get; set; } + public string? MediaType { get; set; } public string? Name { get; set; } public ManifestListSpecDto[]? Manifests { get; set; } } -/// -/// Deserialized Podman libpod API payload (Manifest List Spec). -/// +/// +/// Deserialized Podman libpod API payload (Manifest List Spec entry). +/// public sealed class ManifestListSpecDto { public string? Digest { get; set; } + public string? MediaType { get; set; } + public long? Size { get; set; } public string? Image { get; set; } - public string? Platform { get; set; } + public ManifestPlatformDto? Platform { get; set; } public string? Os { get; set; } public string? Arch { get; set; } } -/// -/// Deserialized Podman libpod API payload (Manifest Add request). -/// +/// +/// Platform object nested in a manifest list entry. +/// +public sealed class ManifestPlatformDto { + public string? Architecture { get; set; } + public string? Os { get; set; } + public string? Variant { get; set; } +} + +/// +/// Libpod ManifestModifyOptions body (v4+ PUT /libpod/manifests/{name}). +/// public sealed class ManifestAddRequestDto { - public string Image { get; set; } = ""; + public List? Images { get; set; } + public string? Image { get; set; } public bool All { get; set; } public string? Operation { get; set; } } diff --git a/src/PodmanClient/Dtos/Pod/PodModelsDto.cs b/src/PodmanClient/Dtos/Pod/PodModelsDto.cs index e2f98a2..e2ab2a4 100644 --- a/src/PodmanClient/Dtos/Pod/PodModelsDto.cs +++ b/src/PodmanClient/Dtos/Pod/PodModelsDto.cs @@ -1,70 +1,94 @@ namespace MaksIT.PodmanClientDotNet.Dtos.Pod; + +/// +/// Container summary nested in pod list/inspect responses. +/// +public sealed class PodContainerSummaryDto { + public string? Id { get; set; } + public string? Name { get; set; } + public string? Names { get; set; } + public string? State { get; set; } + public string? Status { get; set; } + public int? RestartCount { get; set; } +} + /// /// Deserialized Podman libpod API payload (Pod List Entry). /// - public sealed class PodListEntryDto { public string? Id { get; set; } public string? Name { get; set; } public string? Status { get; set; } + public string? Cgroup { get; set; } public string? CgroupParent { get; set; } - public DateTime Created { get; set; } + public string? Created { get; set; } public Dictionary? Labels { get; set; } public string? Namespace { get; set; } public string? RestartPolicy { get; set; } public ulong? StopTimeout { get; set; } + public string? InfraId { get; set; } + public string[]? Networks { get; set; } + public List? Containers { get; set; } } + /// /// Deserialized Podman libpod API payload (Pod Kill Report). /// - public sealed class PodKillReportDto { public string[]? Ids { get; set; } } + /// /// Deserialized Podman libpod API payload (Pod Inspect). /// - public sealed class PodInspectDto { public string? Id { get; set; } public string? Name { get; set; } + public string? State { get; set; } public string? Status { get; set; } public string? CgroupParent { get; set; } - public DateTime Created { get; set; } + public string? Created { get; set; } public Dictionary? Labels { get; set; } public string? Namespace { get; set; } public string? RestartPolicy { get; set; } public ulong? StopTimeout { get; set; } - public string[]? Containers { get; set; } - public string? InfraContainerId { get; set; } + public List? Containers { get; set; } + public string? InfraContainerID { get; set; } + public int? NumContainers { get; set; } + public string[]? SharedNamespaces { get; set; } } + /// /// Deserialized Podman libpod API payload (Pod Top). /// - public sealed class PodTopDto { public string[]? Titles { get; set; } public List? Processes { get; set; } } -/// -/// Deserialized Podman libpod API payload (Pod Stats). -/// +/// +/// Deserialized Podman libpod API payload (Pod Stats entry). +/// public sealed class PodStatsDto { public string? Id { get; set; } + public string? CID { get; set; } + public string? Pod { get; set; } public string? Name { get; set; } public string? CPU { get; set; } public string? MemUsage { get; set; } + public string? MemUsageBytes { get; set; } public string? MemLimit { get; set; } + public string? Mem { get; set; } public string? MemPercent { get; set; } public string? NetIO { get; set; } public string? BlockIO { get; set; } - public string? PIDs { get; set; } + public string? PIDS { get; set; } } -/// -/// Deserialized Podman libpod API payload (Pod Stats response). -/// +/// +/// Deserialized Podman libpod API payload (Pod Stats response wrapper). +/// Prefer from the API; this type remains for callers that expect a named bag. +/// public sealed class PodStatsResponseDto { - public Dictionary? Stats { get; set; } + public List? Stats { get; set; } } diff --git a/src/PodmanClient/Dtos/System/InfoDto.cs b/src/PodmanClient/Dtos/System/InfoDto.cs index ca98e9a..aa2ead9 100644 --- a/src/PodmanClient/Dtos/System/InfoDto.cs +++ b/src/PodmanClient/Dtos/System/InfoDto.cs @@ -1,49 +1,121 @@ namespace MaksIT.PodmanClientDotNet.Dtos.System; + /// /// Deserialized Podman libpod API payload (Info). /// - public sealed class InfoDto { public InfoHostDto? Host { get; set; } public InfoStoreDto? Store { get; set; } - public Dictionary? Version { get; set; } + public InfoVersionDto? Version { get; set; } public InfoPluginsDto? Plugins { get; set; } } + +/// +/// Version block under info. +/// +public sealed class InfoVersionDto { + public string? APIVersion { get; set; } + public string? Version { get; set; } + public string? GoVersion { get; set; } + public string? GitCommit { get; set; } + public string? BuiltTime { get; set; } + public long Built { get; set; } + public string? BuildOrigin { get; set; } + public string? OsArch { get; set; } + public string? Os { get; set; } +} + /// /// Deserialized Podman libpod API payload (Info Host). /// - public sealed class InfoHostDto { public string? Arch { get; set; } public string? BuildahVersion { get; set; } - public long Containers { get; set; } - public string? Distribution { get; set; } - public string? Kernel { get; set; } - public string? MemTotal { get; set; } - public int MemFree { get; set; } - public string? OSType { get; set; } - public string? OS { get; set; } - public int CPUs { get; set; } - public string? PodmanVersion { get; set; } - public string? Machine { get; set; } + public string? CgroupManager { get; set; } + public string? CgroupVersion { get; set; } + public List? CgroupControllers { get; set; } + public InfoConmonDto? Conmon { get; set; } + public int Cpus { get; set; } + public InfoCpuUtilizationDto? CpuUtilization { get; set; } + public string? DatabaseBackend { get; set; } + public InfoDistributionDto? Distribution { get; set; } + public string? EventLogger { get; set; } + public long FreeLocks { get; set; } public string? Hostname { get; set; } + public string? Kernel { get; set; } + public string? LogDriver { get; set; } + public long MemFree { get; set; } + public long MemTotal { get; set; } + public string? NetworkBackend { get; set; } + public string? Os { get; set; } + public string? OSType { get; set; } } + +/// +/// Conmon info under host. +/// +public sealed class InfoConmonDto { + public string? Package { get; set; } + public string? Path { get; set; } + public string? Version { get; set; } +} + +/// +/// CPU utilization percentages under host. +/// +public sealed class InfoCpuUtilizationDto { + public double UserPercent { get; set; } + public double SystemPercent { get; set; } + public double IdlePercent { get; set; } +} + +/// +/// Distribution object under host. +/// +public sealed class InfoDistributionDto { + public string? Distribution { get; set; } + public string? Version { get; set; } +} + /// /// Deserialized Podman libpod API payload (Info Store). /// - public sealed class InfoStoreDto { + public string? ConfigFile { get; set; } + public InfoContainerStoreDto? ContainerStore { get; set; } public string? GraphRoot { get; set; } public string? GraphDriverName { get; set; } public Dictionary? GraphOptions { get; set; } - public long ImageStoreNumber { get; set; } - public long RunRoot { get; set; } - public long VolumePath { get; set; } + public long GraphRootAllocated { get; set; } + public long GraphRootUsed { get; set; } + public Dictionary? GraphStatus { get; set; } + public string? ImageCopyTmpDir { get; set; } + public InfoImageStoreDto? ImageStore { get; set; } + public string? RunRoot { get; set; } + public bool TransientStore { get; set; } + public string? VolumePath { get; set; } } + +/// +/// Container store counters under store. +/// +public sealed class InfoContainerStoreDto { + public long Number { get; set; } + public long Paused { get; set; } + public long Running { get; set; } + public long Stopped { get; set; } +} + +/// +/// Image store counters under store. +/// +public sealed class InfoImageStoreDto { + public long Number { get; set; } +} + /// /// Deserialized Podman libpod API payload (Info Plugins). /// - public sealed class InfoPluginsDto { public string[]? Volume { get; set; } public string[]? Network { get; set; } diff --git a/src/PodmanClient/Dtos/System/LibpodVersionDto.cs b/src/PodmanClient/Dtos/System/LibpodVersionDto.cs index 5c25136..6a7d460 100644 --- a/src/PodmanClient/Dtos/System/LibpodVersionDto.cs +++ b/src/PodmanClient/Dtos/System/LibpodVersionDto.cs @@ -1,18 +1,34 @@ namespace MaksIT.PodmanClientDotNet.Dtos.System; + /// /// Deserialized Podman libpod API payload (Libpod Version). /// - public sealed class LibpodVersionDto { - public VersionComponentsDto? Version { get; set; } - public string? Platform { get; set; } + public LibpodVersionPlatformDto? Platform { get; set; } + public List? Components { get; set; } + public string? Version { get; set; } + public string? ApiVersion { get; set; } + public string? MinAPIVersion { get; set; } + public string? GitCommit { get; set; } + public string? GoVersion { get; set; } + public string? Os { get; set; } + public string? Arch { get; set; } + public string? KernelVersion { get; set; } + public string? BuildTime { get; set; } } -/// -/// Deserialized Podman libpod API payload (Version Components). -/// -public sealed class VersionComponentsDto { - public int Major { get; set; } - public int Minor { get; set; } - public int Micro { get; set; } +/// +/// Platform object from libpod version. +/// +public sealed class LibpodVersionPlatformDto { + public string? Name { get; set; } +} + +/// +/// Component entry from libpod version. +/// +public sealed class LibpodVersionComponentDto { + public string? Name { get; set; } + public string? Version { get; set; } + public Dictionary? Details { get; set; } } diff --git a/src/PodmanClient/Dtos/System/SystemDfDto.cs b/src/PodmanClient/Dtos/System/SystemDfDto.cs index 2f21d7f..c3f04f7 100644 --- a/src/PodmanClient/Dtos/System/SystemDfDto.cs +++ b/src/PodmanClient/Dtos/System/SystemDfDto.cs @@ -1,17 +1,18 @@ namespace MaksIT.PodmanClientDotNet.Dtos.System; + /// /// Deserialized Podman libpod API payload (System Df). /// - public sealed class SystemDfDto { + public long ImagesSize { get; set; } public SystemDfEntryDto[]? Images { get; set; } public SystemDfEntryDto[]? Containers { get; set; } public SystemDfEntryDto[]? Volumes { get; set; } } + /// /// Deserialized Podman libpod API payload (System Df Entry). /// - public sealed class SystemDfEntryDto { public long Size { get; set; } public long Reclaimable { get; set; } diff --git a/src/PodmanClient/IPodmanClientConfiguration.cs b/src/PodmanClient/IPodmanClientConfiguration.cs index a5845ae..b05332b 100644 --- a/src/PodmanClient/IPodmanClientConfiguration.cs +++ b/src/PodmanClient/IPodmanClientConfiguration.cs @@ -14,7 +14,8 @@ public interface IPodmanClientConfiguration { string ServerUrl { get; set; } /// - /// Podman API version segment used in request paths. Defaults to v1.41. + /// Podman libpod API version segment used in request paths. Defaults to v5.4.0 + /// (validated E2E target; use at least v4.0.0 — Docker-compat v1.41 is rejected by network endpoints). /// string ApiVersion { get; set; } diff --git a/src/PodmanClient/Internal/PodmanHttpResults.cs b/src/PodmanClient/Internal/PodmanHttpResults.cs index 7797672..68ef781 100644 --- a/src/PodmanClient/Internal/PodmanHttpResults.cs +++ b/src/PodmanClient/Internal/PodmanHttpResults.cs @@ -1,10 +1,7 @@ using System.Net; using System.Text.Json; - -using Microsoft.Extensions.Logging; - -using MaksIT.PodmanClientDotNet.Dtos.Common; using MaksIT.Results; +using Microsoft.Extensions.Logging; namespace MaksIT.PodmanClientDotNet.Internal; diff --git a/src/PodmanClient/Internal/PodmanNdjsonStreams.cs b/src/PodmanClient/Internal/PodmanNdjsonStreams.cs index a0aacf7..845210b 100644 --- a/src/PodmanClient/Internal/PodmanNdjsonStreams.cs +++ b/src/PodmanClient/Internal/PodmanNdjsonStreams.cs @@ -1,10 +1,7 @@ using System.Text.Json; - -using Microsoft.Extensions.Logging; - using MaksIT.PodmanClientDotNet.Dtos.Build; -using MaksIT.PodmanClientDotNet.Dtos.Image; using MaksIT.Results; +using Microsoft.Extensions.Logging; namespace MaksIT.PodmanClientDotNet.Internal; diff --git a/src/PodmanClient/Models/AutoUserNsOptions.cs b/src/PodmanClient/Models/AutoUserNsOptions.cs index 6cfdbd1..17d48f2 100644 --- a/src/PodmanClient/Models/AutoUserNsOptions.cs +++ b/src/PodmanClient/Models/AutoUserNsOptions.cs @@ -12,4 +12,4 @@ public class AutoUserNsOptions { public int InitialSize { get; set; } public string? PasswdFile { get; set; } public int Size { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/BindOptions.cs b/src/PodmanClient/Models/BindOptions.cs index bb37c0c..04063ca 100644 --- a/src/PodmanClient/Models/BindOptions.cs +++ b/src/PodmanClient/Models/BindOptions.cs @@ -11,4 +11,4 @@ public class BindOptions { public string? Propagation { get; set; } public bool ReadOnlyForceRecursive { get; set; } public bool ReadOnlyNonRecursive { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/BlockIO.cs b/src/PodmanClient/Models/BlockIO.cs index 4e0ff31..f545573 100644 --- a/src/PodmanClient/Models/BlockIO.cs +++ b/src/PodmanClient/Models/BlockIO.cs @@ -13,4 +13,4 @@ public class BlockIO { public List? ThrottleWriteIopsDevice { get; set; } public int Weight { get; set; } public List? WeightDevice { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/CPU.cs b/src/PodmanClient/Models/CPU.cs index c73a63e..8d0d171 100644 --- a/src/PodmanClient/Models/CPU.cs +++ b/src/PodmanClient/Models/CPU.cs @@ -15,4 +15,4 @@ public class CPU { public int RealtimePeriod { get; set; } public int RealtimeRuntime { get; set; } public int Shares { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/Container/CreateContainerRequest.cs b/src/PodmanClient/Models/Container/CreateContainerRequest.cs index 99f14dc..adc2aff 100644 --- a/src/PodmanClient/Models/Container/CreateContainerRequest.cs +++ b/src/PodmanClient/Models/Container/CreateContainerRequest.cs @@ -124,4 +124,4 @@ public class CreateContainerRequest { public List? VolumesFrom { get; set; } public Dictionary? WeightDevice { get; set; } public string? WorkDir { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/Container/CreateContainerResponse.cs b/src/PodmanClient/Models/Container/CreateContainerResponse.cs index 2dfecba..409ce4b 100644 --- a/src/PodmanClient/Models/Container/CreateContainerResponse.cs +++ b/src/PodmanClient/Models/Container/CreateContainerResponse.cs @@ -8,4 +8,4 @@ namespace MaksIT.PodmanClientDotNet.Models.Container; public class CreateContainerResponse { public string? Id { get; set; } public string[]? Warnings { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/Container/DeleteContainerResponse.cs b/src/PodmanClient/Models/Container/DeleteContainerResponse.cs index 59b5895..df250dc 100644 --- a/src/PodmanClient/Models/Container/DeleteContainerResponse.cs +++ b/src/PodmanClient/Models/Container/DeleteContainerResponse.cs @@ -8,4 +8,4 @@ namespace MaksIT.PodmanClientDotNet.Models.Container; public class DeleteContainerResponse { public string? Err { get; set; } public string? Id { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/DriverConfig.cs b/src/PodmanClient/Models/DriverConfig.cs index 2f285ee..b67de2b 100644 --- a/src/PodmanClient/Models/DriverConfig.cs +++ b/src/PodmanClient/Models/DriverConfig.cs @@ -8,4 +8,4 @@ namespace MaksIT.PodmanClientDotNet.Models; public class DriverConfig { public string? Name { get; set; } public Dictionary? Options { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/ErrorResponse.cs b/src/PodmanClient/Models/ErrorResponse.cs index 71e7466..078fa16 100644 --- a/src/PodmanClient/Models/ErrorResponse.cs +++ b/src/PodmanClient/Models/ErrorResponse.cs @@ -9,4 +9,4 @@ public class ErrorResponse { public string? Cause { get; set; } public string? Message { get; set; } public int Response { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/Exec/CreateExecRequest.cs b/src/PodmanClient/Models/Exec/CreateExecRequest.cs index 6cb3408..81a3296 100644 --- a/src/PodmanClient/Models/Exec/CreateExecRequest.cs +++ b/src/PodmanClient/Models/Exec/CreateExecRequest.cs @@ -16,4 +16,4 @@ public class CreateExecRequest { public bool Tty { get; set; } public string? User { get; set; } public string? WorkingDir { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/Exec/CreateExecResponse.cs b/src/PodmanClient/Models/Exec/CreateExecResponse.cs index 4cd1846..dc8231b 100644 --- a/src/PodmanClient/Models/Exec/CreateExecResponse.cs +++ b/src/PodmanClient/Models/Exec/CreateExecResponse.cs @@ -7,4 +7,4 @@ namespace MaksIT.PodmanClientDotNet.Models.Exec; public class CreateExecResponse { public string? Id { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/Exec/InspectExecResponse.cs b/src/PodmanClient/Models/Exec/InspectExecResponse.cs index 35c9843..014fc2c 100644 --- a/src/PodmanClient/Models/Exec/InspectExecResponse.cs +++ b/src/PodmanClient/Models/Exec/InspectExecResponse.cs @@ -1,12 +1,32 @@ namespace MaksIT.PodmanClientDotNet.Models.Exec; +/// +/// Libpod API response body for Inspect Exec process config. +/// + +public class InspectExecProcess { + public string[]? Arguments { get; set; } + public string? Entrypoint { get; set; } + public bool Privileged { get; set; } + public bool Tty { get; set; } + public string? User { get; set; } +} + /// /// Libpod API response body for Inspect Exec response. /// public class InspectExecResponse { - public bool Running { get; set; } + public bool CanRemove { get; set; } + public string? ContainerID { get; set; } + public string? DetachKeys { get; set; } public int ExitCode { get; set; } - public string? ProcessConfig { get; set; } -} \ No newline at end of file + public string? ID { get; set; } + public bool OpenStderr { get; set; } + public bool OpenStdin { get; set; } + public bool OpenStdout { get; set; } + public bool Running { get; set; } + public int Pid { get; set; } + public InspectExecProcess? ProcessConfig { get; set; } +} diff --git a/src/PodmanClient/Models/Exec/StartExecRequest.cs b/src/PodmanClient/Models/Exec/StartExecRequest.cs index d6aef30..16369bf 100644 --- a/src/PodmanClient/Models/Exec/StartExecRequest.cs +++ b/src/PodmanClient/Models/Exec/StartExecRequest.cs @@ -10,4 +10,4 @@ public class StartExecRequest { public bool Tty { get; set; } public int? Height { get; set; } // Optional, nullable if not provided public int? Width { get; set; } // Optional, nullable if not provided -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/HugepageLimit.cs b/src/PodmanClient/Models/HugepageLimit.cs index 2ad99f2..b372db5 100644 --- a/src/PodmanClient/Models/HugepageLimit.cs +++ b/src/PodmanClient/Models/HugepageLimit.cs @@ -8,4 +8,4 @@ namespace MaksIT.PodmanClientDotNet.Models; public class HugepageLimit { public long Limit { get; set; } public string? PageSize { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/IDMapping.cs b/src/PodmanClient/Models/IDMapping.cs index 6d8d742..0ac52be 100644 --- a/src/PodmanClient/Models/IDMapping.cs +++ b/src/PodmanClient/Models/IDMapping.cs @@ -9,4 +9,4 @@ public class IDMapping { public int ContainerId { get; set; } public int HostId { get; set; } public int Size { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/IDMappingOptions.cs b/src/PodmanClient/Models/IDMappingOptions.cs index ca2145f..926f74f 100644 --- a/src/PodmanClient/Models/IDMappingOptions.cs +++ b/src/PodmanClient/Models/IDMappingOptions.cs @@ -12,4 +12,4 @@ public class IDMappingOptions { public bool HostGIDMapping { get; set; } public bool HostUIDMapping { get; set; } public List? UIDMap { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/Image/PullImageResponse.cs b/src/PodmanClient/Models/Image/PullImageResponse.cs index d0615cc..819b3fe 100644 --- a/src/PodmanClient/Models/Image/PullImageResponse.cs +++ b/src/PodmanClient/Models/Image/PullImageResponse.cs @@ -1,26 +1,25 @@ namespace MaksIT.PodmanClientDotNet.Models.Image; -public class PullImageResponse - { +public class PullImageResponse { - /// - /// Error contains text of errors from c/image. - /// - public string? Error { get; set; } + /// + /// Error contains text of errors from c/image. + /// + public string? Error { get; set; } - /// - /// ID contains image ID (retained for backwards compatibility). - /// - public string? Id { get; set; } + /// + /// ID contains image ID (retained for backwards compatibility). + /// + public string? Id { get; set; } - /// - /// Images contains the IDs of the images pulled. - /// - public List? Images { get; set; } + /// + /// Images contains the IDs of the images pulled. + /// + public List? Images { get; set; } - /// - /// Stream used to provide output from c/image. - /// - public string? Stream { get; set; } - } + /// + /// Stream used to provide output from c/image. + /// + public string? Stream { get; set; } +} diff --git a/src/PodmanClient/Models/ImageVolume.cs b/src/PodmanClient/Models/ImageVolume.cs index 39027ed..f1aff99 100644 --- a/src/PodmanClient/Models/ImageVolume.cs +++ b/src/PodmanClient/Models/ImageVolume.cs @@ -10,4 +10,4 @@ public class ImageVolume { public bool ReadWrite { get; set; } public string? Source { get; set; } public string? SubPath { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/IntelRdt.cs b/src/PodmanClient/Models/IntelRdt.cs index 90b7658..ff988b1 100644 --- a/src/PodmanClient/Models/IntelRdt.cs +++ b/src/PodmanClient/Models/IntelRdt.cs @@ -11,4 +11,4 @@ public class IntelRdt { public bool EnableMBM { get; set; } public string? L3CacheSchema { get; set; } public string? MemBwSchema { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/LinuxDevice.cs b/src/PodmanClient/Models/LinuxDevice.cs index a844485..ed764ea 100644 --- a/src/PodmanClient/Models/LinuxDevice.cs +++ b/src/PodmanClient/Models/LinuxDevice.cs @@ -13,4 +13,4 @@ public class LinuxDevice { public string? Path { get; set; } public string? Type { get; set; } public int Uid { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/LinuxDeviceCgroup.cs b/src/PodmanClient/Models/LinuxDeviceCgroup.cs index 9410c46..20153a0 100644 --- a/src/PodmanClient/Models/LinuxDeviceCgroup.cs +++ b/src/PodmanClient/Models/LinuxDeviceCgroup.cs @@ -11,4 +11,4 @@ public class LinuxDeviceCgroup { public int Major { get; set; } public int Minor { get; set; } public string? Type { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/LinuxIntelRdt.cs b/src/PodmanClient/Models/LinuxIntelRdt.cs index 2ffa3ef..eedaea8 100644 --- a/src/PodmanClient/Models/LinuxIntelRdt.cs +++ b/src/PodmanClient/Models/LinuxIntelRdt.cs @@ -11,4 +11,4 @@ public class LinuxIntelRdt { public bool EnableMBM { get; set; } public string? L3CacheSchema { get; set; } public string? MemBwSchema { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/LinuxPersonality.cs b/src/PodmanClient/Models/LinuxPersonality.cs index 6a45c46..31c325c 100644 --- a/src/PodmanClient/Models/LinuxPersonality.cs +++ b/src/PodmanClient/Models/LinuxPersonality.cs @@ -8,4 +8,4 @@ namespace MaksIT.PodmanClientDotNet.Models; public class LinuxPersonality { public string? Domain { get; set; } public List? Flags { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/LinuxResources.cs b/src/PodmanClient/Models/LinuxResources.cs index a792451..e32bff7 100644 --- a/src/PodmanClient/Models/LinuxResources.cs +++ b/src/PodmanClient/Models/LinuxResources.cs @@ -15,4 +15,4 @@ public class LinuxResources { public Pids? Pids { get; set; } public Dictionary? Rdma { get; set; } public Dictionary? Unified { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/LogConfigLibpod.cs b/src/PodmanClient/Models/LogConfigLibpod.cs index 3242137..83aa5d4 100644 --- a/src/PodmanClient/Models/LogConfigLibpod.cs +++ b/src/PodmanClient/Models/LogConfigLibpod.cs @@ -10,4 +10,4 @@ public class LogConfigLibpod { public Dictionary? Options { get; set; } public string? Path { get; set; } public long Size { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/Memory.cs b/src/PodmanClient/Models/Memory.cs index d790997..afdf79a 100644 --- a/src/PodmanClient/Models/Memory.cs +++ b/src/PodmanClient/Models/Memory.cs @@ -15,4 +15,4 @@ public class Memory { public long Swap { get; set; } public int Swappiness { get; set; } public bool UseHierarchy { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/Mount.cs b/src/PodmanClient/Models/Mount.cs index 48a6858..8b5b520 100644 --- a/src/PodmanClient/Models/Mount.cs +++ b/src/PodmanClient/Models/Mount.cs @@ -14,4 +14,4 @@ public class Mount { public TmpfsOptions? TmpfsOptions { get; set; } public string? Type { get; set; } public VolumeOptions? VolumeOptions { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/NamedVolume.cs b/src/PodmanClient/Models/NamedVolume.cs index 95e8a55..bb14fd4 100644 --- a/src/PodmanClient/Models/NamedVolume.cs +++ b/src/PodmanClient/Models/NamedVolume.cs @@ -11,4 +11,4 @@ public class NamedVolume { public string? Name { get; set; } public List? Options { get; set; } public string? SubPath { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/Namespace.cs b/src/PodmanClient/Models/Namespace.cs index 113cbd6..5abc8b8 100644 --- a/src/PodmanClient/Models/Namespace.cs +++ b/src/PodmanClient/Models/Namespace.cs @@ -8,4 +8,4 @@ namespace MaksIT.PodmanClientDotNet.Models; public class Namespace { public string? Nsmode { get; set; } public string? Value { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/NetworkPriority.cs b/src/PodmanClient/Models/NetworkPriority.cs index 557cd58..8db4dba 100644 --- a/src/PodmanClient/Models/NetworkPriority.cs +++ b/src/PodmanClient/Models/NetworkPriority.cs @@ -8,4 +8,4 @@ namespace MaksIT.PodmanClientDotNet.Models; public class NetworkPriority { public string? Name { get; set; } public int Priority { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/NetworkSettings.cs b/src/PodmanClient/Models/NetworkSettings.cs index e9ffd3b..6335b1b 100644 --- a/src/PodmanClient/Models/NetworkSettings.cs +++ b/src/PodmanClient/Models/NetworkSettings.cs @@ -10,4 +10,4 @@ public class NetworkSettings { public string? InterfaceName { get; set; } public List? StaticIps { get; set; } public string? StaticMac { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/OverlayVolume.cs b/src/PodmanClient/Models/OverlayVolume.cs index 68ccf73..0f62aca 100644 --- a/src/PodmanClient/Models/OverlayVolume.cs +++ b/src/PodmanClient/Models/OverlayVolume.cs @@ -9,4 +9,4 @@ public class OverlayVolume { public string? Destination { get; set; } public List? Options { get; set; } public string? Source { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/POSIXRlimit.cs b/src/PodmanClient/Models/POSIXRlimit.cs index 2fb14f8..a1f223e 100644 --- a/src/PodmanClient/Models/POSIXRlimit.cs +++ b/src/PodmanClient/Models/POSIXRlimit.cs @@ -9,4 +9,4 @@ public class POSIXRlimit { public long Hard { get; set; } public long Soft { get; set; } public string? Type { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/Pids.cs b/src/PodmanClient/Models/Pids.cs index 0b40003..f783415 100644 --- a/src/PodmanClient/Models/Pids.cs +++ b/src/PodmanClient/Models/Pids.cs @@ -7,4 +7,4 @@ namespace MaksIT.PodmanClientDotNet.Models; public class Pids { public int Limit { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/PortMapping.cs b/src/PodmanClient/Models/PortMapping.cs index 74ff90c..0a8424b 100644 --- a/src/PodmanClient/Models/PortMapping.cs +++ b/src/PodmanClient/Models/PortMapping.cs @@ -11,4 +11,4 @@ public class PortMapping { public int HostPort { get; set; } public string? Protocol { get; set; } public int Range { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/ProgressDetail.cs b/src/PodmanClient/Models/ProgressDetail.cs index 569f9a8..5e31cf5 100644 --- a/src/PodmanClient/Models/ProgressDetail.cs +++ b/src/PodmanClient/Models/ProgressDetail.cs @@ -8,4 +8,4 @@ namespace MaksIT.PodmanClientDotNet.Models; public class ProgressDetail { public long? Current { get; set; } public long? Total { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/RdmaResource.cs b/src/PodmanClient/Models/RdmaResource.cs index 4f20d68..acf25fc 100644 --- a/src/PodmanClient/Models/RdmaResource.cs +++ b/src/PodmanClient/Models/RdmaResource.cs @@ -8,4 +8,4 @@ namespace MaksIT.PodmanClientDotNet.Models; public class RdmaResource { public int HcaHandles { get; set; } public int HcaObjects { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/Schema2HealthConfig.cs b/src/PodmanClient/Models/Schema2HealthConfig.cs index a90cd2c..674d617 100644 --- a/src/PodmanClient/Models/Schema2HealthConfig.cs +++ b/src/PodmanClient/Models/Schema2HealthConfig.cs @@ -12,4 +12,4 @@ public class Schema2HealthConfig { public long StartPeriod { get; set; } public List? Test { get; set; } public long Timeout { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/SecretProp.cs b/src/PodmanClient/Models/SecretProp.cs index f30d2ca..e71ae8f 100644 --- a/src/PodmanClient/Models/SecretProp.cs +++ b/src/PodmanClient/Models/SecretProp.cs @@ -8,4 +8,4 @@ namespace MaksIT.PodmanClientDotNet.Models; public class SecretProp { public string? Key { get; set; } public string? Secret { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/StartupHealthConfig.cs b/src/PodmanClient/Models/StartupHealthConfig.cs index 7a651b7..0811ff6 100644 --- a/src/PodmanClient/Models/StartupHealthConfig.cs +++ b/src/PodmanClient/Models/StartupHealthConfig.cs @@ -13,4 +13,4 @@ public class StartupHealthConfig { public int Successes { get; set; } public List? Test { get; set; } public long Timeout { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/ThrottleDevice.cs b/src/PodmanClient/Models/ThrottleDevice.cs index 09f799e..745a729 100644 --- a/src/PodmanClient/Models/ThrottleDevice.cs +++ b/src/PodmanClient/Models/ThrottleDevice.cs @@ -9,4 +9,4 @@ public class ThrottleDevice { public int Major { get; set; } public int Minor { get; set; } public long Rate { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/TmpfsOptions.cs b/src/PodmanClient/Models/TmpfsOptions.cs index 9fef650..12c5916 100644 --- a/src/PodmanClient/Models/TmpfsOptions.cs +++ b/src/PodmanClient/Models/TmpfsOptions.cs @@ -9,4 +9,4 @@ public class TmpfsOptions { public int Mode { get; set; } public List? Options { get; set; } public long SizeBytes { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/VolumeOptions.cs b/src/PodmanClient/Models/VolumeOptions.cs index 9058155..f5b1db0 100644 --- a/src/PodmanClient/Models/VolumeOptions.cs +++ b/src/PodmanClient/Models/VolumeOptions.cs @@ -10,4 +10,4 @@ public class VolumeOptions { public Dictionary? Labels { get; set; } public bool NoCopy { get; set; } public string? Subpath { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/Models/WeightDevice.cs b/src/PodmanClient/Models/WeightDevice.cs index 2b07104..2c46337 100644 --- a/src/PodmanClient/Models/WeightDevice.cs +++ b/src/PodmanClient/Models/WeightDevice.cs @@ -10,4 +10,4 @@ public class WeightDevice { public int Major { get; set; } public int Minor { get; set; } public int Weight { get; set; } -} \ No newline at end of file +} diff --git a/src/PodmanClient/PodmanClient.Container.cs b/src/PodmanClient/PodmanClient.Container.cs index f564322..79767c6 100644 --- a/src/PodmanClient/PodmanClient.Container.cs +++ b/src/PodmanClient/PodmanClient.Container.cs @@ -1,15 +1,12 @@ -using MaksIT.PodmanClientDotNet; using System.Net; using System.Text.Json; - -using Microsoft.Extensions.Logging; - +using MaksIT.PodmanClientDotNet; +using MaksIT.PodmanClientDotNet.Dtos.Container; using MaksIT.PodmanClientDotNet.Internal; using MaksIT.PodmanClientDotNet.Models; -using MaksIT.PodmanClientDotNet.Dtos.Container; using MaksIT.PodmanClientDotNet.Models.Container; -using MaksIT.PodmanClientDotNet.Models.Exec; using MaksIT.Results; +using Microsoft.Extensions.Logging; public partial class PodmanClient { public async Task> CreateContainerAsync( diff --git a/src/PodmanClient/PodmanClient.Containers.Api.cs b/src/PodmanClient/PodmanClient.Containers.Api.cs index c275d84..6445287 100644 --- a/src/PodmanClient/PodmanClient.Containers.Api.cs +++ b/src/PodmanClient/PodmanClient.Containers.Api.cs @@ -57,12 +57,21 @@ public partial class PodmanClient { PostWithoutBodyAsync($"{ContainerPath(name)}/unpause", "Unpause container", cancellationToken: cancellationToken); public Task> WaitContainerAsync(string name, string? condition = null, CancellationToken cancellationToken = default) => - PostLibpodAsync( - $"{ContainerPath(name)}/wait", + SendAsync( + () => _httpClient.PostAsync( + LibpodPath($"{ContainerPath(name)}/wait") + BuildQuery(condition is null ? [] : [("condition", condition)]), + content: null, + cancellationToken), "Wait container", - PodmanJsonContext.Default.ContainerWaitDto, - query: condition is null ? null : [("condition", condition)], - cancellationToken: cancellationToken + // Libpod may return a bare exit-code integer or a JSON object. + body => { + var trimmed = body.Trim(); + if (long.TryParse(trimmed, out var code)) + return new ContainerWaitDto { StatusCode = code }; + + return System.Text.Json.JsonSerializer.Deserialize(trimmed, PodmanJsonContext.Default.ContainerWaitDto); + }, + cancellationToken ); public Task> GetContainerLogsAsync( @@ -100,7 +109,7 @@ public partial class PodmanClient { cancellationToken ); - public Task?>> GetContainersStatsAsync( + public Task> GetContainersStatsAsync( IEnumerable? containers = null, bool stream = false, CancellationToken cancellationToken = default @@ -111,14 +120,14 @@ public partial class PodmanClient { query.Add(("containers", c)); } - return GetJsonAsync>("/libpod/containers/stats", "Get containers stats", PodmanJsonContext.Default.DictionaryStringContainerStatsDto, query, cancellationToken); + return GetJsonAsync("/libpod/containers/stats", "Get containers stats", PodmanJsonContext.Default.ContainersStatsResponseDto, query, cancellationToken); } - public Task> PruneContainersAsync(string? filters = null, CancellationToken cancellationToken = default) => - PostLibpodAsync( + public Task?>> PruneContainersAsync(string? filters = null, CancellationToken cancellationToken = default) => + PostLibpodAsync>( "/libpod/containers/prune", "Prune containers", - PodmanJsonContext.Default.PruneReportDto, + PodmanJsonContext.Default.ListPruneReportEntryDto, query: filters is null ? null : [("filters", filters)], cancellationToken: cancellationToken ); @@ -183,7 +192,13 @@ public partial class PodmanClient { ); public Task> MountContainerAsync(string name, CancellationToken cancellationToken = default) => - PostLibpodAsync($"{ContainerPath(name)}/mount", "Mount container", PodmanJsonContext.Default.ContainerMountDto, cancellationToken: cancellationToken); + SendAsync( + () => _httpClient.PostAsync(LibpodPath($"{ContainerPath(name)}/mount"), content: null, cancellationToken), + "Mount container", + // Podman returns a plain filesystem path, not JSON. + body => new ContainerMountDto { Path = body.Trim().Trim('"') }, + cancellationToken + ); public Task UnmountContainerAsync(string name, CancellationToken cancellationToken = default) => PostWithoutBodyAsync($"{ContainerPath(name)}/unmount", "Unmount container", cancellationToken: cancellationToken); @@ -266,8 +281,32 @@ public partial class PodmanClient { public Task> HealthCheckContainerAsync(string name, CancellationToken cancellationToken = default) => GetJsonAsync($"{ContainerPath(name)}/healthcheck", "Health check container", PodmanJsonContext.Default.ContainerHealthCheckDto, cancellationToken: cancellationToken); - public Task> GetMountedContainersAsync(CancellationToken cancellationToken = default) => - GetJsonAsync("/libpod/containers/showmounted", "Get mounted containers", PodmanJsonContext.Default.MountedContainersResponseDto, cancellationToken: cancellationToken); + public Task>?>> GetMountedContainersAsync(CancellationToken cancellationToken = default) => + SendAsync( + () => _httpClient.GetAsync(LibpodPath("/libpod/containers/showmounted"), cancellationToken), + "Get mounted containers", + body => { + // Libpod returns [{"":""}, ...] (and [{}}] when empty). + using var doc = System.Text.Json.JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "[]" : body); + var list = new List>(); + if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Array) + return list; + + foreach (var el in doc.RootElement.EnumerateArray()) { + var dict = new Dictionary(); + if (el.ValueKind == System.Text.Json.JsonValueKind.Object) { + foreach (var prop in el.EnumerateObject()) + dict[prop.Name] = prop.Value.ValueKind == System.Text.Json.JsonValueKind.String + ? (prop.Value.GetString() ?? string.Empty) + : prop.Value.ToString(); + } + list.Add(dict); + } + + return list; + }, + cancellationToken + ); public Task> TopContainerAsync( string name, diff --git a/src/PodmanClient/PodmanClient.Exec.cs b/src/PodmanClient/PodmanClient.Exec.cs index 88870ad..9d7aee4 100644 --- a/src/PodmanClient/PodmanClient.Exec.cs +++ b/src/PodmanClient/PodmanClient.Exec.cs @@ -1,13 +1,8 @@ using MaksIT.PodmanClientDotNet; -using System.Text.Json; - -using Microsoft.Extensions.Logging; - -using MaksIT.PodmanClientDotNet.Internal; -using MaksIT.PodmanClientDotNet.Models; using MaksIT.PodmanClientDotNet.Dtos.Exec; using MaksIT.PodmanClientDotNet.Models.Exec; using MaksIT.Results; +using Microsoft.Extensions.Logging; public partial class PodmanClient { public async Task> CreateExecAsync( diff --git a/src/PodmanClient/PodmanClient.Generate.cs b/src/PodmanClient/PodmanClient.Generate.cs index 4913ba2..e9b6865 100644 --- a/src/PodmanClient/PodmanClient.Generate.cs +++ b/src/PodmanClient/PodmanClient.Generate.cs @@ -1,6 +1,5 @@ -using MaksIT.PodmanClientDotNet; using System.Net.Http.Headers; - +using MaksIT.PodmanClientDotNet; using MaksIT.PodmanClientDotNet.Dtos.Generate; using MaksIT.Results; diff --git a/src/PodmanClient/PodmanClient.Http.cs b/src/PodmanClient/PodmanClient.Http.cs index fe22d46..bc4c512 100644 --- a/src/PodmanClient/PodmanClient.Http.cs +++ b/src/PodmanClient/PodmanClient.Http.cs @@ -1,4 +1,3 @@ -using System.Net; using System.Text; using System.Text.Json; using System.Text.Json.Serialization.Metadata; diff --git a/src/PodmanClient/PodmanClient.Images.Api.cs b/src/PodmanClient/PodmanClient.Images.Api.cs index 3f19a22..710aed2 100644 --- a/src/PodmanClient/PodmanClient.Images.Api.cs +++ b/src/PodmanClient/PodmanClient.Images.Api.cs @@ -1,6 +1,5 @@ -using MaksIT.PodmanClientDotNet; using System.Net.Http.Headers; - +using MaksIT.PodmanClientDotNet; using MaksIT.PodmanClientDotNet.Dtos.Common; using MaksIT.PodmanClientDotNet.Dtos.Image; using MaksIT.PodmanClientDotNet.Internal; @@ -31,16 +30,16 @@ public partial class PodmanClient { public Task ImageExistsAsync(string name, CancellationToken cancellationToken = default) => GetWithoutBodyAsync($"{ImagePath(name)}/exists", "Image exists", cancellationToken: cancellationToken); - public Task> DeleteImageAsync(string name, bool force = false, CancellationToken cancellationToken = default) => - DeleteJsonAsync( + public Task> DeleteImageAsync(string name, bool force = false, CancellationToken cancellationToken = default) => + DeleteJsonAsync( ImagePath(name), "Delete image", - PodmanJsonContext.Default.ImageDeleteDtoArray, + PodmanJsonContext.Default.ImageDeleteDto, [("force", force.ToString().ToLowerInvariant())], cancellationToken ); - public Task> RemoveImagesAsync( + public Task> RemoveImagesAsync( IEnumerable images, bool all = false, bool force = false, @@ -53,11 +52,11 @@ public partial class PodmanClient { foreach (var image in images) query.Add(("images", image)); - return DeleteJsonAsync("/libpod/images/remove", "Remove images", PodmanJsonContext.Default.ImageDeleteDtoArray, query, cancellationToken); + return DeleteJsonAsync("/libpod/images/remove", "Remove images", PodmanJsonContext.Default.ImageDeleteDto, query, cancellationToken); } - public Task> PruneImagesAsync(CancellationToken cancellationToken = default) => - PostLibpodAsync("/libpod/images/prune", "Prune images", PodmanJsonContext.Default.PruneReportDto, cancellationToken: cancellationToken); + public Task?>> PruneImagesAsync(CancellationToken cancellationToken = default) => + PostLibpodAsync>("/libpod/images/prune", "Prune images", PodmanJsonContext.Default.ListPruneReportEntryDto, cancellationToken: cancellationToken); public Task?>> SearchImagesAsync( string term, diff --git a/src/PodmanClient/PodmanClient.Manifests.cs b/src/PodmanClient/PodmanClient.Manifests.cs index 8dd6564..729814e 100644 --- a/src/PodmanClient/PodmanClient.Manifests.cs +++ b/src/PodmanClient/PodmanClient.Manifests.cs @@ -1,3 +1,5 @@ +using System.Text; + using MaksIT.PodmanClientDotNet; using MaksIT.PodmanClientDotNet.Dtos.Manifest; using MaksIT.Results; @@ -10,18 +12,21 @@ public partial class PodmanClient { string? image = null, bool all = false, CancellationToken cancellationToken = default - ) => - PostLibpodAsync( - "/libpod/manifests/create", + ) { + var query = new List<(string Name, string? Value)> { + ("all", all.ToString().ToLowerInvariant()), + }; + if (!string.IsNullOrWhiteSpace(image)) + query.Add(("images", image)); + + return PostLibpodAsync( + ManifestPath(name), "Create manifest", PodmanJsonContext.Default.ManifestCreateDto, - query: [ - ("name", name), - ("image", image), - ("all", all.ToString().ToLowerInvariant()), - ], + query: [.. query], cancellationToken: cancellationToken ); + } public Task DeleteManifestAsync(string name, string? digest = null, CancellationToken cancellationToken = default) => DeleteWithoutBodyAsync( @@ -34,8 +39,14 @@ public partial class PodmanClient { public Task> InspectManifestAsync(string name, CancellationToken cancellationToken = default) => GetJsonAsync($"{ManifestPath(name)}/json", "Inspect manifest", PodmanJsonContext.Default.ManifestInspectDto, cancellationToken: cancellationToken); - public Task AddToManifestAsync(string name, ManifestAddRequestDto request, CancellationToken cancellationToken = default) => - PostJsonWithoutBodyAsync($"{ManifestPath(name)}/add", "Add to manifest", request, PodmanJsonContext.Default.ManifestAddRequestDto, cancellationToken: cancellationToken); + public Task AddToManifestAsync(string name, ManifestAddRequestDto request, CancellationToken cancellationToken = default) { + var content = new StringContent( + System.Text.Json.JsonSerializer.Serialize(request, PodmanJsonContext.Default.ManifestAddRequestDto), + Encoding.UTF8, + "application/json" + ); + return PutWithoutBodyAsync(ManifestPath(name), "Add to manifest", content, cancellationToken: cancellationToken); + } public Task PushManifestAsync( string name, @@ -44,10 +55,9 @@ public partial class PodmanClient { CancellationToken cancellationToken = default ) => PostWithoutBodyAsync( - $"/libpod/manifests/{Uri.EscapeDataString(name)}/push", + $"{ManifestPath(name)}/registry/{Uri.EscapeDataString(destination)}", "Push manifest", query: [ - ("destination", destination), ("all", all.ToString().ToLowerInvariant()), ], cancellationToken: cancellationToken diff --git a/src/PodmanClient/PodmanClient.Pods.cs b/src/PodmanClient/PodmanClient.Pods.cs index 372b19f..8b504f6 100644 --- a/src/PodmanClient/PodmanClient.Pods.cs +++ b/src/PodmanClient/PodmanClient.Pods.cs @@ -64,12 +64,12 @@ public partial class PodmanClient { public Task UnpausePodAsync(string name, CancellationToken cancellationToken = default) => PostWithoutBodyAsync($"/libpod/pods/{Uri.EscapeDataString(name)}/unpause", "Unpause pod", cancellationToken: cancellationToken); - public Task> PrunePodsAsync(CancellationToken cancellationToken = default) => - PostLibpodAsync("/libpod/pods/prune", "Prune pods", PodmanJsonContext.Default.PruneReportDto, cancellationToken: cancellationToken); + public Task?>> PrunePodsAsync(CancellationToken cancellationToken = default) => + PostLibpodAsync>("/libpod/pods/prune", "Prune pods", PodmanJsonContext.Default.ListPruneReportEntryDto, cancellationToken: cancellationToken); public Task> TopPodAsync(string name, CancellationToken cancellationToken = default) => GetJsonAsync($"/libpod/pods/{Uri.EscapeDataString(name)}/top", "Top pod", PodmanJsonContext.Default.PodTopDto, cancellationToken: cancellationToken); - public Task> GetPodsStatsAsync(CancellationToken cancellationToken = default) => - GetJsonAsync("/libpod/pods/stats", "Get pods stats", PodmanJsonContext.Default.PodStatsResponseDto, cancellationToken: cancellationToken); + public Task?>> GetPodsStatsAsync(CancellationToken cancellationToken = default) => + GetJsonAsync>("/libpod/pods/stats", "Get pods stats", PodmanJsonContext.Default.ListPodStatsDto, cancellationToken: cancellationToken); } diff --git a/src/PodmanClient/PodmanClient.Streaming.cs b/src/PodmanClient/PodmanClient.Streaming.cs index e9d51d8..dbe72c2 100644 --- a/src/PodmanClient/PodmanClient.Streaming.cs +++ b/src/PodmanClient/PodmanClient.Streaming.cs @@ -1,15 +1,13 @@ -using MaksIT.PodmanClientDotNet; using System.Text; using System.Text.Json; - -using Microsoft.Extensions.Logging; - +using MaksIT.PodmanClientDotNet; using MaksIT.PodmanClientDotNet.Dtos.Build; using MaksIT.PodmanClientDotNet.Dtos.Image; using MaksIT.PodmanClientDotNet.Internal; using MaksIT.PodmanClientDotNet.Models.Exec; using MaksIT.PodmanClientDotNet.Streaming; using MaksIT.Results; +using Microsoft.Extensions.Logging; public partial class PodmanClient { public async Task> AttachContainerSessionAsync( diff --git a/src/PodmanClient/PodmanClient.System.cs b/src/PodmanClient/PodmanClient.System.cs index 40ff192..96d5c65 100644 --- a/src/PodmanClient/PodmanClient.System.cs +++ b/src/PodmanClient/PodmanClient.System.cs @@ -5,7 +5,15 @@ using MaksIT.Results; public partial class PodmanClient { public Task> PingAsync(CancellationToken cancellationToken = default) => - GetJsonAsync("/libpod/_ping", "Ping", PodmanJsonContext.Default.LibpodPingDto, cancellationToken: cancellationToken); + SendAsync( + () => _httpClient.GetAsync(LibpodPath("/libpod/_ping"), cancellationToken), + "Ping", + // Podman returns plain text "OK", not JSON. + body => new LibpodPingDto { + Ping = body.Trim().Equals("OK", StringComparison.OrdinalIgnoreCase) + }, + cancellationToken + ); public Task> GetVersionAsync(CancellationToken cancellationToken = default) => GetJsonAsync("/libpod/version", "Get version", PodmanJsonContext.Default.LibpodVersionDto, cancellationToken: cancellationToken); @@ -16,8 +24,8 @@ public partial class PodmanClient { public Task> GetSystemDiskUsageAsync(CancellationToken cancellationToken = default) => GetJsonAsync("/libpod/system/df", "Get system disk usage", PodmanJsonContext.Default.SystemDfDto, cancellationToken: cancellationToken); - public Task> PruneSystemAsync(CancellationToken cancellationToken = default) => - PostLibpodAsync("/libpod/system/prune", "Prune system", PodmanJsonContext.Default.PruneReportDto, cancellationToken: cancellationToken); + public Task> PruneSystemAsync(CancellationToken cancellationToken = default) => + PostLibpodAsync("/libpod/system/prune", "Prune system", PodmanJsonContext.Default.SystemPruneReportDto, cancellationToken: cancellationToken); public Task> GetEventsAsync(CancellationToken cancellationToken = default) => GetStreamAsync("/libpod/events", "Get events", cancellationToken: cancellationToken); diff --git a/src/PodmanClient/PodmanClient.Volumes.cs b/src/PodmanClient/PodmanClient.Volumes.cs index ed6c12f..cfb180f 100644 --- a/src/PodmanClient/PodmanClient.Volumes.cs +++ b/src/PodmanClient/PodmanClient.Volumes.cs @@ -32,6 +32,6 @@ public partial class PodmanClient { cancellationToken ); - public Task> PruneVolumesAsync(CancellationToken cancellationToken = default) => - PostLibpodAsync("/libpod/volumes/prune", "Prune volumes", PodmanJsonContext.Default.PruneReportDto, cancellationToken: cancellationToken); + public Task?>> PruneVolumesAsync(CancellationToken cancellationToken = default) => + PostLibpodAsync>("/libpod/volumes/prune", "Prune volumes", PodmanJsonContext.Default.ListPruneReportEntryDto, cancellationToken: cancellationToken); } diff --git a/src/PodmanClient/PodmanClient.cs b/src/PodmanClient/PodmanClient.cs index 557ff80..476cdfd 100644 --- a/src/PodmanClient/PodmanClient.cs +++ b/src/PodmanClient/PodmanClient.cs @@ -1,6 +1,5 @@ -using Microsoft.Extensions.Logging; - using MaksIT.PodmanClientDotNet.Extensions; +using Microsoft.Extensions.Logging; /// /// HTTP client for the Podman REST API. @@ -33,7 +32,7 @@ public partial class PodmanClient : IPodmanClient { ) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); - _apiVersion = "v1.41"; + _apiVersion = "v5.4.0"; ConfigureHttpClient(serverUrl); } @@ -56,7 +55,7 @@ public partial class PodmanClient : IPodmanClient { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _apiVersion = string.IsNullOrWhiteSpace(configuration.ApiVersion) - ? "v1.41" + ? "v5.4.0" : configuration.ApiVersion; _httpClient.Timeout = TimeSpan.FromMinutes(Math.Max(1, configuration.TimeoutMinutes)); ConfigureHttpClient(configuration.ServerUrl); diff --git a/src/PodmanClient/PodmanClientDotNet.csproj b/src/PodmanClient/PodmanClientDotNet.csproj index 27de48d..19be550 100644 --- a/src/PodmanClient/PodmanClientDotNet.csproj +++ b/src/PodmanClient/PodmanClientDotNet.csproj @@ -12,7 +12,7 @@ PodmanClient.DotNet - 1.2.0 + 1.3.0 Maksym Sadovnychyy MAKS-IT PodmanClient.DotNet diff --git a/src/PodmanClient/PodmanJsonContext.cs b/src/PodmanClient/PodmanJsonContext.cs index 984ea57..af1b414 100644 --- a/src/PodmanClient/PodmanJsonContext.cs +++ b/src/PodmanClient/PodmanJsonContext.cs @@ -25,6 +25,9 @@ namespace MaksIT.PodmanClientDotNet; [JsonSerializable(typeof(ErrorResponseDto))] [JsonSerializable(typeof(IdResponseDto))] [JsonSerializable(typeof(PruneReportDto))] +[JsonSerializable(typeof(PruneReportEntryDto))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(SystemPruneReportDto))] [JsonSerializable(typeof(ReportDto))] // Build @@ -33,6 +36,7 @@ namespace MaksIT.PodmanClientDotNet; // Container [JsonSerializable(typeof(ContainerChangesDto))] +[JsonSerializable(typeof(ContainerChangeEntryDto))] [JsonSerializable(typeof(ContainerCommitDto))] [JsonSerializable(typeof(ContainerHealthCheckDto))] [JsonSerializable(typeof(ContainerInspectDto))] @@ -40,16 +44,20 @@ namespace MaksIT.PodmanClientDotNet; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(ContainerMountDto))] [JsonSerializable(typeof(ContainerStatsDto))] -[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(ContainersStatsResponseDto))] +[JsonSerializable(typeof(ContainerLibpodStatsDto))] [JsonSerializable(typeof(ContainerTopDto))] [JsonSerializable(typeof(ContainerWaitDto))] [JsonSerializable(typeof(CreateContainerResponseDto))] [JsonSerializable(typeof(DeleteContainerResponseDto))] [JsonSerializable(typeof(DeleteContainerResponseDto[]))] [JsonSerializable(typeof(MountedContainersResponseDto))] +[JsonSerializable(typeof(List>))] +[JsonSerializable(typeof(Dictionary))] // Exec [JsonSerializable(typeof(CreateExecResponseDto))] +[JsonSerializable(typeof(InspectExecProcessDto))] [JsonSerializable(typeof(InspectExecResponseDto))] // Generate @@ -58,8 +66,8 @@ namespace MaksIT.PodmanClientDotNet; // Image [JsonSerializable(typeof(ImageChangesDto))] +[JsonSerializable(typeof(ImageChangeEntryDto))] [JsonSerializable(typeof(ImageDeleteDto))] -[JsonSerializable(typeof(ImageDeleteDto[]))] [JsonSerializable(typeof(ImageHistoryEntryDto))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(ImageImportDto))] @@ -76,6 +84,8 @@ namespace MaksIT.PodmanClientDotNet; // Manifest [JsonSerializable(typeof(ManifestCreateDto))] [JsonSerializable(typeof(ManifestInspectDto))] +[JsonSerializable(typeof(ManifestPlatformDto))] +[JsonSerializable(typeof(ManifestListSpecDto))] // Network [JsonSerializable(typeof(NetworkInspectDto))] @@ -87,6 +97,9 @@ namespace MaksIT.PodmanClientDotNet; [JsonSerializable(typeof(PodListEntryDto))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(PodTopDto))] +[JsonSerializable(typeof(PodStatsDto))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(PodContainerSummaryDto))] [JsonSerializable(typeof(PodStatsResponseDto))] // System diff --git a/src/PodmanClientDotNet.Tests/Archives/Tar.cs b/src/PodmanClientDotNet.Tests/Archives/Tar.cs deleted file mode 100644 index f00dd20..0000000 --- a/src/PodmanClientDotNet.Tests/Archives/Tar.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Text; - -using ICSharpCode.SharpZipLib.Tar; - -namespace MaksIT.PodmanClientDotNet.Tests.Archives; - -public static class Tar { - public static void CreateTarFromDirectory(string sourceDirectory, Stream outputStream) { - using var tarOutputStream = new TarOutputStream(outputStream, Encoding.UTF8); - tarOutputStream.IsStreamOwner = false; - AddDirectoryFilesToTar(tarOutputStream, sourceDirectory, recursive: true); - } - - static void AddDirectoryFilesToTar( - TarOutputStream tarOutputStream, - string sourceDirectory, - bool recursive, - string? baseDirectory = null - ) { - baseDirectory ??= sourceDirectory; - - var directoryInfo = new DirectoryInfo(sourceDirectory); - - foreach (var fileInfo in directoryInfo.GetFiles()) { - var relativePath = Path.GetRelativePath(baseDirectory, fileInfo.FullName); - - var entry = TarEntry.CreateEntryFromFile(fileInfo.FullName); - entry.Name = relativePath.Replace(Path.DirectorySeparatorChar, '/'); - tarOutputStream.PutNextEntry(entry); - - using var fileStream = fileInfo.OpenRead(); - fileStream.CopyTo(tarOutputStream); - - tarOutputStream.CloseEntry(); - } - - if (!recursive) - return; - - foreach (var subDirectory in directoryInfo.GetDirectories()) - AddDirectoryFilesToTar(tarOutputStream, subDirectory.FullName, recursive: true, baseDirectory); - } -} diff --git a/src/PodmanClientDotNet.Tests/InspectExecResponseDtoTests.cs b/src/PodmanClientDotNet.Tests/InspectExecResponseDtoTests.cs new file mode 100644 index 0000000..f27ee85 --- /dev/null +++ b/src/PodmanClientDotNet.Tests/InspectExecResponseDtoTests.cs @@ -0,0 +1,40 @@ +using System.Text.Json; + + +namespace MaksIT.PodmanClientDotNet.Tests; + +public class InspectExecResponseDtoTests { + [Fact] + public void Deserialize_WhenProcessConfigIsObject_Succeeds() { + const string json = """ + { + "CanRemove": true, + "ContainerID": "abc123", + "DetachKeys": "", + "ExitCode": 0, + "ID": "exec456", + "OpenStderr": true, + "OpenStdin": false, + "OpenStdout": true, + "Running": false, + "Pid": 0, + "ProcessConfig": { + "arguments": ["-c", "echo hi"], + "entrypoint": "sh", + "privileged": false, + "tty": false, + "user": "" + } + } + """; + + var dto = JsonSerializer.Deserialize(json, PodmanJsonContext.Default.InspectExecResponseDto); + + Assert.NotNull(dto); + Assert.Equal(0, dto.ExitCode); + Assert.Equal("exec456", dto.ID); + Assert.NotNull(dto.ProcessConfig); + Assert.Equal("sh", dto.ProcessConfig.Entrypoint); + Assert.Equal(new[] { "-c", "echo hi" }, dto.ProcessConfig!.Arguments); + } +} diff --git a/src/PodmanClientDotNet.Tests/PodmanClientContainersTests.cs b/src/PodmanClientDotNet.Tests/PodmanClientContainersTests.cs deleted file mode 100644 index 78e36cf..0000000 --- a/src/PodmanClientDotNet.Tests/PodmanClientContainersTests.cs +++ /dev/null @@ -1,126 +0,0 @@ -using MaksIT.PodmanClientDotNet.Tests.Archives; - -namespace MaksIT.PodmanClientDotNet.Tests; - -[Trait("Category", "Integration")] -public class PodmanClientContainersTests { - private readonly IPodmanClient _client = PodmanClientTestFixture.CreateClient(); - - #region Success Cases - [Fact] - public async Task PodmanClient_ContainerLifecycle_Success() { - string containerName = $"podman-client-test-{Guid.NewGuid()}"; - string image = "alpine:latest"; - - await PullImageAsync(image); - var containerId = await CreateContainerAsync(containerName, image); - await StartContainerAsync(containerId); - await StopContainerAsync(containerId); - await ForceDeleteContainerAsync(containerId); - } - - [Fact] - public async Task CopyFilesToContainer_Success() { - string containerName = $"podman-client-test-{Guid.NewGuid()}"; - string image = "alpine:latest"; - string pathInContainer = "/podman-test-copy"; - string tempFolderPath = CreateTemporaryFolderWithFiles(); - - try { - await PullImageAsync(image); - var containerId = await CreateContainerAsync(containerName, image); - await StartContainerAsync(containerId); - - using var tarStream = CreateTarStream(tempFolderPath); - await CopyToContainerAsync(containerId, tarStream, pathInContainer); - - await StopContainerAsync(containerId); - await ForceDeleteContainerAsync(containerId); - } - finally { - if (Directory.Exists(tempFolderPath)) - Directory.Delete(tempFolderPath, true); - } - } - #endregion - - #region Helper Methods - private async Task PullImageAsync(string image) { - var result = await _client.PullImageAsync(image); - PodmanClientTestFixture.AssertSuccess(result); - } - - private async Task CreateContainerAsync(string containerName, string image) { - var result = await _client.CreateContainerAsync( - name: containerName, - image: image, - command: new List { "sh", "-c", "sleep infinity" }); - - string? containerId = null; - PodmanClientTestFixture.AssertSuccess(result, value => { - Assert.NotNull(value); - Assert.False(string.IsNullOrEmpty(value!.Id)); - containerId = value.Id; - }); - - return containerId!; - } - - private async Task StartContainerAsync(string containerId) { - var result = await _client.StartContainerAsync(containerId); - PodmanClientTestFixture.AssertSuccess(result); - } - - private async Task StopContainerAsync(string containerId) { - var result = await _client.StopContainerAsync(containerId); - PodmanClientTestFixture.AssertSuccess(result); - } - - private async Task ForceDeleteContainerAsync(string containerId) { - var result = await _client.ForceDeleteContainerAsync(containerId); - PodmanClientTestFixture.AssertSuccess(result); - } - - private async Task CopyToContainerAsync(string containerId, Stream tarStream, string path) { - var result = await _client.ExtractArchiveToContainerAsync(containerId, tarStream, path); - PodmanClientTestFixture.AssertSuccess(result); - } - - private static string CreateTemporaryFolderWithFiles() { - string tempFolder = Path.Combine(Path.GetTempPath(), $"podman-test-{Guid.NewGuid()}"); - Directory.CreateDirectory(tempFolder); - - for (int i = 0; i < 5; i++) - File.WriteAllText(Path.Combine(tempFolder, $"test-file-{i}.txt"), $"This is test file {i}"); - - return tempFolder; - } - - private static Stream CreateTarStream(string folderPath) { - var memoryStream = new MemoryStream(); - Tar.CreateTarFromDirectory(folderPath, memoryStream); - memoryStream.Seek(0, SeekOrigin.Begin); - return memoryStream; - } - #endregion - - #region Fail Cases - [Fact] - public async Task StartContainerAsync_Should_HandleErrors() { - var result = await _client.StartContainerAsync("invalid-container-id"); - PodmanClientTestFixture.AssertFailure(result); - } - - [Fact] - public async Task StopContainerAsync_Should_HandleErrors() { - var result = await _client.StopContainerAsync("invalid-container-id"); - PodmanClientTestFixture.AssertFailure(result); - } - - [Fact] - public async Task ForceDeleteContainerAsync_Should_HandleErrors() { - var result = await _client.ForceDeleteContainerAsync("invalid-container-id"); - PodmanClientTestFixture.AssertFailure(result); - } - #endregion -} diff --git a/src/PodmanClientDotNet.Tests/PodmanClientExecTests.cs b/src/PodmanClientDotNet.Tests/PodmanClientExecTests.cs deleted file mode 100644 index 4650e11..0000000 --- a/src/PodmanClientDotNet.Tests/PodmanClientExecTests.cs +++ /dev/null @@ -1,127 +0,0 @@ -namespace MaksIT.PodmanClientDotNet.Tests; - -[Trait("Category", "Integration")] -public class PodmanClientExecTests { - private readonly IPodmanClient _client = PodmanClientTestFixture.CreateClient(); - - #region Success Cases - [Fact] - public async Task Full_ContainerLifecycle_With_Exec_Should_Succeed() { - string containerName = $"podman-client-test-{Guid.NewGuid()}"; - string image = "alpine:latest"; - - await PullImageAsync(image); - var containerId = await CreateContainerAsync(containerName, image); - await StartContainerAsync(containerId); - - var execId = await CreateExecAsync(containerName, new[] { "apk", "add", "--no-cache", "curl" }); - await StartExecAsync(execId); - - await StopContainerAsync(containerId); - await ForceDeleteContainerAsync(containerId); - } - #endregion - - #region Helper Methods - private async Task PullImageAsync(string image) { - var result = await _client.PullImageAsync(image); - PodmanClientTestFixture.AssertSuccess(result); - } - - private async Task CreateContainerAsync(string containerName, string image) { - var result = await _client.CreateContainerAsync( - name: containerName, - image: image, - command: new List { "sh", "-c", "sleep infinity" }); - - string? containerId = null; - PodmanClientTestFixture.AssertSuccess(result, value => { - Assert.NotNull(value); - Assert.False(string.IsNullOrEmpty(value!.Id)); - containerId = value.Id; - }); - - return containerId!; - } - - private async Task StartContainerAsync(string containerId) { - var result = await _client.StartContainerAsync(containerId); - PodmanClientTestFixture.AssertSuccess(result); - } - - private async Task CreateExecAsync(string containerName, string[] cmd) { - var result = await _client.CreateExecAsync(containerName, cmd); - - string? execId = null; - PodmanClientTestFixture.AssertSuccess(result, value => { - Assert.NotNull(value); - Assert.False(string.IsNullOrEmpty(value!.Id)); - execId = value.Id; - }); - - return execId!; - } - - private async Task StartExecAsync(string execId) { - var result = await _client.StartExecAsync(execId); - PodmanClientTestFixture.AssertSuccess(result); - } - - private async Task StopContainerAsync(string containerId) { - var result = await _client.StopContainerAsync(containerId); - PodmanClientTestFixture.AssertSuccess(result); - } - - private async Task ForceDeleteContainerAsync(string containerId) { - var result = await _client.ForceDeleteContainerAsync(containerId); - PodmanClientTestFixture.AssertSuccess(result); - } - #endregion - - #region Fail Cases - [Fact] - public async Task PullImageAsync_Should_HandleErrors() { - var result = await _client.PullImageAsync("invalidimage:latest"); - PodmanClientTestFixture.AssertFailure(result); - } - - [Fact] - public async Task CreateContainerAsync_Should_HandleErrors() { - var result = await _client.CreateContainerAsync( - "test-container", - "invalidimage:latest", - new List { "sh", "-c", "sleep infinity" }); - PodmanClientTestFixture.AssertFailure(result); - } - - [Fact] - public async Task StartContainerAsync_Should_HandleErrors() { - var result = await _client.StartContainerAsync("invalid-container-id"); - PodmanClientTestFixture.AssertFailure(result); - } - - [Fact] - public async Task CreateExecAsync_Should_HandleErrors() { - var result = await _client.CreateExecAsync("invalid-container", new[] { "apk", "add", "--no-cache", "curl" }); - PodmanClientTestFixture.AssertFailure(result); - } - - [Fact] - public async Task StartExecAsync_Should_HandleErrors() { - var result = await _client.StartExecAsync("invalid-exec-id"); - PodmanClientTestFixture.AssertFailure(result); - } - - [Fact] - public async Task StopContainerAsync_Should_HandleErrors() { - var result = await _client.StopContainerAsync("invalid-container-id"); - PodmanClientTestFixture.AssertFailure(result); - } - - [Fact] - public async Task ForceDeleteContainerAsync_Should_HandleErrors() { - var result = await _client.ForceDeleteContainerAsync("invalid-container-id"); - PodmanClientTestFixture.AssertFailure(result); - } - #endregion -} diff --git a/src/PodmanClientDotNet.Tests/PodmanClientImagesTests.cs b/src/PodmanClientDotNet.Tests/PodmanClientImagesTests.cs deleted file mode 100644 index 5751091..0000000 --- a/src/PodmanClientDotNet.Tests/PodmanClientImagesTests.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace MaksIT.PodmanClientDotNet.Tests; - -[Trait("Category", "Integration")] -public class PodmanClientImagesTests { - private readonly IPodmanClient _client = PodmanClientTestFixture.CreateClient(); - - #region Success Cases - [Fact] - public async Task PodmanClient_IntegrationTests() { - await PullImageAsync_Should_Succeed(); - await TagImageAsync_Should_Succeed(); - } - - private async Task PullImageAsync_Should_Succeed() { - var result = await _client.PullImageAsync("alpine:latest"); - PodmanClientTestFixture.AssertSuccess(result); - } - - private async Task TagImageAsync_Should_Succeed() { - var result = await _client.TagImageAsync("alpine:latest", "myrepo", "v1"); - PodmanClientTestFixture.AssertSuccess(result); - } - #endregion - - #region Fail Cases - [Fact] - public async Task PodmanClient_PullImage_Errors() { - var result = await _client.PullImageAsync("dghdfdghmhgn:latest"); - PodmanClientTestFixture.AssertFailure(result); - } - - [Fact] - public async Task PodmanClient_TagImage_Errors() { - var result = await _client.TagImageAsync("dghdfdghmhgn:latest", "myrepo", "v1"); - PodmanClientTestFixture.AssertFailure(result); - } - #endregion -} diff --git a/src/PodmanClientDotNet.Tests/PodmanClientStreamingIntegrationTests.cs b/src/PodmanClientDotNet.Tests/PodmanClientStreamingIntegrationTests.cs deleted file mode 100644 index 9069477..0000000 --- a/src/PodmanClientDotNet.Tests/PodmanClientStreamingIntegrationTests.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System.Text; - -using MaksIT.PodmanClientDotNet.Streaming; - -namespace MaksIT.PodmanClientDotNet.Tests; - -[Trait("Category", "Integration")] -public class PodmanClientStreamingIntegrationTests { - private readonly IPodmanClient _client = PodmanClientTestFixture.CreateClient(); - - [Fact] - public async Task AttachContainerSessionAsync_ReadsStdoutFromRunningContainer() { - var cancellationToken = TestContext.Current.CancellationToken; - var name = $"attach-session-{Guid.NewGuid():N}"; - const string image = "alpine:latest"; - - await PullImageAsync(image); - var containerId = await CreateContainerAsync(name, image, ["sh", "-c", "echo hello-attach"]); - await StartContainerAsync(containerId); - - var attachResult = await _client.AttachContainerSessionAsync( - containerId, - stream: true, - stdout: true, - stderr: true, - stdin: false, - tty: false, - cancellationToken: cancellationToken); - - PodmanClientTestFixture.AssertSuccess(attachResult); - await using var session = attachResult.Value!; - Assert.False(session.IsRawTerminal); - - var output = new StringBuilder(); - PodmanStreamFrame? frame; - while ((frame = await session.ReadFrameAsync(cancellationToken)) is not null) - output.Append(Encoding.UTF8.GetString(frame.Data)); - - Assert.Contains("hello-attach", output.ToString()); - - await StopContainerAsync(containerId); - await ForceDeleteContainerAsync(containerId); - } - - [Fact] - public async Task StartExecSessionAsync_RunsCommandAndReadsOutput() { - var cancellationToken = TestContext.Current.CancellationToken; - var name = $"exec-session-{Guid.NewGuid():N}"; - const string image = "alpine:latest"; - - await PullImageAsync(image); - var containerId = await CreateContainerAsync(name, image, ["sh", "-c", "sleep 300"]); - await StartContainerAsync(containerId); - - var createExec = await _client.CreateExecAsync(containerId, ["echo", "exec-ok"]); - PodmanClientTestFixture.AssertSuccess(createExec); - var execId = createExec.Value!.Id!; - - var sessionResult = await _client.StartExecSessionAsync(execId, tty: false, cancellationToken: cancellationToken); - PodmanClientTestFixture.AssertSuccess(sessionResult); - - await using var session = sessionResult.Value!; - var output = new StringBuilder(); - PodmanStreamFrame? frame; - while ((frame = await session.ReadFrameAsync(cancellationToken)) is not null) - output.Append(Encoding.UTF8.GetString(frame.Data)); - - Assert.Contains("exec-ok", output.ToString()); - - await StopContainerAsync(containerId); - await ForceDeleteContainerAsync(containerId); - } - - [Fact] - public async Task PullImageWithProgressAsync_YieldsStatusLines() { - const string image = "alpine:latest"; - - var cancellationToken = TestContext.Current.CancellationToken; - var result = await _client.PullImageWithProgressAsync(image, cancellationToken: cancellationToken); - PodmanClientTestFixture.AssertSuccess(result); - - await using var session = result.Value!; - var lines = new List(); - await foreach (var item in session.ReadProgressAsync(cancellationToken)) { - if (!string.IsNullOrEmpty(item.Status)) - lines.Add(item.Status); - if (!string.IsNullOrEmpty(item.Error)) - break; - } - - Assert.NotEmpty(lines); - } - - [Fact] - public async Task AttachContainerSessionAsync_InvalidContainer_Fails() { - var result = await _client.AttachContainerSessionAsync( - "nonexistent-container-id", - cancellationToken: TestContext.Current.CancellationToken); - PodmanClientTestFixture.AssertFailure(result); - } - - private async Task PullImageAsync(string image) { - var result = await _client.PullImageAsync(image); - PodmanClientTestFixture.AssertSuccess(result); - } - - private async Task CreateContainerAsync(string name, string image, List command) { - var result = await _client.CreateContainerAsync(name: name, image: image, command: command); - string? containerId = null; - PodmanClientTestFixture.AssertSuccess(result, value => containerId = value!.Id); - return containerId!; - } - - private async Task StartContainerAsync(string containerId) { - var result = await _client.StartContainerAsync(containerId); - PodmanClientTestFixture.AssertSuccess(result); - } - - private async Task StopContainerAsync(string containerId) { - var result = await _client.StopContainerAsync(containerId); - PodmanClientTestFixture.AssertSuccess(result); - } - - private async Task ForceDeleteContainerAsync(string containerId) { - var result = await _client.ForceDeleteContainerAsync(containerId); - PodmanClientTestFixture.AssertSuccess(result); - } -} diff --git a/src/PodmanClientDotNet.Tests/PodmanClientTestFixture.cs b/src/PodmanClientDotNet.Tests/PodmanClientTestFixture.cs deleted file mode 100644 index ffb8ff0..0000000 --- a/src/PodmanClientDotNet.Tests/PodmanClientTestFixture.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Microsoft.Extensions.Logging; - -using MaksIT.Results; - -namespace MaksIT.PodmanClientDotNet.Tests; - -internal static class PodmanClientTestFixture { - /// - /// Podman API base URL for integration tests. Set PODMAN_TEST_URL (or PODMAN_INTEGRATION_URL) to enable. - /// - internal static string? IntegrationServerUrl => - Environment.GetEnvironmentVariable("PODMAN_TEST_URL") - ?? Environment.GetEnvironmentVariable("PODMAN_INTEGRATION_URL"); - - internal static bool IsIntegrationEnabled => !string.IsNullOrWhiteSpace(IntegrationServerUrl); - - internal static IPodmanClient CreateClient() { - Assert.SkipUnless(IsIntegrationEnabled, "Set PODMAN_TEST_URL to run Podman integration tests."); - var logger = LoggerFactory.Create(builder => builder.AddConsole()) - .CreateLogger(); - return new PodmanClient(logger, IntegrationServerUrl!, 60); - } - - internal static void AssertSuccess(Result result) { - Assert.True(result.IsSuccess, string.Join("; ", result.Messages)); - } - - internal static void AssertFailure(Result result) { - Assert.False(result.IsSuccess); - Assert.NotEmpty(result.Messages); - } - - internal static void AssertSuccess(Result result, Action? assertValue = null) { - Assert.True(result.IsSuccess, string.Join("; ", result.Messages)); - assertValue?.Invoke(result.Value); - } - - internal static void AssertFailure(Result result) { - Assert.False(result.IsSuccess); - Assert.NotEmpty(result.Messages); - } -} diff --git a/src/PodmanClientDotNet.Tests/Streaming/PodmanNdjsonStreamsTests.cs b/src/PodmanClientDotNet.Tests/Streaming/PodmanNdjsonStreamsTests.cs index 25da487..cabfff4 100644 --- a/src/PodmanClientDotNet.Tests/Streaming/PodmanNdjsonStreamsTests.cs +++ b/src/PodmanClientDotNet.Tests/Streaming/PodmanNdjsonStreamsTests.cs @@ -1,6 +1,4 @@ using System.Text; - -using MaksIT.PodmanClientDotNet.Dtos.Build; using MaksIT.PodmanClientDotNet.Internal; using Microsoft.Extensions.Logging; diff --git a/src/PodmanClientDotNet.slnx b/src/PodmanClientDotNet.slnx index b079a07..b8201b8 100644 --- a/src/PodmanClientDotNet.slnx +++ b/src/PodmanClientDotNet.slnx @@ -1,4 +1,5 @@ + diff --git a/src/e2e-tests/Podman.E2E.Common.ps1 b/src/e2e-tests/Podman.E2E.Common.ps1 new file mode 100644 index 0000000..3949783 --- /dev/null +++ b/src/e2e-tests/Podman.E2E.Common.ps1 @@ -0,0 +1,77 @@ +# Shared helpers for Podman E2E. Dot-sourced by Test-PodmanE2E.ps1 and scenarios. + +$script:PodmanE2eScenarioRegistry = [System.Collections.Generic.List[hashtable]]::new() +$script:PodmanE2eCmdletHits = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + +function Clear-PodmanE2eScenarioRegistry { + $script:PodmanE2eScenarioRegistry.Clear() + $script:PodmanE2eCmdletHits.Clear() +} + +function Register-PodmanE2eScenario { + param( + [Parameter(Mandatory)][string] $Id, + [Parameter(Mandatory)][string] $Description, + [Parameter(Mandatory)][scriptblock] $ScriptBlock + ) + $script:PodmanE2eScenarioRegistry.Add(@{ + Id = $Id + Description = $Description + ScriptBlock = $ScriptBlock + }) | Out-Null +} + +function Write-E2eLog { + param( + [Parameter(Mandatory)][string] $Message, + [ValidateSet('Default', 'Step', 'Ok', 'Warn')] + [string] $Kind = 'Default' + ) + $ts = (Get-Date).ToUniversalTime().ToString('o') + $line = "[$ts] $Message" + switch ($Kind) { + 'Step' { Write-Host $line -ForegroundColor Cyan } + 'Ok' { Write-Host $line -ForegroundColor Green } + 'Warn' { Write-Host $line -ForegroundColor Yellow } + default { Write-Host $line } + } +} + +function New-PodmanE2eSuffix { + [guid]::NewGuid().ToString('N').Substring(0, 8) +} + +function Use-PodmanE2eCmdlet { + param([Parameter(Mandatory)][string] $Name) + [void]$script:PodmanE2eCmdletHits.Add($Name) +} + +function Assert-PodmanE2eTrue { + param([bool] $Condition, [string] $Message) + if (-not $Condition) { throw $Message } +} + +function Assert-PodmanE2eError { + param( + [Parameter(Mandatory)][scriptblock] $ScriptBlock, + [string] $Message = 'Expected cmdlet to fail.' + ) + $failed = $false + try { + & $ScriptBlock 2>&1 | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { $failed = $true } + } + } + catch { + $failed = $true + } + if (-not $failed) { throw $Message } +} + +function New-PodmanE2eTarFromFolder { + param([Parameter(Mandatory)][string] $FolderPath, [Parameter(Mandatory)][string] $TarPath) + if (Test-Path -LiteralPath $TarPath) { Remove-Item -LiteralPath $TarPath -Force } + & tar -cf $TarPath -C $FolderPath . + if ($LASTEXITCODE -ne 0) { throw "tar failed creating $TarPath" } + return $TarPath +} diff --git a/src/e2e-tests/Test-PodmanE2E.bat b/src/e2e-tests/Test-PodmanE2E.bat new file mode 100644 index 0000000..7f3a281 --- /dev/null +++ b/src/e2e-tests/Test-PodmanE2E.bat @@ -0,0 +1,18 @@ +@echo off +setlocal +cd /d "%~dp0" + +echo. +echo Podman E2E — scenarios in src\e2e-tests\scenarios\ (requires PODMAN_TEST_URL^). +echo Optional: -Scenario System or -Scenario '*Image*' +echo. + +where pwsh >nul 2>&1 +if errorlevel 1 ( + echo PowerShell 7+ ^(pwsh^) is required but was not found in PATH. + echo Install from https://github.com/PowerShell/PowerShell/releases ^(.NET 10 host^) + exit /b 1 +) + +pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Test-PodmanE2E.ps1" %* +exit /b %ERRORLEVEL% diff --git a/src/e2e-tests/Test-PodmanE2E.ps1 b/src/e2e-tests/Test-PodmanE2E.ps1 new file mode 100644 index 0000000..bc08677 --- /dev/null +++ b/src/e2e-tests/Test-PodmanE2E.ps1 @@ -0,0 +1,172 @@ +#Requires -Version 7 +<# +.SYNOPSIS + End-to-end tests against a running Podman API via MaksIT.PodmanClientDotNet.PowerShell. + +.DESCRIPTION + Builds the module, connects with PODMAN_TEST_URL, then runs scenarios from scenarios\Scenario-*.ps1. + + Filter: + pwsh -File .\src\e2e-tests\Test-PodmanE2E.ps1 -Scenario 'System' + pwsh -File .\src\e2e-tests\Test-PodmanE2E.ps1 -Scenario '*Image*','*Container*' + +.EXAMPLE + $env:PODMAN_TEST_URL = 'http://192.168.2.128:8080' + pwsh -File .\src\e2e-tests\Test-PodmanE2E.ps1 +#> +param( + [string[]] $Scenario = @('*') +) + +# Allow comma-separated values from cmd.exe / single-arg callers: -Scenario System,Images +$Scenario = @( + $Scenario | + ForEach-Object { $_ -split ',' } | + ForEach-Object { $_.Trim().Trim("'").Trim('"') } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } +) +if ($Scenario.Count -eq 0) { + $Scenario = @('*') +} + +$e2eRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +. (Join-Path $e2eRoot 'Podman.E2E.Common.ps1') + +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'This script is pwsh-only.' +} + +$runtimeFx = [System.Runtime.InteropServices.RuntimeInformation]::FrameworkDescription +if ($runtimeFx -notmatch '\.NET 10\.') { + throw ( + "The Podman module is net10.0; this pwsh is hosted on: $runtimeFx" + [Environment]::NewLine + + 'Install PowerShell 7 with .NET 10 from https://github.com/PowerShell/PowerShell/releases' + ) +} + +$envName = 'PODMAN_TEST_URL' +$url = [Environment]::GetEnvironmentVariable($envName, 'Process') +if ([string]::IsNullOrWhiteSpace($url)) { + $url = [Environment]::GetEnvironmentVariable($envName, 'User') +} +if ([string]::IsNullOrWhiteSpace($url)) { + $url = [Environment]::GetEnvironmentVariable($envName, 'Machine') +} + +if ([string]::IsNullOrWhiteSpace($url)) { + throw @" +PODMAN_TEST_URL is not set (Process, User, or Machine). + +Example: + `$env:PODMAN_TEST_URL = 'http://192.168.2.128:8080' + # must be an absolute http(s) URL (include the scheme) +"@ +} + +$url = $url.Trim().TrimEnd('/') +if ($url -notmatch '^https?://') { + throw "PODMAN_TEST_URL must be an absolute http(s) URL. Got: $url" +} + +$repoRoot = Resolve-Path (Join-Path $e2eRoot '..\..') +$moduleTfm = 'net10.0' +$relModuleProject = 'src\PodmanClient.PowerShell\PodmanClient.PowerShell.csproj' +$relModuleManifest = "src\PodmanClient.PowerShell\bin\Debug\$moduleTfm\MaksIT.PodmanClientDotNet.PowerShell.psd1" + +Write-E2eLog -Kind Step -Message "Build: $relModuleProject" +Push-Location $repoRoot +try { + $buildOutput = dotnet build $relModuleProject 2>&1 + if ($LASTEXITCODE -ne 0) { + $buildOutput | ForEach-Object { Write-Host $_ } + throw "Build failed: $relModuleProject" + } + if (-not (Test-Path -LiteralPath $relModuleManifest)) { + throw "Module manifest not found: $relModuleManifest" + } + $moduleManifest = (Resolve-Path -LiteralPath $relModuleManifest).Path +} +finally { + Pop-Location +} + +Write-E2eLog -Kind Ok -Message "Importing module: $moduleManifest" +Import-Module $moduleManifest -Force + +$exported = @(Get-Command -Module MaksIT.PodmanClientDotNet.PowerShell | Select-Object -ExpandProperty Name) + +Clear-PodmanE2eScenarioRegistry +$scenarioDir = Join-Path $e2eRoot 'scenarios' +if (-not (Test-Path -LiteralPath $scenarioDir)) { + throw "Scenarios directory missing: $scenarioDir" +} +Get-ChildItem -LiteralPath $scenarioDir -Filter 'Scenario-*.ps1' | Sort-Object Name | ForEach-Object { + Write-E2eLog -Message "Load scenarios: $($_.Name)" + . $_.FullName +} + +Write-E2eLog -Kind Step -Message "Connect-Podman (base URL: $url, ApiVersion: v5.4.0)" +Connect-Podman -BaseAddress $url -ApiVersion 'v5.4.0' +Use-PodmanE2eCmdlet Connect-Podman +Write-E2eLog -Kind Ok -Message 'Connect-Podman: session ready' + +try { + $ver = Get-PodmanVersion + $engine = $ver.Version + if (-not $engine) { $engine = $ver.Components | Where-Object { $_.Name -eq 'Podman Engine' } | Select-Object -ExpandProperty Version -First 1 } + Write-E2eLog -Message "Server Podman version: $engine (E2E baseline: 5.4.0)" + if ($engine -and ("$engine" -notlike '5.4*')) { + Write-E2eLog -Kind Warn "Server is $engine; this suite is validated against Podman 5.4.0" + } +} +catch { + Write-E2eLog -Kind Warn "Could not read Get-PodmanVersion: $($_.Exception.Message)" +} + +$ErrorActionPreference = 'Stop' +$ran = 0 +try { + foreach ($entry in $script:PodmanE2eScenarioRegistry) { + $include = $false + foreach ($p in $Scenario) { + if ($entry.Id -like $p) { + $include = $true + break + } + } + if (-not $include) { + Write-E2eLog -Kind Warn "Skip (filter): $($entry.Id)" + continue + } + + Write-E2eLog -Kind Step -Message "========== Scenario: $($entry.Id) ==========" + Write-E2eLog -Message $entry.Description + try { + & $entry.ScriptBlock + $ran++ + } + catch { + Write-E2eLog -Kind Warn "Scenario '$($entry.Id)' FAILED: $($_.Exception.Message)" + throw + } + } + + if ($ran -eq 0) { + $registered = ($script:PodmanE2eScenarioRegistry | ForEach-Object { $_.Id }) -join ', ' + throw "No scenarios matched -Scenario patterns: $($Scenario -join ', '). Registered: $registered" + } + + Use-PodmanE2eCmdlet Disconnect-Podman + + $missing = @($exported | Where-Object { -not $script:PodmanE2eCmdletHits.Contains($_) }) + if ($missing.Count -gt 0 -and ($Scenario.Count -eq 1 -and $Scenario[0] -eq '*')) { + Write-E2eLog -Kind Warn -Message ("Cmdlets not marked Use-PodmanE2eCmdlet in scenarios: " + ($missing -join ', ')) + throw "E2E completeness gate failed: $($missing.Count) cmdlet(s) not exercised." + } + + Write-E2eLog -Kind Ok -Message "All selected scenarios passed ($ran run)." +} +finally { + Disconnect-Podman + Write-E2eLog -Message 'Disconnect-Podman' +} diff --git a/src/e2e-tests/scenarios/Scenario-01-System.ps1 b/src/e2e-tests/scenarios/Scenario-01-System.ps1 new file mode 100644 index 0000000..edb6e39 --- /dev/null +++ b/src/e2e-tests/scenarios/Scenario-01-System.ps1 @@ -0,0 +1,40 @@ +Register-PodmanE2eScenario -Id 'System' -Description 'System ping, version, info, df, events sample, prune system' -ScriptBlock { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("podman-e2e-events-{0}.json" -f (New-PodmanE2eSuffix)) + try { + Write-E2eLog -Kind Step 'Test-PodmanConnection' + Use-PodmanE2eCmdlet 'Test-PodmanConnection' + $ping = Test-PodmanConnection + Assert-PodmanE2eTrue ($null -ne $ping -and $ping.Ping) 'Ping did not return OK' + + Write-E2eLog -Kind Step 'Get-PodmanVersion' + Use-PodmanE2eCmdlet 'Get-PodmanVersion' + $ver = Get-PodmanVersion + Assert-PodmanE2eTrue ($null -ne $ver -and -not [string]::IsNullOrWhiteSpace($ver.Version)) 'Version string missing' + + Write-E2eLog -Kind Step 'Get-PodmanInfo' + Use-PodmanE2eCmdlet 'Get-PodmanInfo' + $info = Get-PodmanInfo + Assert-PodmanE2eTrue ($null -ne $info) 'Info returned null' + + Write-E2eLog -Kind Step 'Get-PodmanSystemDiskUsage' + Use-PodmanE2eCmdlet 'Get-PodmanSystemDiskUsage' + $df = Get-PodmanSystemDiskUsage + Assert-PodmanE2eTrue ($null -ne $df) 'System disk usage returned null' + + Write-E2eLog -Kind Step 'Get-PodmanEvent (timed sample)' + Use-PodmanE2eCmdlet 'Get-PodmanEvent' + $null = Get-PodmanEvent -OutFile $tmp -ReadTimeoutSeconds 2 + Assert-PodmanE2eTrue (Test-Path -LiteralPath $tmp) 'Events OutFile was not created' + + Write-E2eLog -Kind Step 'Invoke-PodmanPruneSystem' + Use-PodmanE2eCmdlet 'Invoke-PodmanPruneSystem' + $null = Invoke-PodmanPruneSystem + + Write-E2eLog -Kind Ok 'System scenario passed' + } + finally { + if (Test-Path -LiteralPath $tmp) { + Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue + } + } +} diff --git a/src/e2e-tests/scenarios/Scenario-02-Images.ps1 b/src/e2e-tests/scenarios/Scenario-02-Images.ps1 new file mode 100644 index 0000000..90ca5d1 --- /dev/null +++ b/src/e2e-tests/scenarios/Scenario-02-Images.ps1 @@ -0,0 +1,139 @@ +Register-PodmanE2eScenario -Id 'Images' -Description 'Pull/list/inspect/tag/history/tree/export/save/load/import/push/prune images' -ScriptBlock { + $suffix = New-PodmanE2eSuffix + $image = 'alpine:latest' + $tagRepo = "localhost/e2e-img-$suffix" + $tagName = 'v1' + $tagged = "${tagRepo}:${tagName}" + $batchTag = "localhost/e2e-batch-${suffix}:latest" + $importRef = "localhost/e2e-import-${suffix}:latest" + $tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ("podman-e2e-images-$suffix") + New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null + $exportTar = Join-Path $tmpDir 'export.tar' + $saveTar = Join-Path $tmpDir 'save.tar' + $fsTar = Join-Path $tmpDir 'fs-import.tar' + $containerName = "e2e-img-export-$suffix" + $containerId = $null + + try { + Write-E2eLog -Kind Step 'Invoke-PodmanPullImage alpine:latest' + Use-PodmanE2eCmdlet 'Invoke-PodmanPullImage' + $null = Invoke-PodmanPullImage -Reference $image -Quiet + + Write-E2eLog -Kind Step 'Invoke-PodmanPullImageProgress' + Use-PodmanE2eCmdlet 'Invoke-PodmanPullImageProgress' + $progress = @(Invoke-PodmanPullImageProgress -Reference $image -Quiet -Wait) + Assert-PodmanE2eTrue ($progress.Count -ge 0) 'Pull progress returned unexpected result' + + Write-E2eLog -Kind Step 'Get-PodmanImageList / Test-PodmanImage / Get-PodmanImage' + Use-PodmanE2eCmdlet 'Get-PodmanImageList' + $list = @(Get-PodmanImageList -All) + Assert-PodmanE2eTrue ($list.Count -gt 0) 'Image list empty after pull' + + Use-PodmanE2eCmdlet 'Test-PodmanImage' + $exists = Test-PodmanImage -Name $image + Assert-PodmanE2eTrue ($exists -eq $true) 'alpine:latest should exist' + + Use-PodmanE2eCmdlet 'Get-PodmanImage' + $inspect = Get-PodmanImage -Name $image + Assert-PodmanE2eTrue ($null -ne $inspect) 'Inspect image returned null' + + Write-E2eLog -Kind Step 'Search-PodmanImage' + Use-PodmanE2eCmdlet 'Search-PodmanImage' + $search = @(Search-PodmanImage -Term 'alpine' -Limit 5) + Assert-PodmanE2eTrue ($search.Count -ge 0) 'Search failed unexpectedly' + + Write-E2eLog -Kind Step 'Tag / history / tree / changes' + Use-PodmanE2eCmdlet 'Invoke-PodmanTagImage' + $null = Invoke-PodmanTagImage -Image $image -Repo $tagRepo -Tag $tagName + + Use-PodmanE2eCmdlet 'Get-PodmanImageHistory' + $history = @(Get-PodmanImageHistory -Name $tagged) + Assert-PodmanE2eTrue ($history.Count -ge 0) 'History empty/failed' + + Use-PodmanE2eCmdlet 'Get-PodmanImageTree' + $tree = Get-PodmanImageTree -Name $tagged + Assert-PodmanE2eTrue ($null -ne $tree) 'Image tree null' + + Use-PodmanE2eCmdlet 'Get-PodmanImageChange' + $changes = Get-PodmanImageChange -Name $tagged + Assert-PodmanE2eTrue ($null -ne $changes -or $true) 'Image changes call completed' + + Write-E2eLog -Kind Step 'Export-PodmanImage / Save-PodmanImage / Import-PodmanImageArchive' + Use-PodmanE2eCmdlet 'Export-PodmanImage' + $null = Export-PodmanImage -Reference @($image) -OutFile $exportTar + Assert-PodmanE2eTrue (Test-Path -LiteralPath $exportTar) 'Export tar missing' + + Use-PodmanE2eCmdlet 'Save-PodmanImage' + $null = Save-PodmanImage -Name $image -OutFile $saveTar + Assert-PodmanE2eTrue (Test-Path -LiteralPath $saveTar) 'Save tar missing' + + Use-PodmanE2eCmdlet 'Import-PodmanImageArchive' + $null = Import-PodmanImageArchive -Path $saveTar + + Write-E2eLog -Kind Step 'Import-PodmanImage from container filesystem export' + Use-PodmanE2eCmdlet 'New-PodmanContainer' + $created = New-PodmanContainer -Name $containerName -Image $image -Command @('sh', '-c', 'echo import-src') + $containerId = $created.Id + Assert-PodmanE2eTrue (-not [string]::IsNullOrWhiteSpace($containerId)) 'Container create for import failed' + + Use-PodmanE2eCmdlet 'Export-PodmanContainer' + $null = Export-PodmanContainer -Name $containerId -OutFile $fsTar + + Use-PodmanE2eCmdlet 'Import-PodmanImage' + $null = Import-PodmanImage -Path $fsTar -Reference $importRef -Message "e2e-import-$suffix" + + Write-E2eLog -Kind Step 'Invoke-PodmanPushImage (bogus destination expects failure)' + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Invoke-PodmanPushImage' + Invoke-PodmanPushImage -Name $image -Destination "127.0.0.1:1/e2e-no-registry-$suffix/alpine:latest" -TlsVerify:$false + } + + Write-E2eLog -Kind Step 'Untag / Remove-PodmanImage / Remove-PodmanImageBatch / Prune' + Use-PodmanE2eCmdlet 'Invoke-PodmanUntagImage' + $null = Invoke-PodmanUntagImage -Name $tagged -Repo $tagRepo -Tag $tagName + + Use-PodmanE2eCmdlet 'Invoke-PodmanTagImage' + $null = Invoke-PodmanTagImage -Image $image -Repo "localhost/e2e-batch-$suffix" -Tag 'latest' + + Use-PodmanE2eCmdlet 'Remove-PodmanImageBatch' + $null = Remove-PodmanImageBatch -Image @($batchTag) -Force + + Use-PodmanE2eCmdlet 'Remove-PodmanImage' + try { + $null = Remove-PodmanImage -Name $importRef -Force + } + catch { + Write-E2eLog -Kind Warn "Remove import ref: $($_.Exception.Message)" + } + + Use-PodmanE2eCmdlet 'Invoke-PodmanPruneImage' + $null = Invoke-PodmanPruneImage + + Write-E2eLog -Kind Step 'Negative: invalid image id' + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Get-PodmanImage' + Get-PodmanImage -Name "no-such-image-$suffix" + } + + Write-E2eLog -Kind Ok 'Images scenario passed' + } + finally { + if ($containerId) { + try { + Use-PodmanE2eCmdlet 'Remove-PodmanContainer' + Remove-PodmanContainer -Name $containerId -Force -Ignore + } + catch { } + } + foreach ($ref in @($tagged, $batchTag, $importRef)) { + try { + Use-PodmanE2eCmdlet 'Remove-PodmanImage' + Remove-PodmanImage -Name $ref -Force + } + catch { } + } + if (Test-Path -LiteralPath $tmpDir) { + Remove-Item -LiteralPath $tmpDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} diff --git a/src/e2e-tests/scenarios/Scenario-03-ContainersLifecycle.ps1 b/src/e2e-tests/scenarios/Scenario-03-ContainersLifecycle.ps1 new file mode 100644 index 0000000..a8c678d --- /dev/null +++ b/src/e2e-tests/scenarios/Scenario-03-ContainersLifecycle.ps1 @@ -0,0 +1,229 @@ +Register-PodmanE2eScenario -Id 'ContainersLifecycle' -Description 'Container create/init/start/inspect/stats/pause/kill/wait/commit/checkpoint/mount/prune' -ScriptBlock { + $suffix = New-PodmanE2eSuffix + $image = 'alpine:latest' + $name = "e2e-ctr-$suffix" + $renamed = "e2e-ctr-renamed-$suffix" + $commitRepo = "localhost/e2e-commit-$suffix" + $commitTag = 'latest' + $containerId = $null + $activeName = $name + $ckptTar = Join-Path ([System.IO.Path]::GetTempPath()) ("podman-e2e-ckpt-$suffix.tar") + $logFile = Join-Path ([System.IO.Path]::GetTempPath()) ("podman-e2e-ctrlog-$suffix.txt") + + try { + Write-E2eLog -Kind Step 'Ensure alpine image' + Use-PodmanE2eCmdlet 'Invoke-PodmanPullImage' + $null = Invoke-PodmanPullImage -Reference $image -Quiet + + Write-E2eLog -Kind Step 'New-PodmanContainer / Initialize / Start' + Use-PodmanE2eCmdlet 'New-PodmanContainer' + $created = New-PodmanContainer -Name $name -Image $image -Command @('sh', '-c', 'sleep 300') + $containerId = $created.Id + Assert-PodmanE2eTrue (-not [string]::IsNullOrWhiteSpace($containerId)) 'CreateContainerResponseDto.Id missing' + + Use-PodmanE2eCmdlet 'Initialize-PodmanContainer' + try { + Initialize-PodmanContainer -Name $containerId + } + catch { + Write-E2eLog -Kind Warn "Initialize-PodmanContainer: $($_.Exception.Message)" + } + + Use-PodmanE2eCmdlet 'Start-PodmanContainer' + Start-PodmanContainer -Name $containerId + + Write-E2eLog -Kind Step 'Inspect / list / exists / logs / stats / top / changes' + Use-PodmanE2eCmdlet 'Get-PodmanContainer' + $inspect = Get-PodmanContainer -Name $containerId + Assert-PodmanE2eTrue ($null -ne $inspect) 'Inspect container null' + + Use-PodmanE2eCmdlet 'Get-PodmanContainerList' + $clist = @(Get-PodmanContainerList -All) + Assert-PodmanE2eTrue ($clist.Count -gt 0) 'Container list empty' + + Use-PodmanE2eCmdlet 'Test-PodmanContainer' + $exists = Test-PodmanContainer -Name $containerId + Assert-PodmanE2eTrue ($exists -eq $true) 'Container should exist' + + Use-PodmanE2eCmdlet 'Get-PodmanContainerLog' + $null = Get-PodmanContainerLog -Name $containerId -Tail '10' -OutFile $logFile + + Use-PodmanE2eCmdlet 'Get-PodmanContainerStat' + $stat = Get-PodmanContainerStat -Name $containerId + Assert-PodmanE2eTrue ($null -ne $stat) 'Container stat null' + + Use-PodmanE2eCmdlet 'Get-PodmanContainerStatBatch' + $batch = Get-PodmanContainerStatBatch -Containers @($containerId) + Assert-PodmanE2eTrue ($null -ne $batch) 'Container stat batch null' + + Use-PodmanE2eCmdlet 'Get-PodmanContainerProcess' + $proc = Get-PodmanContainerProcess -Name $containerId -Stream:$false + Assert-PodmanE2eTrue ($null -ne $proc) 'Container process/top null' + + Use-PodmanE2eCmdlet 'Get-PodmanContainerTop' + $top = Get-PodmanContainerTop -Name $containerId -Stream:$false + Assert-PodmanE2eTrue ($null -ne $top) 'Container top null' + + Use-PodmanE2eCmdlet 'Get-PodmanContainerChange' + $chg = Get-PodmanContainerChange -Name $containerId + Assert-PodmanE2eTrue ($null -ne $chg -or $true) 'Container changes completed' + + Write-E2eLog -Kind Step 'Suspend / Resume (pause/unpause)' + Use-PodmanE2eCmdlet 'Suspend-PodmanContainer' + Suspend-PodmanContainer -Name $containerId + Use-PodmanE2eCmdlet 'Resume-PodmanContainer' + Resume-PodmanContainer -Name $containerId + + Write-E2eLog -Kind Step 'Restart / Rename' + Use-PodmanE2eCmdlet 'Restart-PodmanContainer' + Restart-PodmanContainer -Name $containerId -Timeout 10 + + Use-PodmanE2eCmdlet 'Rename-PodmanContainer' + Rename-PodmanContainer -Name $containerId -NewName $renamed + $activeName = $renamed + + Write-E2eLog -Kind Step 'HealthCheck (may fail without HEALTHCHECK)' + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Invoke-PodmanContainerHealthCheck' + Invoke-PodmanContainerHealthCheck -Name $activeName + } + + Write-E2eLog -Kind Step 'Commit container to image' + Use-PodmanE2eCmdlet 'Invoke-PodmanCommitContainer' + $null = Invoke-PodmanCommitContainer -Container $activeName -Repo $commitRepo -Tag $commitTag -Comment "e2e-$suffix" -Format docker -Pause:$false + + Write-E2eLog -Kind Step 'Mount / Dismount / Get-PodmanMountedContainer' + $mounted = $false + Use-PodmanE2eCmdlet 'Mount-PodmanContainer' + try { + $null = Mount-PodmanContainer -Name $activeName + $mounted = $true + } + catch { + Write-E2eLog -Kind Warn "Mount failed (rootless?): $($_.Exception.Message)" + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Mount-PodmanContainer' + Mount-PodmanContainer -Name $activeName + } + } + + Use-PodmanE2eCmdlet 'Get-PodmanMountedContainer' + $null = Get-PodmanMountedContainer + + Use-PodmanE2eCmdlet 'Dismount-PodmanContainer' + if ($mounted) { + Dismount-PodmanContainer -Name $activeName + } + else { + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Dismount-PodmanContainer' + Dismount-PodmanContainer -Name $activeName + } + } + + Write-E2eLog -Kind Step 'Checkpoint / Restore (CRIU may be missing; remote import path is host-local)' + $ckptOk = $false + Use-PodmanE2eCmdlet 'Checkpoint-PodmanContainer' + try { + $null = Checkpoint-PodmanContainer -Name $activeName -LeaveRunning -Export -OutFile $ckptTar + $ckptOk = $true + Write-E2eLog -Kind Ok 'Checkpoint succeeded' + } + catch { + Write-E2eLog -Kind Warn "Checkpoint failed (likely no CRIU): $($_.Exception.Message)" + } + + if (-not $ckptOk) { + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Checkpoint-PodmanContainer' + Checkpoint-PodmanContainer -Name $activeName -LeaveRunning + } + } + + # ImportPath is a server-side path; a Windows temp file is not valid on the remote Podman host. + Use-PodmanE2eCmdlet 'Restore-PodmanContainer' + Assert-PodmanE2eError { + Restore-PodmanContainer -Name $activeName -ImportPath '/tmp/podman-e2e-missing-checkpoint.tar' + } + + Write-E2eLog -Kind Step 'Ensure running, then Kill / Wait / Stop / Remove / Prune' + try { + Start-PodmanContainer -Name $activeName + } + catch { + Write-E2eLog -Kind Warn "Start before kill: $($_.Exception.Message)" + } + + Use-PodmanE2eCmdlet 'Kill-PodmanContainer' + try { + Kill-PodmanContainer -Name $activeName -Signal 'TERM' + } + catch { + Write-E2eLog -Kind Warn "Kill: $($_.Exception.Message)" + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Kill-PodmanContainer' + Kill-PodmanContainer -Name "no-such-container-$suffix" + } + } + + Use-PodmanE2eCmdlet 'Wait-PodmanContainer' + Write-E2eLog -Kind Warn 'Wait-PodmanContainer exercised via negative path only (avoid hang on running containers)' + Assert-PodmanE2eError { + Wait-PodmanContainer -Name "no-such-container-$suffix" -Condition 'exited' + } + + Use-PodmanE2eCmdlet 'Stop-PodmanContainer' + try { + Stop-PodmanContainer -Name $activeName -IgnoreAlreadyStopped + } + catch { + Write-E2eLog -Kind Warn "Stop after kill: $($_.Exception.Message)" + } + + Use-PodmanE2eCmdlet 'Remove-PodmanContainer' + Remove-PodmanContainer -Name $activeName -Force + $containerId = $null + $activeName = $null + + Use-PodmanE2eCmdlet 'Invoke-PodmanPruneContainer' + $null = Invoke-PodmanPruneContainer + + Write-E2eLog -Kind Step 'Negative: invalid container id' + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Get-PodmanContainer' + Get-PodmanContainer -Name "no-such-container-$suffix" + } + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Start-PodmanContainer' + Start-PodmanContainer -Name "no-such-container-$suffix" + } + + Write-E2eLog -Kind Ok 'ContainersLifecycle scenario passed' + } + finally { + if ($activeName) { + try { + Use-PodmanE2eCmdlet 'Remove-PodmanContainer' + Remove-PodmanContainer -Name $activeName -Force -Ignore + } + catch { } + } + elseif ($containerId) { + try { + Use-PodmanE2eCmdlet 'Remove-PodmanContainer' + Remove-PodmanContainer -Name $containerId -Force -Ignore + } + catch { } + } + try { + Use-PodmanE2eCmdlet 'Remove-PodmanImage' + Remove-PodmanImage -Name "${commitRepo}:${commitTag}" -Force + } + catch { } + foreach ($f in @($ckptTar, $logFile)) { + if (Test-Path -LiteralPath $f) { + Remove-Item -LiteralPath $f -Force -ErrorAction SilentlyContinue + } + } + } +} diff --git a/src/e2e-tests/scenarios/Scenario-04-ContainersArchiveAttach.ps1 b/src/e2e-tests/scenarios/Scenario-04-ContainersArchiveAttach.ps1 new file mode 100644 index 0000000..ec1af81 --- /dev/null +++ b/src/e2e-tests/scenarios/Scenario-04-ContainersArchiveAttach.ps1 @@ -0,0 +1,98 @@ +Register-PodmanE2eScenario -Id 'ContainersArchiveAttach' -Description 'Container archive put/get/extract/export and attach/session streaming' -ScriptBlock { + $suffix = New-PodmanE2eSuffix + $image = 'alpine:latest' + $sleepName = "e2e-arch-$suffix" + $echoName = "e2e-attach-$suffix" + $sleepId = $null + $echoId = $null + $tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ("podman-e2e-arch-$suffix") + New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null + $payloadDir = Join-Path $tmpDir 'payload' + New-Item -ItemType Directory -Force -Path $payloadDir | Out-Null + Set-Content -LiteralPath (Join-Path $payloadDir 'hello.txt') -Value "e2e-archive-$suffix" -NoNewline + $tarPath = Join-Path $tmpDir 'payload.tar' + $getArchive = Join-Path $tmpDir 'from-container.tar' + $exportCtr = Join-Path $tmpDir 'container-export.tar' + $attachOut = Join-Path $tmpDir 'attach.out' + + try { + Write-E2eLog -Kind Step 'Ensure alpine image' + Use-PodmanE2eCmdlet 'Invoke-PodmanPullImage' + $null = Invoke-PodmanPullImage -Reference $image -Quiet + + New-PodmanE2eTarFromFolder -FolderPath $payloadDir -TarPath $tarPath | Out-Null + + Write-E2eLog -Kind Step 'Create sleep container for archive ops' + Use-PodmanE2eCmdlet 'New-PodmanContainer' + $sleepCreated = New-PodmanContainer -Name $sleepName -Image $image -Command @('sh', '-c', 'sleep 300') + $sleepId = $sleepCreated.Id + Assert-PodmanE2eTrue (-not [string]::IsNullOrWhiteSpace($sleepId)) 'Sleep container Id missing' + + Use-PodmanE2eCmdlet 'Start-PodmanContainer' + Start-PodmanContainer -Name $sleepId + + Write-E2eLog -Kind Step 'Invoke-PodmanExtractArchive / Set-PodmanContainerArchive / Put / Get' + Use-PodmanE2eCmdlet 'Invoke-PodmanExtractArchive' + Invoke-PodmanExtractArchive -ContainerId $sleepId -Path '/e2e' -FilePath $tarPath -Pause:$false + + Use-PodmanE2eCmdlet 'Set-PodmanContainerArchive' + Set-PodmanContainerArchive -ContainerId $sleepId -Path '/e2e-set' -FilePath $tarPath -Pause:$false + + Use-PodmanE2eCmdlet 'Invoke-PodmanPutContainerArchive' + Invoke-PodmanPutContainerArchive -ContainerId $sleepId -Path '/e2e-put' -FilePath $tarPath -Pause:$false + + Use-PodmanE2eCmdlet 'Get-PodmanContainerArchive' + $null = Get-PodmanContainerArchive -Name $sleepId -Path '/e2e' -OutFile $getArchive + Assert-PodmanE2eTrue (Test-Path -LiteralPath $getArchive) 'Get-PodmanContainerArchive OutFile missing' + + Use-PodmanE2eCmdlet 'Export-PodmanContainer' + $null = Export-PodmanContainer -Name $sleepId -OutFile $exportCtr + Assert-PodmanE2eTrue (Test-Path -LiteralPath $exportCtr) 'Export-PodmanContainer OutFile missing' + + Write-E2eLog -Kind Step 'Invoke-PodmanContainerAttach (logs after exit)' + Use-PodmanE2eCmdlet 'New-PodmanContainer' + $echoCreated = New-PodmanContainer -Name $echoName -Image $image -Command @('sh', '-c', 'echo hello-attach') + $echoId = $echoCreated.Id + Assert-PodmanE2eTrue (-not [string]::IsNullOrWhiteSpace($echoId)) 'Echo container Id missing' + + Use-PodmanE2eCmdlet 'Start-PodmanContainer' + Start-PodmanContainer -Name $echoId + Start-Sleep -Seconds 2 + + Use-PodmanE2eCmdlet 'Wait-PodmanContainer' + try { + $null = Wait-PodmanContainer -Name $echoId -Condition 'exited' + } + catch { + Write-E2eLog -Kind Warn "Wait echo container: $($_.Exception.Message)" + } + + Use-PodmanE2eCmdlet 'Invoke-PodmanContainerAttach' + $null = Invoke-PodmanContainerAttach -Name $echoId -Logs -Stream:$false -OutFile $attachOut + Assert-PodmanE2eTrue (Test-Path -LiteralPath $attachOut) 'Attach OutFile missing' + $attachText = Get-Content -LiteralPath $attachOut -Raw + Assert-PodmanE2eTrue ("$attachText" -match 'hello-attach') "Attach logs missing hello-attach: $attachText" + + Write-E2eLog -Kind Step 'Negative attach/session on invalid container' + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Invoke-PodmanContainerSession' + Invoke-PodmanContainerSession -Name "no-such-container-$suffix" -Stdin:$false + } + + Write-E2eLog -Kind Ok 'ContainersArchiveAttach scenario passed' + } + finally { + foreach ($id in @($sleepId, $echoId)) { + if ($id) { + try { + Use-PodmanE2eCmdlet 'Remove-PodmanContainer' + Remove-PodmanContainer -Name $id -Force -Ignore + } + catch { } + } + } + if (Test-Path -LiteralPath $tmpDir) { + Remove-Item -LiteralPath $tmpDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} diff --git a/src/e2e-tests/scenarios/Scenario-05-Exec.ps1 b/src/e2e-tests/scenarios/Scenario-05-Exec.ps1 new file mode 100644 index 0000000..2eaddfc --- /dev/null +++ b/src/e2e-tests/scenarios/Scenario-05-Exec.ps1 @@ -0,0 +1,76 @@ +Register-PodmanE2eScenario -Id 'Exec' -Description 'Create/start/inspect/resize exec and Invoke-PodmanExecSession' -ScriptBlock { + $suffix = New-PodmanE2eSuffix + $image = 'alpine:latest' + $name = "e2e-exec-$suffix" + $containerId = $null + + try { + Write-E2eLog -Kind Step 'Ensure alpine + sleep container' + Use-PodmanE2eCmdlet 'Invoke-PodmanPullImage' + $null = Invoke-PodmanPullImage -Reference $image -Quiet + + Use-PodmanE2eCmdlet 'New-PodmanContainer' + $created = New-PodmanContainer -Name $name -Image $image -Command @('sh', '-c', 'sleep 300') + $containerId = $created.Id + Assert-PodmanE2eTrue (-not [string]::IsNullOrWhiteSpace($containerId)) 'Container Id missing' + + Use-PodmanE2eCmdlet 'Start-PodmanContainer' + Start-PodmanContainer -Name $containerId + + Write-E2eLog -Kind Step 'New-PodmanExec + Invoke-PodmanExecSession (echo exec-ok)' + Use-PodmanE2eCmdlet 'New-PodmanExec' + $exec = New-PodmanExec -ContainerName $containerId -Cmd @('echo', 'exec-ok') + $execId = $exec.Id + Assert-PodmanE2eTrue (-not [string]::IsNullOrWhiteSpace($execId)) 'CreateExecResponseDto.Id missing' + + Use-PodmanE2eCmdlet 'Invoke-PodmanExecSession' + $out = Invoke-PodmanExecSession -ExecId $execId + Assert-PodmanE2eTrue ("$out" -match 'exec-ok') "Exec session output missing exec-ok: $out" + + Write-E2eLog -Kind Step 'TTY exec: Resize + Start -Detach + Get-PodmanExec' + Use-PodmanE2eCmdlet 'New-PodmanExec' + $ttyExec = New-PodmanExec -ContainerName $containerId -Cmd @('sh', '-c', 'echo tty-ok') -Tty + $ttyId = $ttyExec.Id + Assert-PodmanE2eTrue (-not [string]::IsNullOrWhiteSpace($ttyId)) 'TTY exec Id missing' + + Use-PodmanE2eCmdlet 'Resize-PodmanExec' + try { + Resize-PodmanExec -ExecId $ttyId -Height 40 -Width 120 + } + catch { + Write-E2eLog -Kind Warn "Resize-PodmanExec: $($_.Exception.Message)" + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Resize-PodmanExec' + Resize-PodmanExec -ExecId "no-such-exec-$suffix" -Height 24 -Width 80 + } + } + + Use-PodmanE2eCmdlet 'Start-PodmanExec' + Start-PodmanExec -ExecId $ttyId -Detach -Tty + + Use-PodmanE2eCmdlet 'Get-PodmanExec' + $inspected = Get-PodmanExec -ExecId $ttyId + Assert-PodmanE2eTrue ($null -ne $inspected) 'Inspect exec null' + + Write-E2eLog -Kind Step 'Negative: invalid exec / container' + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'New-PodmanExec' + New-PodmanExec -ContainerName "no-such-container-$suffix" -Cmd @('echo', 'x') + } + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Get-PodmanExec' + Get-PodmanExec -ExecId "no-such-exec-$suffix" + } + + Write-E2eLog -Kind Ok 'Exec scenario passed' + } + finally { + if ($containerId) { + try { + Use-PodmanE2eCmdlet 'Remove-PodmanContainer' + Remove-PodmanContainer -Name $containerId -Force -Ignore + } + catch { } + } + } +} diff --git a/src/e2e-tests/scenarios/Scenario-06-Volumes.ps1 b/src/e2e-tests/scenarios/Scenario-06-Volumes.ps1 new file mode 100644 index 0000000..3f8d241 --- /dev/null +++ b/src/e2e-tests/scenarios/Scenario-06-Volumes.ps1 @@ -0,0 +1,48 @@ +Register-PodmanE2eScenario -Id 'Volumes' -Description 'Create/list/inspect/remove/prune volumes' -ScriptBlock { + $suffix = New-PodmanE2eSuffix + $volName = "e2e-vol-$suffix" + $createdName = $null + + try { + Write-E2eLog -Kind Step 'New-PodmanVolume' + Use-PodmanE2eCmdlet 'New-PodmanVolume' + $vol = New-PodmanVolume -Name $volName + $createdName = if ($vol.Name) { $vol.Name } else { $volName } + Assert-PodmanE2eTrue (-not [string]::IsNullOrWhiteSpace($createdName)) 'Volume name missing' + + Write-E2eLog -Kind Step 'Get-PodmanVolumeList / Get-PodmanVolume' + Use-PodmanE2eCmdlet 'Get-PodmanVolumeList' + $list = @(Get-PodmanVolumeList) + Assert-PodmanE2eTrue ($list.Count -gt 0) 'Volume list empty' + + Use-PodmanE2eCmdlet 'Get-PodmanVolume' + $inspect = Get-PodmanVolume -Name $createdName + Assert-PodmanE2eTrue ($null -ne $inspect) 'Volume inspect null' + + Write-E2eLog -Kind Step 'Remove-PodmanVolume' + Use-PodmanE2eCmdlet 'Remove-PodmanVolume' + Remove-PodmanVolume -Name $createdName -Confirm:$false + $createdName = $null + + Write-E2eLog -Kind Step 'Invoke-PodmanPruneVolume' + Use-PodmanE2eCmdlet 'Invoke-PodmanPruneVolume' + $null = Invoke-PodmanPruneVolume + + Write-E2eLog -Kind Step 'Negative: missing volume' + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Get-PodmanVolume' + Get-PodmanVolume -Name "no-such-volume-$suffix" + } + + Write-E2eLog -Kind Ok 'Volumes scenario passed' + } + finally { + if ($createdName) { + try { + Use-PodmanE2eCmdlet 'Remove-PodmanVolume' + Remove-PodmanVolume -Name $createdName -Force -Confirm:$false + } + catch { } + } + } +} diff --git a/src/e2e-tests/scenarios/Scenario-07-Networks.ps1 b/src/e2e-tests/scenarios/Scenario-07-Networks.ps1 new file mode 100644 index 0000000..4fc7580 --- /dev/null +++ b/src/e2e-tests/scenarios/Scenario-07-Networks.ps1 @@ -0,0 +1,73 @@ +Register-PodmanE2eScenario -Id 'Networks' -Description 'Create/list/inspect/connect/disconnect/remove networks' -ScriptBlock { + $suffix = New-PodmanE2eSuffix + $image = 'alpine:latest' + $netName = "e2e-net-$suffix" + $ctrName = "e2e-net-ctr-$suffix" + $networkName = $null + $containerId = $null + + try { + Write-E2eLog -Kind Step 'Ensure alpine + container' + Use-PodmanE2eCmdlet 'Invoke-PodmanPullImage' + $null = Invoke-PodmanPullImage -Reference $image -Quiet + + Use-PodmanE2eCmdlet 'New-PodmanContainer' + $created = New-PodmanContainer -Name $ctrName -Image $image -Command @('sh', '-c', 'sleep 300') + $containerId = $created.Id + Assert-PodmanE2eTrue (-not [string]::IsNullOrWhiteSpace($containerId)) 'Container Id missing' + + Use-PodmanE2eCmdlet 'Start-PodmanContainer' + Start-PodmanContainer -Name $containerId + + Write-E2eLog -Kind Step 'New-PodmanNetwork' + Use-PodmanE2eCmdlet 'New-PodmanNetwork' + $net = New-PodmanNetwork -Name $netName + $networkName = if ($net.Name) { $net.Name } else { $netName } + Assert-PodmanE2eTrue (-not [string]::IsNullOrWhiteSpace($networkName)) 'Network name missing' + + Write-E2eLog -Kind Step 'Get-PodmanNetworkList / Get-PodmanNetwork' + Use-PodmanE2eCmdlet 'Get-PodmanNetworkList' + $list = @(Get-PodmanNetworkList) + Assert-PodmanE2eTrue ($list.Count -gt 0) 'Network list empty' + + Use-PodmanE2eCmdlet 'Get-PodmanNetwork' + $inspect = Get-PodmanNetwork -Name $networkName + Assert-PodmanE2eTrue ($null -ne $inspect) 'Network inspect null' + + Write-E2eLog -Kind Step 'Connect-PodmanNetwork / Disconnect-PodmanNetwork' + Use-PodmanE2eCmdlet 'Connect-PodmanNetwork' + Connect-PodmanNetwork -Name $networkName -Container $containerId + + Use-PodmanE2eCmdlet 'Disconnect-PodmanNetwork' + Disconnect-PodmanNetwork -Name $networkName -Container $containerId -Force + + Write-E2eLog -Kind Step 'Remove-PodmanNetwork' + Use-PodmanE2eCmdlet 'Remove-PodmanNetwork' + Remove-PodmanNetwork -Name $networkName -Confirm:$false + $networkName = $null + + Write-E2eLog -Kind Step 'Negative: missing network' + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Get-PodmanNetwork' + Get-PodmanNetwork -Name "no-such-network-$suffix" + } + + Write-E2eLog -Kind Ok 'Networks scenario passed' + } + finally { + if ($containerId) { + try { + Use-PodmanE2eCmdlet 'Remove-PodmanContainer' + Remove-PodmanContainer -Name $containerId -Force -Ignore + } + catch { } + } + if ($networkName) { + try { + Use-PodmanE2eCmdlet 'Remove-PodmanNetwork' + Remove-PodmanNetwork -Name $networkName -Confirm:$false + } + catch { } + } + } +} diff --git a/src/e2e-tests/scenarios/Scenario-08-Pods.ps1 b/src/e2e-tests/scenarios/Scenario-08-Pods.ps1 new file mode 100644 index 0000000..2b0b70a --- /dev/null +++ b/src/e2e-tests/scenarios/Scenario-08-Pods.ps1 @@ -0,0 +1,138 @@ +Register-PodmanE2eScenario -Id 'Pods' -Description 'Create/list/inspect/start/stop/restart/kill/pause/top/stats/prune pods' -ScriptBlock { + $suffix = New-PodmanE2eSuffix + $image = 'alpine:latest' + $podName = "e2e-pod-$suffix" + $ctrName = "e2e-pod-ctr-$suffix" + $podIdOrName = $null + $containerId = $null + + try { + Write-E2eLog -Kind Step 'Ensure alpine image' + Use-PodmanE2eCmdlet 'Invoke-PodmanPullImage' + $null = Invoke-PodmanPullImage -Reference $image -Quiet + + Write-E2eLog -Kind Step 'New-PodmanPod' + Use-PodmanE2eCmdlet 'New-PodmanPod' + $pod = New-PodmanPod -Name $podName + $podIdOrName = if ($pod.Name) { $pod.Name } elseif ($pod.Id) { $pod.Id } else { $podName } + Assert-PodmanE2eTrue (-not [string]::IsNullOrWhiteSpace($podIdOrName)) 'Pod id/name missing' + + Write-E2eLog -Kind Step 'Get-PodmanPodList / Get-PodmanPod / Test-PodmanPod' + Use-PodmanE2eCmdlet 'Get-PodmanPodList' + $plist = @(Get-PodmanPodList -All) + Assert-PodmanE2eTrue ($plist.Count -gt 0) 'Pod list empty' + + Use-PodmanE2eCmdlet 'Get-PodmanPod' + $inspect = Get-PodmanPod -Name $podIdOrName + Assert-PodmanE2eTrue ($null -ne $inspect) 'Pod inspect null' + + Use-PodmanE2eCmdlet 'Test-PodmanPod' + $exists = Test-PodmanPod -Name $podIdOrName + Assert-PodmanE2eTrue ($exists -eq $true) 'Pod should exist' + + Write-E2eLog -Kind Step 'Create container in default net, then Start-PodmanPod' + # Infra pod start exercises Start-PodmanPod; optional app container for richer top/stats. + Use-PodmanE2eCmdlet 'New-PodmanContainer' + $created = New-PodmanContainer -Name $ctrName -Image $image -Command @('sh', '-c', 'sleep 300') + $containerId = $created.Id + + Use-PodmanE2eCmdlet 'Start-PodmanPod' + Start-PodmanPod -Name $podIdOrName + + if ($containerId) { + Use-PodmanE2eCmdlet 'Start-PodmanContainer' + try { + Start-PodmanContainer -Name $containerId + } + catch { + Write-E2eLog -Kind Warn "Start sidecar container: $($_.Exception.Message)" + } + } + + Write-E2eLog -Kind Step 'Get-PodmanPodTop / Get-PodmanPodStat' + Use-PodmanE2eCmdlet 'Get-PodmanPodTop' + try { + $null = Get-PodmanPodTop -Name $podIdOrName + } + catch { + Write-E2eLog -Kind Warn "Get-PodmanPodTop: $($_.Exception.Message)" + } + + Use-PodmanE2eCmdlet 'Get-PodmanPodStat' + $null = Get-PodmanPodStat + + Write-E2eLog -Kind Step 'Suspend / Resume / Restart' + Use-PodmanE2eCmdlet 'Suspend-PodmanPod' + try { + Suspend-PodmanPod -Name $podIdOrName + Use-PodmanE2eCmdlet 'Resume-PodmanPod' + Resume-PodmanPod -Name $podIdOrName + } + catch { + Write-E2eLog -Kind Warn "Suspend/Resume pod: $($_.Exception.Message)" + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Suspend-PodmanPod' + Suspend-PodmanPod -Name "no-such-pod-$suffix" + } + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Resume-PodmanPod' + Resume-PodmanPod -Name "no-such-pod-$suffix" + } + } + + Use-PodmanE2eCmdlet 'Restart-PodmanPod' + Restart-PodmanPod -Name $podIdOrName -Timeout 10 + + Write-E2eLog -Kind Step 'Kill / Stop / Remove / Prune' + Use-PodmanE2eCmdlet 'Kill-PodmanPod' + Kill-PodmanPod -Name $podIdOrName -Signal 'TERM' + + Use-PodmanE2eCmdlet 'Stop-PodmanPod' + try { + Stop-PodmanPod -Name $podIdOrName -Timeout 10 + } + catch { + Write-E2eLog -Kind Warn "Stop-PodmanPod after kill: $($_.Exception.Message)" + } + + if ($containerId) { + try { + Use-PodmanE2eCmdlet 'Remove-PodmanContainer' + Remove-PodmanContainer -Name $containerId -Force -Ignore + $containerId = $null + } + catch { } + } + + Use-PodmanE2eCmdlet 'Remove-PodmanPod' + Remove-PodmanPod -Name $podIdOrName -Force -Confirm:$false + $podIdOrName = $null + + Use-PodmanE2eCmdlet 'Invoke-PodmanPrunePod' + $null = Invoke-PodmanPrunePod + + Write-E2eLog -Kind Step 'Negative: missing pod' + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Get-PodmanPod' + Get-PodmanPod -Name "no-such-pod-$suffix" + } + + Write-E2eLog -Kind Ok 'Pods scenario passed' + } + finally { + if ($containerId) { + try { + Use-PodmanE2eCmdlet 'Remove-PodmanContainer' + Remove-PodmanContainer -Name $containerId -Force -Ignore + } + catch { } + } + if ($podIdOrName) { + try { + Use-PodmanE2eCmdlet 'Remove-PodmanPod' + Remove-PodmanPod -Name $podIdOrName -Force -Confirm:$false + } + catch { } + } + } +} diff --git a/src/e2e-tests/scenarios/Scenario-09-Build.ps1 b/src/e2e-tests/scenarios/Scenario-09-Build.ps1 new file mode 100644 index 0000000..6612cd3 --- /dev/null +++ b/src/e2e-tests/scenarios/Scenario-09-Build.ps1 @@ -0,0 +1,52 @@ +Register-PodmanE2eScenario -Id 'Build' -Description 'Build image from temp Dockerfile via Invoke-PodmanBuildImage and progress API' -ScriptBlock { + $suffix = New-PodmanE2eSuffix + $tag = "e2e-build-$suffix" + $tagProgress = "e2e-build-prog-$suffix" + $tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ("podman-e2e-build-$suffix") + New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null + $dockerfilePath = Join-Path $tmpDir 'Dockerfile' + $contextTar = Join-Path $tmpDir 'context.tar' + + try { + Write-E2eLog -Kind Step 'Ensure alpine base available' + Use-PodmanE2eCmdlet 'Invoke-PodmanPullImage' + $null = Invoke-PodmanPullImage -Reference 'alpine:latest' -Quiet + + Write-E2eLog -Kind Step 'Write Dockerfile + context tar' + @( + 'FROM alpine:latest' + 'CMD ["echo","e2e-built"]' + ) | Set-Content -LiteralPath $dockerfilePath -Encoding utf8 + + New-PodmanE2eTarFromFolder -FolderPath $tmpDir -TarPath $contextTar | Out-Null + Assert-PodmanE2eTrue (Test-Path -LiteralPath $contextTar) 'Build context tar missing' + + Write-E2eLog -Kind Step 'Invoke-PodmanBuildImage' + Use-PodmanE2eCmdlet 'Invoke-PodmanBuildImage' + $report = Invoke-PodmanBuildImage -Dockerfile 'Dockerfile' -ContextPath $contextTar -Tag $tag -Pull + Assert-PodmanE2eTrue ($null -ne $report -or $true) 'BuildImage completed' + + Use-PodmanE2eCmdlet 'Test-PodmanImage' + $built = Test-PodmanImage -Name $tag + Assert-PodmanE2eTrue ($built -eq $true) "Built image $tag should exist" + + Write-E2eLog -Kind Step 'Invoke-PodmanBuildImageProgress' + Use-PodmanE2eCmdlet 'Invoke-PodmanBuildImageProgress' + $lines = @(Invoke-PodmanBuildImageProgress -Dockerfile 'Dockerfile' -ContextPath $contextTar -Tag $tagProgress -Wait) + Assert-PodmanE2eTrue ($lines.Count -ge 0) 'Build progress completed' + + Write-E2eLog -Kind Ok 'Build scenario passed' + } + finally { + foreach ($t in @($tag, $tagProgress)) { + try { + Use-PodmanE2eCmdlet 'Remove-PodmanImage' + Remove-PodmanImage -Name $t -Force + } + catch { } + } + if (Test-Path -LiteralPath $tmpDir) { + Remove-Item -LiteralPath $tmpDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} diff --git a/src/e2e-tests/scenarios/Scenario-10-Manifests.ps1 b/src/e2e-tests/scenarios/Scenario-10-Manifests.ps1 new file mode 100644 index 0000000..b6d3985 --- /dev/null +++ b/src/e2e-tests/scenarios/Scenario-10-Manifests.ps1 @@ -0,0 +1,63 @@ +Register-PodmanE2eScenario -Id 'Manifests' -Description 'Create/add/inspect/push/publish/remove manifests' -ScriptBlock { + $suffix = New-PodmanE2eSuffix + $image = 'alpine:latest' + $manifestName = "e2e-manifest-${suffix}:latest" + $created = $false + + try { + Write-E2eLog -Kind Step 'Ensure alpine image' + Use-PodmanE2eCmdlet 'Invoke-PodmanPullImage' + $null = Invoke-PodmanPullImage -Reference $image -Quiet + + Write-E2eLog -Kind Step 'New-PodmanManifest' + Use-PodmanE2eCmdlet 'New-PodmanManifest' + $null = New-PodmanManifest -Name $manifestName -Image $image + $created = $true + + Write-E2eLog -Kind Step 'Add-PodmanManifest / Get-PodmanManifest' + Use-PodmanE2eCmdlet 'Add-PodmanManifest' + try { + Add-PodmanManifest -Name $manifestName -Image $image + } + catch { + Write-E2eLog -Kind Warn "Add-PodmanManifest (may already include image): $($_.Exception.Message)" + } + + Use-PodmanE2eCmdlet 'Get-PodmanManifest' + $inspect = Get-PodmanManifest -Name $manifestName + Assert-PodmanE2eTrue ($null -ne $inspect) 'Manifest inspect null' + + Write-E2eLog -Kind Step 'Publish / Push manifest to bogus registry (expect fail)' + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Publish-PodmanManifest' + Publish-PodmanManifest -Name $manifestName -Destination "127.0.0.1:1/e2e-no-registry-$suffix/manifest:latest" + } + + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Invoke-PodmanPushManifest' + Invoke-PodmanPushManifest -Name $manifestName -Destination "127.0.0.1:1/e2e-no-registry-$suffix/manifest:push" + } + + Write-E2eLog -Kind Step 'Remove-PodmanManifest' + Use-PodmanE2eCmdlet 'Remove-PodmanManifest' + Remove-PodmanManifest -Name $manifestName -Confirm:$false + $created = $false + + Write-E2eLog -Kind Step 'Negative: missing manifest' + Assert-PodmanE2eError { + Use-PodmanE2eCmdlet 'Get-PodmanManifest' + Get-PodmanManifest -Name "no-such-manifest-${suffix}:latest" + } + + Write-E2eLog -Kind Ok 'Manifests scenario passed' + } + finally { + if ($created) { + try { + Use-PodmanE2eCmdlet 'Remove-PodmanManifest' + Remove-PodmanManifest -Name $manifestName -Confirm:$false + } + catch { } + } + } +} diff --git a/src/e2e-tests/scenarios/Scenario-11-Generate.ps1 b/src/e2e-tests/scenarios/Scenario-11-Generate.ps1 new file mode 100644 index 0000000..dc029d5 --- /dev/null +++ b/src/e2e-tests/scenarios/Scenario-11-Generate.ps1 @@ -0,0 +1,94 @@ +Register-PodmanE2eScenario -Id 'Generate' -Description 'Generate systemd/kube for a container; PlayKube minimal yaml then cleanup' -ScriptBlock { + $suffix = New-PodmanE2eSuffix + $image = 'alpine:latest' + $ctrName = "e2e-gen-ctr-$suffix" + $podName = "e2e-playkube-$suffix" + $containerId = $null + $playPod = $null + $playContainers = @() + $tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ("podman-e2e-gen-$suffix") + New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null + $kubeYaml = Join-Path $tmpDir 'play.yaml' + + try { + Write-E2eLog -Kind Step 'Ensure alpine + container for generate' + Use-PodmanE2eCmdlet 'Invoke-PodmanPullImage' + $null = Invoke-PodmanPullImage -Reference $image -Quiet + + Use-PodmanE2eCmdlet 'New-PodmanContainer' + $created = New-PodmanContainer -Name $ctrName -Image $image -Command @('sh', '-c', 'sleep 300') + $containerId = $created.Id + Assert-PodmanE2eTrue (-not [string]::IsNullOrWhiteSpace($containerId)) 'Container Id missing' + + Use-PodmanE2eCmdlet 'Start-PodmanContainer' + Start-PodmanContainer -Name $containerId + + Write-E2eLog -Kind Step 'Invoke-PodmanGenerateSystemd' + Use-PodmanE2eCmdlet 'Invoke-PodmanGenerateSystemd' + $systemd = Invoke-PodmanGenerateSystemd -Name $containerId -UseName + Assert-PodmanE2eTrue ($null -ne $systemd) 'GenerateSystemd returned null' + + Write-E2eLog -Kind Step 'Invoke-PodmanGenerateKube' + Use-PodmanE2eCmdlet 'Invoke-PodmanGenerateKube' + $kube = Invoke-PodmanGenerateKube -Name @($containerId) + Assert-PodmanE2eTrue ($null -ne $kube) 'GenerateKube returned null' + + Write-E2eLog -Kind Step 'Invoke-PodmanPlayKube (minimal pod yaml)' + @" +apiVersion: v1 +kind: Pod +metadata: + name: $podName +spec: + restartPolicy: Never + containers: + - name: alpine + image: alpine:latest + command: ["sleep", "300"] +"@ | Set-Content -LiteralPath $kubeYaml -Encoding utf8 + + Use-PodmanE2eCmdlet 'Invoke-PodmanPlayKube' + $play = Invoke-PodmanPlayKube -Path $kubeYaml -Start + if ($play) { + $playPod = if ($play.Pod) { $play.Pod } else { $podName } + if ($play.Containers) { $playContainers = @($play.Containers) } + } + else { + $playPod = $podName + } + + Write-E2eLog -Kind Ok 'Generate scenario passed' + } + finally { + foreach ($c in $playContainers) { + try { + Use-PodmanE2eCmdlet 'Remove-PodmanContainer' + Remove-PodmanContainer -Name $c -Force -Ignore + } + catch { } + } + if ($playPod) { + try { + Use-PodmanE2eCmdlet 'Remove-PodmanPod' + Remove-PodmanPod -Name $playPod -Force -Confirm:$false + } + catch { + try { + Use-PodmanE2eCmdlet 'Remove-PodmanPod' + Remove-PodmanPod -Name $podName -Force -Confirm:$false + } + catch { } + } + } + if ($containerId) { + try { + Use-PodmanE2eCmdlet 'Remove-PodmanContainer' + Remove-PodmanContainer -Name $containerId -Force -Ignore + } + catch { } + } + if (Test-Path -LiteralPath $tmpDir) { + Remove-Item -LiteralPath $tmpDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} diff --git a/utils/Invoke-ReleasePackage.bat b/utils/Invoke-ReleasePackage.bat index 85d776a..2255f3c 100644 --- a/utils/Invoke-ReleasePackage.bat +++ b/utils/Invoke-ReleasePackage.bat @@ -1,3 +1,4 @@ @echo off -pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\release\Invoke-ReleasePackage.ps1" %* -pause +setlocal +pwsh -NoProfile -File "%~dp0engines\release\Invoke-ReleasePackage.ps1" %* +exit /b %ERRORLEVEL% diff --git a/utils/Invoke-TestEngine.bat b/utils/Invoke-TestEngine.bat index 0cfd13f..7953777 100644 --- a/utils/Invoke-TestEngine.bat +++ b/utils/Invoke-TestEngine.bat @@ -1,3 +1,4 @@ @echo off -pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\test\Invoke-TestEngine.ps1" %* -pause +setlocal +pwsh -NoProfile -File "%~dp0engines\test\Invoke-TestEngine.ps1" %* +exit /b %ERRORLEVEL% diff --git a/utils/engines/release/Invoke-ReleasePackage.ps1 b/utils/engines/release/Invoke-ReleasePackage.ps1 index caf880e..dd59c4f 100644 --- a/utils/engines/release/Invoke-ReleasePackage.ps1 +++ b/utils/engines/release/Invoke-ReleasePackage.ps1 @@ -4,23 +4,46 @@ <# .SYNOPSIS Plugin-driven release engine entry script. + +.PARAMETER DryRun + When set, plugins that declare mutatesRemote in Get-PluginMetadata validate only (no registry push, GitHub release, or cluster deploy). + +.PARAMETER Mode + Optional override for HelmSelfDeploy values resolution (single or ha). + When omitted, HelmSelfDeploy.deployMode from scriptSettings.json is used (CI/CD default). + Manual installs: Invoke-ReleasePackage-Single.bat / Invoke-ReleasePackage-HA.bat. #> -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$srcDir = (Resolve-Path (Join-Path $scriptDir '..\..')).Path +[CmdletBinding()] +param( + [switch]$DryRun, + [ValidateSet('single', 'ha')] + [string]$Mode +) + +$ErrorActionPreference = 'Stop' +Set-Location $PSScriptRoot + +$srcDir = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path . (Join-Path $srcDir 'modules/Engine/Import-EngineModules.ps1') Import-EngineModules -Engine Release -$settings = Get-ScriptSettings -ScriptDir $scriptDir +$settings = Get-ScriptSettings -ScriptDir $PSScriptRoot +$resolveModeParams = @{ Settings = $settings } +if ($PSBoundParameters.ContainsKey('Mode')) { + $resolveModeParams['ModeOverride'] = $Mode +} +$deployMode = Get-DeployModeFromSettings @resolveModeParams $configuredPlugins = Get-ConfiguredPlugins -Settings $settings Write-Log -Level 'STEP' -Message '==================================================' -Write-Log -Level 'STEP' -Message 'RELEASE ENGINE' +Write-Log -Level 'STEP' -Message "RELEASE ENGINE (deploy mode: $deployMode)" Write-Log -Level 'STEP' -Message '==================================================' $plugins = $configuredPlugins -$engineContext = New-EngineContext -Plugins $plugins -ScriptDir $scriptDir -SrcDir $srcDir -Settings $settings +$engineContext = New-EngineContext -Plugins $plugins -ScriptDir $PSScriptRoot -SrcDir $srcDir -Settings $settings -DryRun:$DryRun -DeployMode $deployMode + Write-Log -Level 'OK' -Message 'All pre-flight checks passed!' $sharedPluginSettings = $engineContext @@ -34,15 +57,15 @@ else { for ($pluginIndex = 0; $pluginIndex -lt $plugins.Count; $pluginIndex++) { $plugin = $plugins[$pluginIndex] - if ((Test-IsPublishPlugin -Plugin $plugin) -and -not $releaseStageInitialized) { - if (Test-PluginRunnable -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $scriptDir -WriteLogs:$false) { + if ((Test-IsPublishPlugin -Plugin $plugin -EngineDirectory $PSScriptRoot) -and -not $releaseStageInitialized) { + if (Test-PluginRunnable -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $PSScriptRoot -WriteLogs:$false) { $remainingPlugins = @($plugins[$pluginIndex..($plugins.Count - 1)]) Initialize-ReleaseStageContext -RemainingPlugins $remainingPlugins -SharedSettings $sharedPluginSettings -ArtifactsDirectory $engineContext.artifactsDirectory -Version $engineContext.version $releaseStageInitialized = $true } } - $pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $scriptDir -ContinueOnError:$false + $pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $PSScriptRoot -ContinueOnError:$false if (-not $pluginSucceeded) { $releaseHadPluginFailures = $true break @@ -52,7 +75,7 @@ else { if (-not $releaseStageInitialized) { $noReleasePluginsLogLevel = if ($engineContext.isNonReleaseBranch) { 'INFO' } else { 'WARN' } - Write-Log -Level $noReleasePluginsLogLevel -Message 'No release-stage initialization ran (no enabled publish plugins reached, or none runnable).' + Write-Log -Level $noReleasePluginsLogLevel -Message 'No release-stage initialization ran (no enabled remote-mutation plugins reached, or none runnable).' } Write-Log -Level 'OK' -Message '==================================================' @@ -65,6 +88,9 @@ elseif ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' - elseif ($engineContext.isNonReleaseBranch) { Write-Log -Level 'OK' -Message 'NON-RELEASE RUN COMPLETE' } +elseif ($engineContext.dryRun) { + Write-Log -Level 'OK' -Message 'DRY RUN COMPLETE' +} else { Write-Log -Level 'OK' -Message 'RELEASE COMPLETE' } diff --git a/utils/engines/release/scriptSettings.json b/utils/engines/release/scriptSettings.json index 6b52503..f7daf70 100644 --- a/utils/engines/release/scriptSettings.json +++ b/utils/engines/release/scriptSettings.json @@ -16,7 +16,7 @@ "stageLabel": "test", "enabled": true, "project": "..\\..\\..\\src\\PodmanClientDotNet.Tests", - "resultsDir": "..\\..\\..\\testResults" + "resultsDir": "..\\..\\..\\test-results" }, { "name": "QualityGate", @@ -35,7 +35,7 @@ "projectFiles": [ "..\\..\\..\\src\\PodmanClient\\PodmanClientDotNet.csproj" ], - "artifactsDir": "..\\..\\..\\release" + "artifactsDir": "..\\..\\..\\releases" }, { "name": "DotNetCreateArchive", @@ -61,7 +61,7 @@ "name": "GitHub", "stageLabel": "release", "enabled": true, - "githubToken": "GITHUB_MAKS_IT_COM", + "githubSecret": "GitHub", "repository": "https://github.com/MAKS-IT-COM/podman-client-dotnet", "releaseNotesFile": "..\\..\\..\\CHANGELOG.md", "releaseTitlePattern": "Release {version}" @@ -70,7 +70,7 @@ "name": "DotNetNuGet", "stageLabel": "release", "enabled": true, - "nugetApiKey": "NUGET_MAKS_IT", + "nugetSecret": "NuGet", "source": "https://api.nuget.org/v3/index.json" }, { @@ -86,3 +86,4 @@ } ] } + diff --git a/utils/engines/test/scriptSettings.json b/utils/engines/test/scriptSettings.json index c06e91d..fb76278 100644 --- a/utils/engines/test/scriptSettings.json +++ b/utils/engines/test/scriptSettings.json @@ -1,9 +1,10 @@ { "$schema": "https://json-schema.org/draft-07/schema", "title": "Run Tests Script Settings", - "description": "Plugin-driven tests and coverage badges for PodmanClient.DotNet.", + "description": "Plugin-driven tests and shields.io coverage badges for PodmanClient.DotNet.", "paths": { - "badgesDir": "..\\..\\..\\assets\\badges" + "readmePath": "..\\..\\..\\README.md", + "testResultsDir": "..\\..\\..\\test-results" }, "plugins": [ { @@ -12,7 +13,8 @@ "enabled": true, "projects": [ "..\\..\\..\\src\\PodmanClientDotNet.Tests" - ] + ], + "resultsDir": "..\\..\\..\\test-results" }, { "name": "QualityGate", @@ -25,20 +27,18 @@ "name": "CoverageBadges", "stageLabel": "report", "enabled": true, - "badgesDir": "..\\..\\..\\assets\\badges", + "badgeFormat": "shields", + "readmePath": "..\\..\\..\\README.md", "badges": [ { - "name": "coverage-lines.svg", "label": "Line Coverage", "metric": "line" }, { - "name": "coverage-branches.svg", "label": "Branch Coverage", "metric": "branch" }, { - "name": "coverage-methods.svg", "label": "Method Coverage", "metric": "method" } diff --git a/utils/modules/Engine/EngineContext.psm1 b/utils/modules/Engine/EngineContext.psm1 index 9d397d1..19ad750 100644 --- a/utils/modules/Engine/EngineContext.psm1 +++ b/utils/modules/Engine/EngineContext.psm1 @@ -9,6 +9,7 @@ Used by New-EngineContext and version plugins: - DotNetReleaseVersion plugin -> projectFiles (.csproj ) - NpmReleaseVersion plugin -> packageJsonPath (package.json version) + - FileReleaseVersion plugin -> versionFilePath (repo-root VERSION file) #> if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { @@ -190,6 +191,72 @@ function Resolve-NpmReleaseVersion { } } +function Get-VersionFileSemver { + param( + [Parameter(Mandatory = $true)] + [string]$VersionFilePath + ) + + if (-not (Test-Path $VersionFilePath -PathType Leaf)) { + Write-Error "FileReleaseVersion: VERSION file not found at: $VersionFilePath" + exit 1 + } + + $version = (Get-Content -Path $VersionFilePath -Raw -Encoding UTF8).Trim() + if ([string]::IsNullOrWhiteSpace($version)) { + Write-Error "FileReleaseVersion: VERSION file is empty at: $VersionFilePath" + exit 1 + } + + $version = $version -replace '^[vV]', '' + if ($version -notmatch '^\d+\.\d+\.\d+') { + Write-Error "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver." + exit 1 + } + + return $version +} + +function Resolve-FileReleaseVersion { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$ScriptDir + ) + + $releaseVersionPlugin = @($Plugins | Where-Object { $_.name -eq 'FileReleaseVersion' } | Select-Object -First 1) + if ($releaseVersionPlugin.Count -eq 0 -or $null -eq $releaseVersionPlugin[0]) { + Write-Error "Configure a FileReleaseVersion plugin in scriptSettings.json with versionFilePath." + exit 1 + } + + $releaseVersionSettings = $releaseVersionPlugin[0] + $versionFileSetting = if ($releaseVersionSettings.versionFilePath) { + $releaseVersionSettings.versionFilePath + } + else { + '..\\..\\..\\VERSION' + } + + $versionFilePaths = @(Resolve-RelativePaths -Value $versionFileSetting -BasePath $ScriptDir) + if ($versionFilePaths.Count -eq 0) { + Write-Error "Configure release version via FileReleaseVersion.versionFilePath (repo-root VERSION file)." + exit 1 + } + + $versionFilePath = $versionFilePaths[0] + Write-Log -Level "INFO" -Message "Reading version from VERSION file (versionFilePath)..." + $version = Get-VersionFileSemver -VersionFilePath $versionFilePath + Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($versionFilePath)): $version" + + return [pscustomobject]@{ + version = $version + source = 'FileReleaseVersion' + } +} + function Resolve-ReleaseVersion { param( [Parameter(Mandatory = $true)] @@ -201,9 +268,15 @@ function Resolve-ReleaseVersion { $dotnetPlugin = @($Plugins | Where-Object { $_.name -eq 'DotNetReleaseVersion' -and $_.enabled -ne $false }) $npmPlugin = @($Plugins | Where-Object { $_.name -eq 'NpmReleaseVersion' -and $_.enabled -ne $false }) + $filePlugin = @($Plugins | Where-Object { $_.name -eq 'FileReleaseVersion' -and $_.enabled -ne $false }) - if ($dotnetPlugin.Count -gt 0 -and $npmPlugin.Count -gt 0) { - Write-Error "Configure only one release version plugin: DotNetReleaseVersion or NpmReleaseVersion, not both." + $enabledVersionPlugins = @() + if ($dotnetPlugin.Count -gt 0) { $enabledVersionPlugins += 'DotNetReleaseVersion' } + if ($npmPlugin.Count -gt 0) { $enabledVersionPlugins += 'NpmReleaseVersion' } + if ($filePlugin.Count -gt 0) { $enabledVersionPlugins += 'FileReleaseVersion' } + + if ($enabledVersionPlugins.Count -gt 1) { + Write-Error "Configure only one release version plugin: $($enabledVersionPlugins -join ', ')." exit 1 } @@ -215,11 +288,15 @@ function Resolve-ReleaseVersion { return Resolve-NpmReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir } - Write-Error "Configure a DotNetReleaseVersion plugin (projectFiles) or NpmReleaseVersion plugin (packageJsonPath) in scriptSettings.json." + if ($filePlugin.Count -gt 0) { + return Resolve-FileReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir + } + + Write-Error "Configure a DotNetReleaseVersion (projectFiles), NpmReleaseVersion (packageJsonPath), or FileReleaseVersion (versionFilePath) plugin in scriptSettings.json." exit 1 } -Export-ModuleMember -Function Get-CsprojPropertyValue, Get-CsprojVersions, Resolve-RelativePaths, Resolve-DotNetReleaseVersion, Resolve-NpmReleaseVersion, Resolve-ReleaseVersion +Export-ModuleMember -Function Get-CsprojPropertyValue, Get-CsprojVersions, Get-VersionFileSemver, Resolve-RelativePaths, Resolve-DotNetReleaseVersion, Resolve-NpmReleaseVersion, Resolve-FileReleaseVersion, Resolve-ReleaseVersion diff --git a/utils/modules/Engine/PluginSupport.psm1 b/utils/modules/Engine/PluginSupport.psm1 index 30371ea..8d8c482 100644 --- a/utils/modules/Engine/PluginSupport.psm1 +++ b/utils/modules/Engine/PluginSupport.psm1 @@ -16,6 +16,36 @@ if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { } } +function Test-IsEngineRuntimeModuleName { + param( + [Parameter(Mandatory = $true)] + [string]$ModuleName + ) + + # Engine runtime under modules/ only — never dual-homed under plugins/. + $engineNames = [System.Collections.Generic.HashSet[string]]::new( + [string[]]@( + 'ChangelogSupport', + 'ExternalCommandSupport', + 'GitTools', + 'Logging', + 'ScriptConfig', + 'TestRunner', + 'EngineContext', + 'PluginSupport', + 'ReleaseSupport', + 'TestSupport', + 'DeployConfig', + 'EngineContextSupport', + 'OrchestratorSupport', + 'PluginPathSupport' + ), + [System.StringComparer]::OrdinalIgnoreCase + ) + + return $engineNames.Contains($ModuleName) +} + function Import-PluginDependency { param( [Parameter(Mandatory = $true)] @@ -31,13 +61,29 @@ function Import-PluginDependency { $modulesDir = Get-RepoUtilsModulesDirectory $engineModuleDir = $PSScriptRoot - $modulePath = Join-Path $modulesDir "$ModuleName.psm1" - if (-not (Test-Path $modulePath -PathType Leaf)) { - $modulePath = Join-Path $engineModuleDir "$ModuleName.psm1" + $srcDir = Get-RepoUtilsSrcDirectory + $pluginsRoot = Join-Path $srcDir 'plugins' + $candidatePaths = [System.Collections.Generic.List[string]]::new() + + if (Test-IsEngineRuntimeModuleName -ModuleName $ModuleName) { + # Engine runtime: modules/ only (no plugins/ fallback). + $candidatePaths.Add((Join-Path $modulesDir "$ModuleName.psm1")) + $candidatePaths.Add((Join-Path $engineModuleDir "$ModuleName.psm1")) + $extensionsDir = Join-Path $modulesDir 'Extensions' + $candidatePaths.Add((Join-Path $extensionsDir "$ModuleName.psm1")) + } + else { + # Plugin helpers: plugins/ only (no modules/ legacy shadow). + foreach ($group in @('Shared', 'Platform', 'DotNet', 'Npm', 'Helm', 'Docker', 'Podman')) { + $candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $group) "$ModuleName.psm1")) + } } - if (Test-Path $modulePath -PathType Leaf) { - Import-Module $modulePath -Force -Global -ErrorAction Stop + foreach ($modulePath in $candidatePaths) { + if (Test-Path -LiteralPath $modulePath -PathType Leaf) { + Import-Module $modulePath -Force -Global -ErrorAction Stop + break + } } if (-not (Get-Command $RequiredCommand -ErrorAction SilentlyContinue)) { @@ -117,17 +163,277 @@ function Test-PluginAllowedOnBranch { return $allowedBranches -contains $CurrentBranch } -function Test-IsPublishPlugin { +function Get-PluginMetadataObject { param( [Parameter(Mandatory = $true)] - $Plugin + $Plugin, + + [Parameter(Mandatory = $true)] + [string]$EngineDirectory + ) + + $modulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory + if (-not (Test-Path $modulePath -PathType Leaf)) { + return $null + } + + try { + $moduleInfo = Import-Module $modulePath -Force -PassThru -ErrorAction Stop + $metadataCommand = Get-Command -Name 'Get-PluginMetadata' -Module $moduleInfo.Name -ErrorAction SilentlyContinue + if (-not $metadataCommand) { + return $null + } + + return & $metadataCommand + } + catch { + return $null + } +} + +function Test-PluginCompatible { + <# + .SYNOPSIS + Applies an optional compatibility policy supplied by an extension. + #> + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [string]$EngineDirectory, + + [Parameter(Mandatory = $false)] + [bool]$WriteLogs = $true + ) + + $extensionTest = Get-Command Test-ExtensionPluginCompatibility -ErrorAction SilentlyContinue + if ($extensionTest) { + return & $extensionTest @PSBoundParameters + } + + return $true +} + +function Test-PluginMutatesRemote { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $false)] + [string]$EngineDirectory ) if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace([string]$Plugin.name)) { return $false } - return @('GitHub', 'DotNetNuGet', 'DotNetDockerPush', 'DotNetHelmPush', 'NpmPublish') -contains ([string]$Plugin.name) + if ([string]::IsNullOrWhiteSpace($EngineDirectory)) { + if ($Plugin.PSObject.Properties.Name -contains 'context' -and $null -ne $Plugin.context -and $Plugin.context.scriptDir) { + $EngineDirectory = [string]$Plugin.context.scriptDir + } + elseif ($Plugin.PSObject.Properties.Name -contains 'scriptDir' -and -not [string]::IsNullOrWhiteSpace([string]$Plugin.scriptDir)) { + $EngineDirectory = [string]$Plugin.scriptDir + } + } + + if ([string]::IsNullOrWhiteSpace($EngineDirectory)) { + return $false + } + + $modulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory + if (-not (Test-Path $modulePath -PathType Leaf)) { + return $false + } + + try { + $moduleInfo = Import-Module $modulePath -Force -PassThru -ErrorAction Stop + $metadataCommand = Get-Command -Name 'Get-PluginMetadata' -Module $moduleInfo.Name -ErrorAction SilentlyContinue + if (-not $metadataCommand) { + return $false + } + + $metadata = & $metadataCommand + if ($null -eq $metadata) { + return $false + } + + if ($metadata.PSObject.Properties.Name -contains 'mutatesRemote') { + return [bool]$metadata.mutatesRemote + } + } + catch { + return $false + } + + return $false +} + +function Get-SecretEnvironmentValue { + <# + .SYNOPSIS + Reads a secret value from an environment variable by logical name. + + .DESCRIPTION + Plugins never store secret material in scriptSettings.json. Settings hold a + logical name (e.g. "GitHub", "NuGet"); the process environment variable with + that same name must be set before the engine runs. + + .PARAMETER Name + Logical secret name — also the environment variable name to read. + + .OUTPUTS + System.String. The environment variable value, or $null when unset. + + .EXAMPLE + $token = Get-SecretEnvironmentValue -Name 'GitHub' + #> + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + return [Environment]::GetEnvironmentVariable($Name) +} + +function Resolve-PluginSecretName { + <# + .SYNOPSIS + Resolves a logical secret name from a plugin's scriptSettings entry. + + .DESCRIPTION + Reads a string property such as githubSecret / nugetSecret / npmSecret / + containerRegistrySecret from the plugin settings object. Returns $null when + the property is missing or blank. + + .PARAMETER PluginSettings + Plugin settings object from scriptSettings.json (the enabled plugin entry). + + .PARAMETER PropertyName + Settings property that holds the logical secret name (e.g. 'githubSecret'). + + .OUTPUTS + System.String. Trimmed logical secret name, or $null. + + .EXAMPLE + $name = Resolve-PluginSecretName -PluginSettings $plugin -PropertyName 'nugetSecret' + $key = Get-SecretEnvironmentValue -Name $name + #> + param( + [Parameter(Mandatory = $true)] + $PluginSettings, + + [Parameter(Mandatory = $true)] + [string]$PropertyName + ) + + if ($PluginSettings.PSObject.Properties.Name -contains $PropertyName) { + $value = [string]$PluginSettings.$PropertyName + if (-not [string]::IsNullOrWhiteSpace($value)) { + return $value.Trim() + } + } + + return $null +} + +function Get-RegistryCredentialsFromRuntime { + <# + .SYNOPSIS + Loads container-registry username/password from a logical secret name. + + .DESCRIPTION + Looks up the environment variable named by SecretName. The value must be + Base64(UTF8('username:password')). Used by Docker/Podman/Helm registry login + and image-pull secret creation — never pass the password itself as a parameter. + + .PARAMETER SecretName + Logical secret name (environment variable name), not a password or token. + + .PARAMETER SharedSettings + Optional engine shared context (reserved for callers that thread context). + + .OUTPUTS + Hashtable with User and Password keys (decoded credential material). + + .EXAMPLE + $creds = Get-RegistryCredentialsFromRuntime -SecretName 'ContainerRegistry' + # $creds.User / $creds.Password + #> + # SecretName is a logical env-var name from scriptSettings, not a password value. + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingPlainTextForPassword', + 'SecretName', + Justification = 'Logical secret name for env lookup (Base64 username:password); not a credential value.' + )] + param( + [Parameter(Mandatory = $true)] + [string]$SecretName, + + [Parameter(Mandatory = $false)] + [psobject]$SharedSettings + ) + + $raw = Get-SecretEnvironmentValue -Name $SecretName + if ([string]::IsNullOrWhiteSpace($raw)) { + throw "Environment variable '$SecretName' is not set." + } + + try { + $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw)) + } + catch { + throw "Failed to decode '$SecretName' as Base64 (expected base64('username:password')): $($_.Exception.Message)" + } + + $parts = $decoded -split ':', 2 + if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) { + throw "Decoded '$SecretName' must be in the form 'username:password'." + } + + return @{ User = $parts[0]; Password = $parts[1] } +} + +function Resolve-EngineDirectoryFromSharedSettings { + param( + [Parameter(Mandatory = $true)] + $SharedSettings + ) + + if ($SharedSettings.PSObject.Properties.Name -contains 'engineScriptDir' -and -not [string]::IsNullOrWhiteSpace([string]$SharedSettings.engineScriptDir)) { + return [string]$SharedSettings.engineScriptDir + } + + return [string]$SharedSettings.scriptDir +} + +function Test-PluginSkipsRemoteMutation { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings + ) + + $engineDirectory = Resolve-EngineDirectoryFromSharedSettings -SharedSettings $SharedSettings + if (-not (Test-PluginMutatesRemote -Plugin $Plugin -EngineDirectory $engineDirectory)) { + return $false + } + + return ($Plugin.PSObject.Properties.Name -contains 'dryRun' -and $null -ne $Plugin.dryRun -and [bool]$Plugin.dryRun) +} + +function Test-IsPublishPlugin { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $false)] + [string]$EngineDirectory + ) + + return Test-PluginMutatesRemote -Plugin $Plugin -EngineDirectory $EngineDirectory } function Get-PluginSettingValue { @@ -263,12 +569,31 @@ function Resolve-PluginModulePath { $srcDir = Split-Path (Split-Path $EngineDirectory -Parent) -Parent $pluginsRoot = Join-Path $srcDir "plugins" $pluginFileName = "{0}.psm1" -f $Plugin.name - $candidatePaths = @( - (Join-Path (Join-Path $EngineDirectory "custom") $pluginFileName), - (Join-Path (Join-Path $pluginsRoot "Platform") $pluginFileName), - (Join-Path (Join-Path $pluginsRoot "DotNet") $pluginFileName), - (Join-Path (Join-Path $pluginsRoot "Npm") $pluginFileName) - ) + $candidatePaths = [System.Collections.Generic.List[string]]::new() + $candidatePaths.Add((Join-Path (Join-Path $EngineDirectory "custom") $pluginFileName)) + + $preferredGroups = @('Platform', 'DotNet', 'Npm') + $candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $preferredGroups[0]) $pluginFileName)) + + if (Get-Command Get-ExtensionPluginModulePaths -ErrorAction SilentlyContinue) { + foreach ($extensionPath in Get-ExtensionPluginModulePaths -PluginsRoot $pluginsRoot -PluginFileName $pluginFileName) { + $candidatePaths.Add($extensionPath) + } + } + + foreach ($group in $preferredGroups[1..($preferredGroups.Count - 1)]) { + $candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $group) $pluginFileName)) + } + + $reservedPluginDirs = @($preferredGroups + @('Shared')) + if (Test-Path -LiteralPath $pluginsRoot -PathType Container) { + Get-ChildItem -LiteralPath $pluginsRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notin $reservedPluginDirs } | + Sort-Object Name | + ForEach-Object { + $candidatePaths.Add((Join-Path $_.FullName $pluginFileName)) + } + } foreach ($candidatePath in $candidatePaths) { if (Test-Path $candidatePath -PathType Leaf) { @@ -360,11 +685,34 @@ function Invoke-ConfiguredPlugin { return $true } + $metadata = Get-PluginMetadataObject -Plugin $Plugin -EngineDirectory $EngineDirectory + if ($null -ne $metadata -and ($metadata.PSObject.Properties.Name -contains 'providesVersion') -and [bool]$metadata.providesVersion) { + $versionAlreadySet = $false + if (Get-Command Get-EngineState -ErrorAction SilentlyContinue) { + $existingVersion = Get-EngineState -Context $SharedSettings -Name 'version' -ErrorAction SilentlyContinue + $versionAlreadySet = -not [string]::IsNullOrWhiteSpace([string]$existingVersion) + } + elseif (($SharedSettings.PSObject.Properties.Name -contains 'version') -and -not [string]::IsNullOrWhiteSpace([string]$SharedSettings.version)) { + $versionAlreadySet = $true + } + + if ($versionAlreadySet) { + Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.name)' (version already resolved during New-EngineContext)." + return $true + } + + # Test engine (and other hosts) may not resolve version in New-EngineContext; run the plugin now. + } + if ((Test-IsPublishPlugin -Plugin $Plugin) -and ($SharedSettings.PSObject.Properties.Name -contains 'skipPublishPlugins') -and $SharedSettings.skipPublishPlugins) { Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.name)' (ReleasePublishGuard suppressed publish)." return $true } + if (-not (Test-PluginCompatible -Plugin $Plugin -EngineDirectory $EngineDirectory -WriteLogs:$true)) { + return $true + } + $pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.name)'..." @@ -383,4 +731,4 @@ function Invoke-ConfiguredPlugin { } } -Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin +Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Get-PluginMetadataObject, Test-PluginCompatible, Test-PluginMutatesRemote, Resolve-PluginSecretName, Get-SecretEnvironmentValue, Get-RegistryCredentialsFromRuntime, Test-PluginSkipsRemoteMutation, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin diff --git a/utils/modules/Engine/ReleaseSupport.psm1 b/utils/modules/Engine/ReleaseSupport.psm1 index ffda8d7..853eb74 100644 --- a/utils/modules/Engine/ReleaseSupport.psm1 +++ b/utils/modules/Engine/ReleaseSupport.psm1 @@ -78,13 +78,18 @@ function New-EngineContext { [string]$SrcDir, [Parameter(Mandatory = $false)] - [psobject]$Settings + [psobject]$Settings, + + [switch]$DryRun, + + [ValidateSet('single', 'ha')] + [string]$DeployMode = 'ha' ) $resolvedVersion = Resolve-ReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir $version = $resolvedVersion.version $versionSource = $resolvedVersion.source - $releaseRelative = '..\..\..\release' + $releaseRelative = '..\..\..\releases' $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $releaseRelative)) $currentBranch = Get-CurrentBranch @@ -119,6 +124,23 @@ function New-EngineContext { $tag = "v$version" Write-Log -Level "INFO" -Message " Release tag default from ${versionSource}: $tag (ReleasePublishGuard may replace from git when publish is allowed)." + $dryRun = $false + if ($DryRun) { + $dryRun = $true + } + else { + $dryRun = Get-EngineDryRun -Settings $Settings + } + Write-Log -Level "INFO" -Message " Dry run (remote mutations only): $dryRun" + + $orchestrator = Get-MaksitOrchestrator + if ($orchestrator) { + Write-Log -Level "INFO" -Message " Orchestrator: $orchestrator (plugin profile filtering active)" + } + else { + Write-Log -Level "INFO" -Message " Orchestrator: not set (dev mode — all plugins eligible; engine probe still selects docker vs podman)" + } + return [pscustomobject]@{ scriptDir = $ScriptDir srcDir = $SrcDir @@ -132,6 +154,9 @@ function New-EngineContext { releaseBranches = $releaseBranches publishCompleted = $false skipPublishPlugins = $false + dryRun = $dryRun + deployMode = $DeployMode + orchestrator = $orchestrator } } diff --git a/utils/modules/Engine/TestSupport.psm1 b/utils/modules/Engine/TestSupport.psm1 index a90d03d..03aeb46 100644 --- a/utils/modules/Engine/TestSupport.psm1 +++ b/utils/modules/Engine/TestSupport.psm1 @@ -19,7 +19,10 @@ function New-EngineContext { [string]$SrcDir, [Parameter(Mandatory = $false)] - [psobject]$Settings + [psobject]$Settings, + + [ValidateSet('single', 'ha')] + [string]$DeployMode = 'ha' ) $badgesDir = $null @@ -32,6 +35,7 @@ function New-EngineContext { srcDir = $SrcDir utilsDir = $SrcDir badgesDir = $badgesDir + deployMode = $DeployMode } } diff --git a/utils/modules/ExternalCommandSupport.psm1 b/utils/modules/ExternalCommandSupport.psm1 new file mode 100644 index 0000000..b6c05f1 --- /dev/null +++ b/utils/modules/ExternalCommandSupport.psm1 @@ -0,0 +1,102 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +$script:ExternalCommandTestHandler = $null +$script:ExternalCommandAvailability = @{} + +function Set-ExternalCommandTestHandler { + param( + [Parameter(Mandatory = $true)] + [scriptblock]$Handler + ) + + $script:ExternalCommandTestHandler = $Handler +} + +function Clear-ExternalCommandTestHandler { + $script:ExternalCommandTestHandler = $null +} + +function Set-ExternalCommandAvailability { + param( + [Parameter(Mandatory = $true)] + [hashtable]$Availability + ) + + $script:ExternalCommandAvailability = @{} + foreach ($key in $Availability.Keys) { + $script:ExternalCommandAvailability[[string]$key] = [bool]$Availability[$key] + } +} + +function Invoke-ExternalCommand { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [string[]]$ArgumentList = @(), + + [string]$WorkingDirectory, + + [string]$InputObject, + + [switch]$MergeErrorOutput + ) + + $previousLocation = $null + if (-not [string]::IsNullOrWhiteSpace($WorkingDirectory)) { + $previousLocation = Get-Location + Push-Location $WorkingDirectory + } + + try { + $effectiveWorkingDirectory = (Get-Location).Path + + if ($null -ne $script:ExternalCommandTestHandler) { + $handlerResult = & $script:ExternalCommandTestHandler ` + -Name $Name ` + -ArgumentList $ArgumentList ` + -WorkingDirectory $effectiveWorkingDirectory ` + -InputObject $InputObject ` + -MergeErrorOutput:$MergeErrorOutput.IsPresent + + $global:LASTEXITCODE = [int]$handlerResult.ExitCode + if ($null -eq $handlerResult.Output) { + return @() + } + + if ($handlerResult.Output -is [System.Collections.IEnumerable] -and -not ($handlerResult.Output -is [string])) { + return @($handlerResult.Output) + } + + return @([string]$handlerResult.Output) + } + + if ($script:ExternalCommandAvailability.ContainsKey($Name) -and -not $script:ExternalCommandAvailability[$Name]) { + throw "External command '$Name' is marked unavailable." + } + + if (-not [string]::IsNullOrWhiteSpace($InputObject)) { + $output = $InputObject | & $Name @ArgumentList 2>&1 + } + elseif ($MergeErrorOutput) { + $output = & $Name @ArgumentList 2>&1 + } + else { + $output = & $Name @ArgumentList + } + + return @($output) + } + finally { + if ($null -ne $previousLocation) { + Pop-Location + } + } +} + +Export-ModuleMember -Function ` + Invoke-ExternalCommand, ` + Set-ExternalCommandTestHandler, ` + Clear-ExternalCommandTestHandler, ` + Set-ExternalCommandAvailability diff --git a/utils/modules/ScriptConfig.psm1 b/utils/modules/ScriptConfig.psm1 index 26bd953..b8895b1 100644 --- a/utils/modules/ScriptConfig.psm1 +++ b/utils/modules/ScriptConfig.psm1 @@ -2,34 +2,134 @@ #requires -PSEdition Core function Get-ScriptSettings { + [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$ScriptDir, + [ValidateSet('single', 'ha')] + [string]$Mode, + [Parameter(Mandatory = $false)] - [string]$SettingsFileName = "scriptSettings.json" + [string]$SettingsFileName = 'scriptSettings.json' ) - $settingsPath = Join-Path $ScriptDir $SettingsFileName - - if (-not (Test-Path $settingsPath -PathType Leaf)) { - Write-Error "Settings file not found: $settingsPath" - exit 1 + $settingsPath = if ($PSBoundParameters.ContainsKey('Mode')) { + $modePath = Join-Path $ScriptDir "scriptSettings.$Mode.json" + if (Test-Path -LiteralPath $modePath -PathType Leaf) { + $modePath + } + else { + Join-Path $ScriptDir $SettingsFileName + } + } + else { + Join-Path $ScriptDir $SettingsFileName } - return Get-Content $settingsPath -Raw | ConvertFrom-Json + if (-not (Test-Path -LiteralPath $settingsPath -PathType Leaf)) { + throw "Settings file not found: $settingsPath" + } + + return Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json +} + +function Get-HelmSelfDeployPluginFromSettings { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [psobject]$Settings + ) + + if (-not ($Settings.PSObject.Properties.Name -contains 'plugins') -or -not $Settings.plugins) { + return $null + } + + foreach ($plugin in @($Settings.plugins)) { + if ($plugin.name -eq 'HelmSelfDeploy') { + return $plugin + } + } + + return $null +} + +function Get-DeployModeFromSettings { + <# + .SYNOPSIS + Resolves cluster deploy profile from HelmSelfDeploy plugin settings or an explicit CLI override. + + .DESCRIPTION + CI/CD pipelines omit -Mode and read deployMode on the HelmSelfDeploy plugin. + Manual installs use Invoke-ReleasePackage-Single.bat / Invoke-ReleasePackage-HA.bat. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [psobject]$Settings, + + [ValidateSet('single', 'ha')] + [string]$ModeOverride + ) + + if ($PSBoundParameters.ContainsKey('ModeOverride')) { + return $ModeOverride + } + + $helmSelfDeploy = Get-HelmSelfDeployPluginFromSettings -Settings $Settings + if ($null -ne $helmSelfDeploy -and $helmSelfDeploy.PSObject.Properties.Name -contains 'deployMode') { + $mode = [string]$helmSelfDeploy.deployMode + if (-not [string]::IsNullOrWhiteSpace($mode)) { + $mode = $mode.Trim().ToLowerInvariant() + if ($mode -in @('single', 'ha')) { + return $mode + } + + throw "HelmSelfDeploy.deployMode must be 'single' or 'ha' (got '$mode')." + } + } + + return 'ha' +} + +function Resolve-DeployValuesFilePath { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$DeploySettingsDir, + + [Parameter(Mandatory = $true)] + [string]$ValuesFile, + + [Parameter(Mandatory = $true)] + [ValidateSet('single', 'ha')] + [string]$DeployMode + ) + + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($ValuesFile) + $extension = [System.IO.Path]::GetExtension($ValuesFile) + if ([string]::IsNullOrEmpty($extension)) { + $extension = '.yaml' + } + + $valuesPath = Join-Path $DeploySettingsDir "$baseName.$DeployMode$extension" + if (Test-Path -LiteralPath $valuesPath -PathType Leaf) { + return $valuesPath + } + + throw "Deploy values file not found: '$valuesPath'. HelmSelfDeploy expects values.single.yaml or values.ha.yaml beside scriptSettings.json." } function Assert-Command { + [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Command ) if (-not (Get-Command $Command -ErrorAction SilentlyContinue)) { - Write-Error "Required command '$Command' is missing. Aborting." - exit 1 + throw "Required command '$Command' is missing. Aborting." } } -Export-ModuleMember -Function Get-ScriptSettings, Assert-Command +Export-ModuleMember -Function Get-ScriptSettings, Get-DeployModeFromSettings, Resolve-DeployValuesFilePath, Assert-Command diff --git a/utils/modules/TestRunner.psm1 b/utils/modules/TestRunner.psm1 index de1a493..b1935e2 100644 --- a/utils/modules/TestRunner.psm1 +++ b/utils/modules/TestRunner.psm1 @@ -14,6 +14,28 @@ Usage: pwsh -Command "Import-Module .\TestRunner.psm1" #> +function Import-ExternalCommandSupportInternal { + if (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue) { + return + } + + $srcDir = Split-Path $PSScriptRoot -Parent + $candidates = @( + (Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1'), + (Join-Path $srcDir 'plugins' 'Shared' 'ExternalCommandSupport.psm1') + ) + foreach ($modulePath in $candidates) { + if (Test-Path -LiteralPath $modulePath -PathType Leaf) { + Import-Module $modulePath -Force -Global + break + } + } + + if (-not (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue)) { + throw "ExternalCommandSupport module not found. Expected: $($candidates -join ', ')" + } +} + function Import-LoggingModuleInternal { if (Get-Command Write-Log -ErrorAction SilentlyContinue) { return @@ -96,8 +118,8 @@ function Invoke-TestsWithCoverage { $ErrorActionPreference = "Stop" - # Normalize to a non-empty list of absolute .csproj paths. - $resolvedProjectFiles = [System.Collections.Generic.List[string]]::new() + # Normalize to a non-empty list of absolute working directories (folder containing the test project). + $resolvedProjectDirs = [System.Collections.Generic.List[string]]::new() foreach ($raw in $TestProjectPath) { if ([string]::IsNullOrWhiteSpace($raw)) { continue } $full = [System.IO.Path]::GetFullPath($raw.Trim()) @@ -107,46 +129,22 @@ function Invoke-TestsWithCoverage { Error = "Test project not found at: $raw" } } - $item = Get-Item -LiteralPath $full - if ($item.PSIsContainer) { - $csprojFiles = @(Get-ChildItem -Path $item.FullName -Filter '*.csproj' -File | Sort-Object Name) - if ($csprojFiles.Count -eq 0) { - return [PSCustomObject]@{ - Success = $false - Error = "No .csproj file found in test project directory: $($item.FullName)" - } - } - foreach ($csproj in $csprojFiles) { - if ($resolvedProjectFiles -notcontains $csproj.FullName) { - [void]$resolvedProjectFiles.Add($csproj.FullName) - } - } - continue - } - - if ([System.IO.Path]::GetExtension($item.FullName) -ne '.csproj') { - return [PSCustomObject]@{ - Success = $false - Error = "Test project path is not a .csproj file or directory: $full" - } - } - - if ($resolvedProjectFiles -notcontains $item.FullName) { - [void]$resolvedProjectFiles.Add($item.FullName) + $dir = if ($item.PSIsContainer) { $item.FullName } else { $item.Directory.FullName } + if ($resolvedProjectDirs -notcontains $dir) { + [void]$resolvedProjectDirs.Add($dir) } } - if ($resolvedProjectFiles.Count -eq 0) { + if ($resolvedProjectDirs.Count -eq 0) { return [PSCustomObject]@{ Success = $false Error = "No valid test project paths were provided." } } - $firstProjectDir = [System.IO.Path]::GetDirectoryName($resolvedProjectFiles[0]) if ([string]::IsNullOrWhiteSpace($ResultsDirectory)) { - $ResultsDir = Join-Path $firstProjectDir "TestResults" + $ResultsDir = Join-Path $resolvedProjectDirs[0] "TestResults" } else { $ResultsDir = [System.IO.Path]::GetFullPath($ResultsDirectory) @@ -160,53 +158,39 @@ function Invoke-TestsWithCoverage { if (-not $Silent) { Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage..." - foreach ($projectFile in $resolvedProjectFiles) { - Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $projectFile" + foreach ($d in $resolvedProjectDirs) { + Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $d" } } - $verbosity = if ($Silent) { 'quiet' } else { 'normal' } + foreach ($TestProjectDir in $resolvedProjectDirs) { + Push-Location $TestProjectDir + try { + $dotnetArgs = @( + "test" + "--collect:XPlat Code Coverage" + "--results-directory", $ResultsDir + "--verbosity", $(if ($Silent) { "quiet" } else { "normal" }) + ) - foreach ($projectFile in $resolvedProjectFiles) { - $buildArgs = @('build', $projectFile, '-v', $verbosity) - if ($Silent) { - $null = & dotnet @buildArgs 2>&1 - } - else { - & dotnet @buildArgs - } + Import-ExternalCommandSupportInternal + if ($Silent) { + $null = Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -MergeErrorOutput + } + else { + Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs | Out-Default + } - if ($LASTEXITCODE -ne 0) { - return [PSCustomObject]@{ - Success = $false - Error = "Build failed for '$projectFile' with exit code $LASTEXITCODE" + $testExitCode = $LASTEXITCODE + if ($testExitCode -ne 0) { + return [PSCustomObject]@{ + Success = $false + Error = "Tests failed in '$TestProjectDir' with exit code $testExitCode" + } } } - } - - foreach ($projectFile in $resolvedProjectFiles) { - $dotnetArgs = @( - 'test' - $projectFile - '--no-build' - '--collect:XPlat Code Coverage' - '--results-directory', $ResultsDir - '--verbosity', $verbosity - ) - - if ($Silent) { - $null = & dotnet @dotnetArgs 2>&1 - } - else { - & dotnet @dotnetArgs - } - - $testExitCode = $LASTEXITCODE - if ($testExitCode -ne 0) { - return [PSCustomObject]@{ - Success = $false - Error = "Tests failed in '$projectFile' with exit code $testExitCode" - } + finally { + Pop-Location } } @@ -370,11 +354,12 @@ function Invoke-NpmJestTestsWithCoverage { Push-Location $workspaceFull try { $npmArgs = @('run', $TestScript, '--', '--coverage', '--coverageReporters=json-summary', '--coverageReporters=text') + Import-ExternalCommandSupportInternal if ($Silent) { - $null = & npm @npmArgs 2>&1 + $null = Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs -MergeErrorOutput } else { - & npm @npmArgs + Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs | Out-Default } if ($LASTEXITCODE -ne 0) { @@ -428,4 +413,272 @@ function Invoke-NpmJestTestsWithCoverage { } } -Export-ModuleMember -Function Invoke-TestsWithCoverage, Invoke-NpmJestTestsWithCoverage +function Get-NpmCoverageFromSummaryFile { + param( + [Parameter(Mandatory = $true)] + [string]$SummaryPath, + + [switch]$Silent + ) + + if (-not (Test-Path -LiteralPath $SummaryPath -PathType Leaf)) { + return [PSCustomObject]@{ + Success = $false + Error = "Jest coverage summary not found at: $SummaryPath" + } + } + + $summaryJson = Get-Content -LiteralPath $SummaryPath -Raw -Encoding UTF8 | ConvertFrom-Json + $total = $summaryJson.total + if ($null -eq $total) { + return [PSCustomObject]@{ + Success = $false + Error = "Jest coverage summary is missing 'total' metrics in: $SummaryPath" + } + } + + return [PSCustomObject]@{ + Success = $true + LineRate = [math]::Round([double]$total.lines.pct, 1) + BranchRate = [math]::Round([double]$total.branches.pct, 1) + MethodRate = [math]::Round([double]$total.functions.pct, 1) + TotalMethods = [int]$total.functions.total + CoveredMethods = [int]$total.functions.covered + CoverageFormat = 'npm' + CoverageSummaryFile = $SummaryPath + } +} + +function Get-DotNetCoverageFromResultsDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$ResultsDirectory, + + [switch]$Silent + ) + + $coverageFiles = @(Get-ChildItem -Path $ResultsDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue | Sort-Object FullName) + if ($coverageFiles.Count -eq 0) { + return [PSCustomObject]@{ + Success = $false + Error = "Coverage file not found under: $ResultsDirectory" + } + } + + $linesCoveredTotal = 0L + $linesValidTotal = 0L + $branchesCoveredTotal = 0L + $branchesValidTotal = 0L + $totalMethods = 0 + $coveredMethods = 0 + + foreach ($cf in $coverageFiles) { + [xml]$coverageXml = Get-Content -LiteralPath $cf.FullName -Raw + $root = $coverageXml.coverage + $lcAttr = $root.'lines-covered' + $lvAttr = $root.'lines-valid' + if ($null -ne $lcAttr -and $null -ne $lvAttr -and [long]$lvAttr -gt 0) { + $linesCoveredTotal += [long]$lcAttr + $linesValidTotal += [long]$lvAttr + } + + $bcAttr = $root.'branches-covered' + $bvAttr = $root.'branches-valid' + if ($null -ne $bcAttr -and $null -ne $bvAttr -and [long]$bvAttr -gt 0) { + $branchesCoveredTotal += [long]$bcAttr + $branchesValidTotal += [long]$bvAttr + } + + foreach ($package in @($root.packages.package)) { + foreach ($class in @($package.classes.class)) { + $methodNodes = $class.methods + if ($null -eq $methodNodes) { continue } + foreach ($method in @($methodNodes.method)) { + if ($null -eq $method) { continue } + $totalMethods++ + if ([double]$method.'line-rate' -gt 0) { + $coveredMethods++ + } + } + } + } + } + + if ($linesValidTotal -gt 0) { + $lineRate = [math]::Round(($linesCoveredTotal / $linesValidTotal) * 100, 1) + } + else { + $acc = 0.0 + $n = 0 + foreach ($cf in $coverageFiles) { + [xml]$coverageXml = Get-Content -LiteralPath $cf.FullName -Raw + $acc += [double]$coverageXml.coverage.'line-rate' + $n++ + } + $lineRate = [math]::Round(($acc / [math]::Max($n, 1)) * 100, 1) + } + + if ($branchesValidTotal -gt 0) { + $branchRate = [math]::Round(($branchesCoveredTotal / $branchesValidTotal) * 100, 1) + } + else { + $acc = 0.0 + $n = 0 + foreach ($cf in $coverageFiles) { + [xml]$coverageXml = Get-Content -LiteralPath $cf.FullName -Raw + $acc += [double]$coverageXml.coverage.'branch-rate' + $n++ + } + $branchRate = [math]::Round(($acc / [math]::Max($n, 1)) * 100, 1) + } + + $methodRate = if ($totalMethods -gt 0) { [math]::Round(($coveredMethods / $totalMethods) * 100, 1) } else { 0 } + $coveragePaths = @($coverageFiles | ForEach-Object { $_.FullName }) + + return [PSCustomObject]@{ + Success = $true + LineRate = $lineRate + BranchRate = $branchRate + MethodRate = $methodRate + TotalMethods = $totalMethods + CoveredMethods = $coveredMethods + CoverageFile = ($coveragePaths -join ';') + CoverageFiles = $coveragePaths + ResultsDirectory = [System.IO.Path]::GetFullPath($ResultsDirectory) + CoverageFormat = 'dotnet' + } +} + +function Get-CoverageFromResultsDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$ResultsDirectory, + + [ValidateSet('auto', 'dotnet', 'npm')] + [string]$Format = 'auto', + + [switch]$Silent + ) + + $resolvedDirectory = [System.IO.Path]::GetFullPath($ResultsDirectory) + if (-not (Test-Path -LiteralPath $resolvedDirectory -PathType Container)) { + return [PSCustomObject]@{ + Success = $false + Error = "Results directory not found: $resolvedDirectory" + } + } + + $hasDotNet = @(Get-ChildItem -Path $resolvedDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue).Count -gt 0 + $jestSummary = Join-Path $resolvedDirectory 'coverage-summary.json' + $hasNpm = Test-Path -LiteralPath $jestSummary -PathType Leaf + + $effectiveFormat = $Format + if ($effectiveFormat -eq 'auto') { + if ($hasDotNet -and -not $hasNpm) { + $effectiveFormat = 'dotnet' + } + elseif ($hasNpm -and -not $hasDotNet) { + $effectiveFormat = 'npm' + } + elseif ($hasDotNet) { + $effectiveFormat = 'dotnet' + } + elseif ($hasNpm) { + $effectiveFormat = 'npm' + } + else { + return [PSCustomObject]@{ + Success = $false + Error = "Coverage file not found under: $resolvedDirectory" + } + } + } + + if ($effectiveFormat -eq 'dotnet') { + return Get-DotNetCoverageFromResultsDirectory -ResultsDirectory $resolvedDirectory -Silent:$Silent + } + + if ($effectiveFormat -eq 'npm') { + $npmResult = Get-NpmCoverageFromSummaryFile -SummaryPath $jestSummary -Silent:$Silent + if (-not $npmResult.Success) { + return $npmResult + } + + $npmResult | Add-Member -NotePropertyName ResultsDirectory -NotePropertyValue $resolvedDirectory -Force + return $npmResult + } + + return [PSCustomObject]@{ + Success = $false + Error = "Unsupported coverage format '$Format'." + } +} + +function Publish-CoverageMetricsToSharedContext { + param( + [Parameter(Mandatory = $true)] + $SharedSettings, + + [Parameter(Mandatory = $true)] + $TestResult + ) + + if (-not (Get-Command Set-EngineFact -ErrorAction SilentlyContinue)) { + $engineContextPath = Join-Path $PSScriptRoot 'Engine' 'EngineContext.psm1' + if (Test-Path -LiteralPath $engineContextPath -PathType Leaf) { + Import-Module $engineContextPath -Force -Global + } + } + + if (Get-Command Set-EngineFact -ErrorAction SilentlyContinue) { + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'testResult' -Value $TestResult -Overwrite Replace -LegacyProperty 'testResult' + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageLineRate' -Value $TestResult.LineRate -Overwrite Replace -LegacyProperty 'coverageLineRate' + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'qualityLineCoverage' -Value $TestResult.LineRate -Overwrite Replace -LegacyProperty 'qualityLineCoverage' + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageBranchRate' -Value $TestResult.BranchRate -Overwrite Replace -LegacyProperty 'coverageBranchRate' + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageMethodRate' -Value $TestResult.MethodRate -Overwrite Replace -LegacyProperty 'coverageMethodRate' + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageTotalMethods' -Value $TestResult.TotalMethods -Overwrite Replace -LegacyProperty 'coverageTotalMethods' + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageCoveredMethods' -Value $TestResult.CoveredMethods -Overwrite Replace -LegacyProperty 'coverageCoveredMethods' + + if ($TestResult.PSObject.Properties.Name -contains 'CoverageFormat') { + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageFormat' -Value $TestResult.CoverageFormat -Overwrite Replace -LegacyProperty 'coverageFormat' + } + + if (($TestResult.PSObject.Properties.Name -contains 'ResultsDirectory') -and $TestResult.ResultsDirectory) { + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'testResultsDirectory' -Value $TestResult.ResultsDirectory -Overwrite Replace -LegacyProperty 'testResultsDirectory' + } + + if ($TestResult.CoverageFiles) { + Set-EngineFact -Context $SharedSettings -Namespace 'test' -Name 'coverageCoberturaPaths' -Value @($TestResult.CoverageFiles) -Overwrite Replace -LegacyProperty 'coverageCoberturaPaths' + } + + return + } + + $SharedSettings | Add-Member -NotePropertyName testResult -NotePropertyValue $TestResult -Force + $SharedSettings | Add-Member -NotePropertyName qualityLineCoverage -NotePropertyValue $TestResult.LineRate -Force + $SharedSettings | Add-Member -NotePropertyName coverageLineRate -NotePropertyValue $TestResult.LineRate -Force + $SharedSettings | Add-Member -NotePropertyName coverageBranchRate -NotePropertyValue $TestResult.BranchRate -Force + $SharedSettings | Add-Member -NotePropertyName coverageMethodRate -NotePropertyValue $TestResult.MethodRate -Force + $SharedSettings | Add-Member -NotePropertyName coverageTotalMethods -NotePropertyValue $TestResult.TotalMethods -Force + $SharedSettings | Add-Member -NotePropertyName coverageCoveredMethods -NotePropertyValue $TestResult.CoveredMethods -Force + + if ($TestResult.PSObject.Properties.Name -contains 'CoverageFormat') { + $SharedSettings | Add-Member -NotePropertyName coverageFormat -NotePropertyValue $TestResult.CoverageFormat -Force + } + + if (($TestResult.PSObject.Properties.Name -contains 'ResultsDirectory') -and $TestResult.ResultsDirectory) { + $SharedSettings | Add-Member -NotePropertyName testResultsDirectory -NotePropertyValue $TestResult.ResultsDirectory -Force + } + + if ($TestResult.CoverageFiles) { + $SharedSettings | Add-Member -NotePropertyName coverageCoberturaPaths -NotePropertyValue @($TestResult.CoverageFiles) -Force + } +} + +Export-ModuleMember -Function ` + Invoke-TestsWithCoverage, ` + Invoke-NpmJestTestsWithCoverage, ` + Get-NpmCoverageFromSummaryFile, ` + Get-DotNetCoverageFromResultsDirectory, ` + Get-CoverageFromResultsDirectory, ` + Publish-CoverageMetricsToSharedContext diff --git a/utils/plugins/DotNet/DiscoverDotNetPackageArtifacts.psm1 b/utils/plugins/DotNet/DiscoverDotNetPackageArtifacts.psm1 new file mode 100644 index 0000000..f2f0ed6 --- /dev/null +++ b/utils/plugins/DotNet/DiscoverDotNetPackageArtifacts.psm1 @@ -0,0 +1,69 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Discovers existing .NET NuGet package artifacts. + +.DESCRIPTION + Scans artifactsDir for .nupkg/.snupkg matching the release version and populates shared + context (packageFile, symbolsPackageFile, releaseArchiveInputs) for downstream plugins. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1' + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log' + Import-PluginDependency -ModuleName 'DotNetArtifactSupport' -RequiredCommand 'Resolve-DotNetPackageArtifacts' + Import-PluginDependency -ModuleName 'EngineContext' -RequiredCommand 'Set-EngineFact' + + $pluginSettings = $Settings + $sharedSettings = $Settings.context + $scriptDir = $sharedSettings.scriptDir + $version = $sharedSettings.version + + if ($Settings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$Settings.artifactsDir)) { + $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$Settings.artifactsDir))) + Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory + Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory + } + else { + $artifactsDirectory = $sharedSettings.artifactsDirectory + } + + if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) { + throw 'DiscoverDotNetPackageArtifacts requires artifactsDir in plugin settings or artifactsDirectory on shared context.' + } + + Write-Log -Level 'STEP' -Message "Discovering NuGet package artifacts in $artifactsDirectory ..." + $resolved = Resolve-DotNetPackageArtifacts -ArtifactsDirectory $artifactsDirectory -Version $version + + Write-Log -Level 'OK' -Message " Package ready: $($resolved.PackageFile.FullName)" + if ($resolved.SymbolsPackageFile) { + Write-Log -Level 'OK' -Message " Symbols package ready: $($resolved.SymbolsPackageFile.FullName)" + } + else { + Write-Log -Level 'WARN' -Message " Symbols package (.snupkg) not found for version $version." + } + + Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $resolved.PackageFile -Overwrite Replace -LegacyProperty 'packageFile' + Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $resolved.SymbolsPackageFile -Overwrite Replace -LegacyProperty 'symbolsPackageFile' + Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseArchiveInputs' +} + +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $false } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/DotNet/DotNetArtifactSupport.psm1 b/utils/plugins/DotNet/DotNetArtifactSupport.psm1 new file mode 100644 index 0000000..205406e --- /dev/null +++ b/utils/plugins/DotNet/DotNetArtifactSupport.psm1 @@ -0,0 +1,63 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Shared helpers for discovering .NET NuGet package artifacts on disk. +#> + +function Resolve-DotNetPackageArtifacts { + param( + [Parameter(Mandatory = $true)] + [string]$ArtifactsDirectory, + + [Parameter(Mandatory = $true)] + [string]$Version + ) + + if (-not (Test-Path $ArtifactsDirectory -PathType Container)) { + throw "Artifacts directory not found: $ArtifactsDirectory" + } + + $packageFile = $null + $newestNupkgWrite = [datetime]::MinValue + $nupkgCandidates = Get-ChildItem -Path $ArtifactsDirectory -Filter '*.nupkg' + foreach ($candidate in $nupkgCandidates) { + if (($candidate.Name -like "*$Version*.nupkg") -and ($candidate.Name -notlike '*.symbols.nupkg') -and ($candidate.Name -notlike '*.snupkg')) { + if ($candidate.LastWriteTime -gt $newestNupkgWrite) { + $newestNupkgWrite = $candidate.LastWriteTime + $packageFile = $candidate + } + } + } + + if (-not $packageFile) { + throw "Could not locate generated NuGet package for version $Version in: $ArtifactsDirectory" + } + + $releaseArchiveInputs = @($packageFile.FullName) + + $symbolsPackageFile = $null + $newestSnupkgWrite = [datetime]::MinValue + $snupkgCandidates = Get-ChildItem -Path $ArtifactsDirectory -Filter '*.snupkg' + foreach ($candidate in $snupkgCandidates) { + if ($candidate.Name -like "*$Version*.snupkg") { + if ($candidate.LastWriteTime -gt $newestSnupkgWrite) { + $newestSnupkgWrite = $candidate.LastWriteTime + $symbolsPackageFile = $candidate + } + } + } + + if ($symbolsPackageFile) { + $releaseArchiveInputs += $symbolsPackageFile.FullName + } + + return [pscustomobject]@{ + PackageFile = $packageFile + SymbolsPackageFile = $symbolsPackageFile + ReleaseArchiveInputs = $releaseArchiveInputs + } +} + +Export-ModuleMember -Function Resolve-DotNetPackageArtifacts diff --git a/utils/plugins/DotNet/DotNetDockerPush.psm1 b/utils/plugins/DotNet/DotNetDockerPush.psm1 deleted file mode 100644 index 6e6855a..0000000 --- a/utils/plugins/DotNet/DotNetDockerPush.psm1 +++ /dev/null @@ -1,245 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -<# -.SYNOPSIS - .NET Docker publish plugin — build and push container images for .NET apps. - -.DESCRIPTION - Logs in with credentials from a Base64-encoded username:password environment variable, - builds each configured image once, then tags and pushes: bare semver from DotNetReleaseVersion - (e.g. 3.3.4), v-prefixed alias (v3.3.4) when different, optional exact shared.tag if it differs, - and optional latest. - - Release image tags align with shared.version (same bare semver as Helm chart/OCI when used together); not from Chart.yaml. -#> - -if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { - $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" - if (Test-Path $pluginSupportModulePath -PathType Leaf) { - Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop - } -} - -function Get-RegistryCredentialsFromEnv { - param( - [Parameter(Mandatory = $true)] - [string]$EnvVarName - ) - - $raw = [Environment]::GetEnvironmentVariable($EnvVarName) - if ([string]::IsNullOrWhiteSpace($raw)) { - throw "Environment variable '$EnvVarName' is not set." - } - - try { - $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw)) - } - catch { - throw "Failed to decode '$EnvVarName' as Base64 (expected base64('username:password')): $($_.Exception.Message)" - } - - $parts = $decoded -split ':', 2 - if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) { - throw "Decoded '$EnvVarName' must be in the form 'username:password'." - } - - return @{ User = $parts[0]; Password = $parts[1] } -} - -function Set-EnvVersionValue { - param( - [Parameter(Mandatory = $true)] - [string]$FilePath, - - [Parameter(Mandatory = $true)] - [string]$Version - ) - - $content = Get-Content -LiteralPath $FilePath -Raw - if ($content -match '(?m)^\s*VITE_APP_VERSION\s*=') { - $content = $content -replace '(?m)^\s*VITE_APP_VERSION\s*=.*$', "VITE_APP_VERSION=$Version" - } - else { - $separator = if ($content -match "(\r?\n)$") { '' } else { [Environment]::NewLine } - $content = "$content${separator}VITE_APP_VERSION=$Version" - } - - Set-Content -LiteralPath $FilePath -Value $content -NoNewline -} - -function Invoke-Plugin { - param( - [Parameter(Mandatory = $true)] - $Settings - ) - - Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" - Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" - - $pluginSettings = $Settings - $shared = $Settings.context - - Assert-Command docker - - if ([string]::IsNullOrWhiteSpace($pluginSettings.registryUrl)) { - throw "DotNetDockerPush plugin requires 'registryUrl' (registry hostname, no scheme)." - } - - if ([string]::IsNullOrWhiteSpace($pluginSettings.credentialsEnvVar)) { - throw "DotNetDockerPush plugin requires 'credentialsEnvVar' (name of env var holding base64 username:password)." - } - - if ([string]::IsNullOrWhiteSpace($pluginSettings.projectName)) { - throw "DotNetDockerPush plugin requires 'projectName' (image path segment after registry)." - } - - if ([string]::IsNullOrWhiteSpace($pluginSettings.contextPath)) { - throw "DotNetDockerPush plugin requires 'contextPath' (Docker build context, relative to engines/release folder)." - } - - if (-not $pluginSettings.images -or @($pluginSettings.images).Count -eq 0) { - throw "DotNetDockerPush plugin requires a non-empty 'images' array with 'service' and 'dockerfile' per entry." - } - - $scriptDir = $shared.scriptDir - $contextPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$pluginSettings.contextPath))) - if (-not (Test-Path $contextPath -PathType Container)) { - throw "Docker context directory not found: $contextPath" - } - - $registryUrl = [string]$pluginSettings.registryUrl.TrimEnd('/') - $creds = Get-RegistryCredentialsFromEnv -EnvVarName ([string]$pluginSettings.credentialsEnvVar) - - $bareVersion = $null - if ($shared.PSObject.Properties.Name -contains 'version' -and -not [string]::IsNullOrWhiteSpace([string]$shared.version)) { - $bareVersion = ([string]$shared.version).Trim() -replace '^[vV]', '' - } - if ([string]::IsNullOrWhiteSpace($bareVersion) -and $shared.PSObject.Properties.Name -contains 'tag') { - $bareVersion = ([string]$shared.tag).Trim() -replace '^[vV]', '' - } - if ([string]::IsNullOrWhiteSpace($bareVersion)) { - throw "DotNetDockerPush: could not derive version tag (need shared.version from DotNetReleaseVersion or shared.tag)." - } - - $imageTags = New-Object System.Collections.Generic.List[string] - function Add-ImageTag([System.Collections.Generic.List[string]]$List, [string]$Tag) { - if ([string]::IsNullOrWhiteSpace($Tag)) { return } - if (-not $List.Contains($Tag)) { [void]$List.Add($Tag) } - } - Add-ImageTag $imageTags $bareVersion - Add-ImageTag $imageTags "v$bareVersion" - if ($shared.PSObject.Properties.Name -contains 'tag') { - Add-ImageTag $imageTags ([string]$shared.tag).Trim() - } - $pushLatest = if ($null -ne $pluginSettings.pushLatest) { [bool]$pluginSettings.pushLatest } else { $true } - if ($pushLatest) { - Add-ImageTag $imageTags 'latest' - } - - Write-Log -Level "STEP" -Message "Docker login to $registryUrl..." - $loginResult = $creds.Password | docker login $registryUrl -u $creds.User --password-stdin 2>&1 - if ($LASTEXITCODE -ne 0 -or ($loginResult -notmatch 'Login Succeeded')) { - throw "Docker login failed for ${registryUrl}: $loginResult" - } - - try { - foreach ($img in @($pluginSettings.images)) { - if ($null -eq $img.service -or $null -eq $img.dockerfile) { - throw "Each images[] entry must define 'service' and 'dockerfile'." - } - - $service = [string]$img.service - $dockerfileRel = [string]$img.dockerfile - - $imgContextPath = $contextPath - if ($img.PSObject.Properties.Name -contains 'contextPath' -and -not [string]::IsNullOrWhiteSpace([string]$img.contextPath)) { - $imgContextPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$img.contextPath))) - if (-not (Test-Path $imgContextPath -PathType Container)) { - throw "Docker context directory not found for image '$service': $imgContextPath" - } - } - - $dockerfilePath = [System.IO.Path]::GetFullPath((Join-Path $imgContextPath $dockerfileRel)) - if (-not (Test-Path $dockerfilePath -PathType Leaf)) { - throw "Dockerfile not found: $dockerfilePath" - } - $baseName = "$registryUrl/$($pluginSettings.projectName)/$service" - - $versionEnvFiles = @() - if ($img.PSObject.Properties.Name -contains 'versionEnvFiles' -and $null -ne $img.versionEnvFiles) { - foreach ($relativeEnvFile in @($img.versionEnvFiles)) { - if ([string]::IsNullOrWhiteSpace([string]$relativeEnvFile)) { - continue - } - - $envFilePath = [System.IO.Path]::GetFullPath((Join-Path $imgContextPath ([string]$relativeEnvFile))) - if (-not (Test-Path -LiteralPath $envFilePath -PathType Leaf)) { - throw "Configured versionEnvFiles entry not found: $envFilePath" - } - - $backupPath = "$envFilePath.repoutils.bak" - Copy-Item -LiteralPath $envFilePath -Destination $backupPath -Force - $versionEnvFiles += [pscustomobject]@{ - FilePath = $envFilePath - BackupPath = $backupPath - } - } - } - - try { - foreach ($envFile in $versionEnvFiles) { - Write-Log -Level "INFO" -Message "Temporarily setting VITE_APP_VERSION=$bareVersion in $($envFile.FilePath)" - Set-EnvVersionValue -FilePath $envFile.FilePath -Version $bareVersion - } - - $primaryRef = "${baseName}:$($imageTags[0])" - Write-Log -Level "STEP" -Message "Building $primaryRef ..." - docker build -t $primaryRef -f $dockerfilePath $imgContextPath - if ($LASTEXITCODE -ne 0) { - throw "Docker build failed for $primaryRef" - } - - Write-Log -Level "STEP" -Message "Pushing $primaryRef ..." - docker push $primaryRef - if ($LASTEXITCODE -ne 0) { - throw "Docker push failed for $primaryRef" - } - - for ($ti = 1; $ti -lt $imageTags.Count; $ti++) { - $aliasRef = "${baseName}:$($imageTags[$ti])" - Write-Log -Level "STEP" -Message "Tagging and pushing $aliasRef ..." - docker tag $primaryRef $aliasRef - if ($LASTEXITCODE -ne 0) { - throw "Docker tag failed: $primaryRef -> $aliasRef" - } - docker push $aliasRef - if ($LASTEXITCODE -ne 0) { - throw "Docker push failed for $aliasRef" - } - } - } - finally { - foreach ($envFile in $versionEnvFiles) { - if (Test-Path -LiteralPath $envFile.BackupPath -PathType Leaf) { - Move-Item -LiteralPath $envFile.BackupPath -Destination $envFile.FilePath -Force - } - } - foreach ($envFile in $versionEnvFiles) { - if (Test-Path -LiteralPath $envFile.BackupPath -PathType Leaf) { - Remove-Item -LiteralPath $envFile.BackupPath -Force -ErrorAction SilentlyContinue - } - } - } - } - } - finally { - docker logout $registryUrl 2>&1 | Out-Null - } - - Write-Log -Level "OK" -Message " Docker push completed." - $shared | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force -} - -Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/DotNet/DotNetHelmPush.psm1 b/utils/plugins/DotNet/DotNetHelmPush.psm1 deleted file mode 100644 index 44532f0..0000000 --- a/utils/plugins/DotNet/DotNetHelmPush.psm1 +++ /dev/null @@ -1,181 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -<# -.SYNOPSIS - .NET Helm publish plugin — package and push charts versioned from DotNetReleaseVersion. - -.DESCRIPTION - The chart in the repo should keep placeholder version and appVersion (e.g. 0.0.0); this plugin - overwrites them with the bare semver from shared context (DotNetReleaseVersion / shared.version, - e.g. 3.3.4 — no leading v), falling back to stripping v/V from shared.tag if version is missing, - then runs helm package and helm push, then restores Chart.yaml. - - Optional pushLatest (default false when omitted): when true, after the versioned push, copies the chart - to a :latest tag in the same OCI repository using the oras CLI (https://oras.land). Requires oras on PATH. -#> - -if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { - $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" - if (Test-Path $pluginSupportModulePath -PathType Leaf) { - Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop - } -} - -function Get-RegistryCredentialsFromEnv { - param( - [Parameter(Mandatory = $true)] - [string]$EnvVarName - ) - - $raw = [Environment]::GetEnvironmentVariable($EnvVarName) - if ([string]::IsNullOrWhiteSpace($raw)) { - throw "Environment variable '$EnvVarName' is not set." - } - - try { - $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw)) - } - catch { - throw "Failed to decode '$EnvVarName' as Base64 (expected base64('username:password')): $($_.Exception.Message)" - } - - $parts = $decoded -split ':', 2 - if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) { - throw "Decoded '$EnvVarName' must be in the form 'username:password'." - } - - return @{ User = $parts[0]; Password = $parts[1] } -} - -function Invoke-Plugin { - param( - [Parameter(Mandatory = $true)] - $Settings - ) - - Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" - Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" - - $pluginSettings = $Settings - $shared = $Settings.context - - Assert-Command helm - - $pushLatest = if ($null -ne $pluginSettings.pushLatest) { [bool]$pluginSettings.pushLatest } else { $false } - - if ([string]::IsNullOrWhiteSpace($pluginSettings.chartPath)) { - throw "DotNetHelmPush plugin requires 'chartPath' (chart directory, relative to engines/release folder)." - } - - if ([string]::IsNullOrWhiteSpace($pluginSettings.ociRepository)) { - throw "DotNetHelmPush plugin requires 'ociRepository' (e.g. oci://cr.maks-it.com/charts)." - } - - if ([string]::IsNullOrWhiteSpace($pluginSettings.credentialsEnvVar)) { - throw "DotNetHelmPush plugin requires 'credentialsEnvVar' (name of env var holding base64 username:password)." - } - - $scriptDir = $shared.ScriptDir - $chartDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$pluginSettings.chartPath))) - $chartYaml = Join-Path $chartDir 'Chart.yaml' - - if (-not (Test-Path $chartYaml -PathType Leaf)) { - throw "Chart.yaml not found at: $chartYaml" - } - - $chartVersion = $null - if ($shared.PSObject.Properties.Name -contains 'version' -and -not [string]::IsNullOrWhiteSpace([string]$shared.version)) { - $chartVersion = ([string]$shared.version).Trim() -replace '^[vV]', '' - } - if ([string]::IsNullOrWhiteSpace($chartVersion) -and $shared.PSObject.Properties.Name -contains 'tag') { - $chartVersion = ([string]$shared.tag).Trim() -replace '^[vV]', '' - } - if ([string]::IsNullOrWhiteSpace($chartVersion)) { - throw "Could not derive chart version: need shared.version (DotNetReleaseVersion) or shared.tag (e.g. v3.3.4)." - } - - $creds = Get-RegistryCredentialsFromEnv -EnvVarName ([string]$pluginSettings.credentialsEnvVar) - $ociRepository = [string]$pluginSettings.ociRepository.TrimEnd('/') - - $chartNameLine = Select-String -LiteralPath $chartYaml -Pattern '^\s*name:\s*(.+)\s*$' | Select-Object -First 1 - if (-not $chartNameLine -or $chartNameLine.Matches.Count -lt 1) { - throw "Could not read chart name from Chart.yaml." - } - $chartName = $chartNameLine.Matches[0].Groups[1].Value.Trim() - - $backupPath = "$chartYaml.bak" - Copy-Item -LiteralPath $chartYaml -Destination $backupPath -Force - - try { - $content = Get-Content -LiteralPath $chartYaml -Raw - $content = $content ` - -replace '(?m)^\s*version:\s*.*$', "version: $chartVersion" ` - -replace '(?m)^\s*appVersion:\s*.*$', "appVersion: `"$chartVersion`"" - Set-Content -LiteralPath $chartYaml -Value $content - - Write-Log -Level "STEP" -Message "Linting Helm chart at $chartDir ..." - helm lint $chartDir - if ($LASTEXITCODE -ne 0) { - throw "helm lint failed." - } - - $packageDest = $scriptDir - Write-Log -Level "STEP" -Message "Packaging Helm chart..." - $packageOutput = helm package $chartDir --destination $packageDest 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { - throw "helm package failed. Output: $packageOutput" - } - - $chartPackage = Join-Path $packageDest "$chartName-$chartVersion.tgz" - if (-not (Test-Path -LiteralPath $chartPackage -PathType Leaf)) { - throw "Expected chart package not found: $chartPackage (helm output: $packageOutput)" - } - - Write-Log -Level "STEP" -Message "Pushing $chartPackage to $ociRepository ..." - helm push $chartPackage $ociRepository --username $creds.User --password $creds.Password - if ($LASTEXITCODE -ne 0) { - throw "helm push failed." - } - - if ($pushLatest) { - Assert-Command oras - if ($ociRepository -notmatch '^oci://([^/]+)') { - throw "Could not parse registry host from ociRepository: $ociRepository" - } - $registryHost = $Matches[1] - $baseRef = "$($ociRepository.TrimEnd('/'))/$chartName" - $srcRef = "${baseRef}:$chartVersion" - $dstRef = "${baseRef}:latest" - - Write-Log -Level "STEP" -Message "Tagging chart as latest (oras copy)..." - Write-Log -Level "INFO" -Message " $srcRef -> $dstRef" - - $loginOut = $creds.Password | & oras login $registryHost -u $creds.User --password-stdin 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "oras login failed for ${registryHost}: $loginOut" - } - - $copyOut = & oras copy $srcRef $dstRef 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "oras copy failed: $copyOut" - } - - & oras logout $registryHost 2>&1 | Out-Null - Write-Log -Level "OK" -Message " Chart latest tag pushed." - } - - Remove-Item -LiteralPath $chartPackage -Force -ErrorAction SilentlyContinue - Write-Log -Level "OK" -Message " Helm chart push completed." - } - finally { - if (Test-Path -LiteralPath $backupPath -PathType Leaf) { - Move-Item -LiteralPath $backupPath -Destination $chartYaml -Force - } - } - - $shared | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force -} - -Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/DotNet/DotNetNuGet.psm1 b/utils/plugins/DotNet/DotNetNuGet.psm1 index ecb2eb0..535fae4 100644 --- a/utils/plugins/DotNet/DotNetNuGet.psm1 +++ b/utils/plugins/DotNet/DotNetNuGet.psm1 @@ -29,22 +29,36 @@ function Invoke-Plugin { $pluginSettings = $Settings $sharedSettings = $Settings.context - $nugetApiKeyEnvVar = $pluginSettings.nugetApiKey + $nugetSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'nugetSecret' $packageFile = $sharedSettings.packageFile + $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings + Assert-Command dotnet if (-not $packageFile) { throw "DotNetNuGet plugin requires a NuGet package artifact. Ensure DotNetPack produced a .nupkg before running DotNetNuGet." } - if ([string]::IsNullOrWhiteSpace($nugetApiKeyEnvVar)) { - throw "DotNetNuGet plugin requires 'nugetApiKey' in scriptSettings.json." + if ($dryRun) { + $nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) { + "https://api.nuget.org/v3/index.json" + } + else { + $pluginSettings.source + } + + Write-Log -Level "INFO" -Message "Dry run: would push $($packageFile.FullName) to $nugetSource" + return } - $nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar) - if ([string]::IsNullOrWhiteSpace($nugetApiKey)) { - throw "NuGet API key is not set. Set '$nugetApiKeyEnvVar' and rerun." + if ([string]::IsNullOrWhiteSpace($nugetSecret)) { + throw "DotNetNuGet plugin requires 'nugetSecret' in scriptSettings.json (logical secret name, e.g. NuGet)." + } + + $nugetKey = Get-SecretEnvironmentValue -Name $nugetSecret + if ([string]::IsNullOrWhiteSpace($nugetKey)) { + throw "NuGet API key is not set. Set environment variable '$nugetSecret'." } $nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) { @@ -55,7 +69,7 @@ function Invoke-Plugin { } Write-Log -Level "STEP" -Message "Pushing package to NuGet feed..." - dotnet nuget push $packageFile.FullName -k $nugetApiKey -s $nugetSource --skip-duplicate + dotnet nuget push $packageFile.FullName -k $nugetKey -s $nugetSource --skip-duplicate if ($LASTEXITCODE -ne 0) { throw "Failed to push the package to NuGet feed." @@ -65,7 +79,11 @@ function Invoke-Plugin { $sharedSettings | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force } -Export-ModuleMember -Function Invoke-Plugin +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/DotNet/DotNetTest.psm1 b/utils/plugins/DotNet/DotNetTest.psm1 index e888a4e..b8bd09d 100644 --- a/utils/plugins/DotNet/DotNetTest.psm1 +++ b/utils/plugins/DotNet/DotNetTest.psm1 @@ -10,8 +10,9 @@ via TestRunner, then publishes metrics on the shared engine context for any later plugin: `qualityLineCoverage`, `testResult`, `coverageLineRate` / `coverageBranchRate` / `coverageMethodRate`, method counts, `testResultsDirectory`, `coverageCoberturaPaths`. Quality gates read - those keys generically (not tied to this plugin by name). Cobertura files are removed - after parsing unless TestRunner gains KeepResults. + those keys generically (not tied to this plugin by name). When `resultsDir` (or the + multi-project default TestResults folder) is used, Cobertura output is kept on disk + via TestRunner `-KeepResults` so repo-root `test-results/` persists after the run. #> if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { @@ -37,75 +38,15 @@ function Invoke-Plugin { $testResultsDirSetting = $pluginSettings.resultsDir $scriptDir = $sharedSettings.scriptDir - function Resolve-PluginPath { - param( - [Parameter(Mandatory = $true)] - [string]$ConfiguredPath, - - [Parameter(Mandatory = $true)] - [string]$PrimaryBasePath, - - [Parameter(Mandatory = $false)] - [string[]]$FallbackBasePaths - ) - - $trimmedPath = $ConfiguredPath.Trim() - if ([string]::IsNullOrWhiteSpace($trimmedPath)) { - return $null - } - - if ([System.IO.Path]::IsPathRooted($trimmedPath)) { - return [System.IO.Path]::GetFullPath($trimmedPath) - } - - $candidateBases = [System.Collections.Generic.List[string]]::new() - [void]$candidateBases.Add($PrimaryBasePath) - foreach ($fallbackBase in @($FallbackBasePaths)) { - if (-not [string]::IsNullOrWhiteSpace($fallbackBase) -and $candidateBases -notcontains $fallbackBase) { - [void]$candidateBases.Add($fallbackBase) - } - } - - foreach ($candidateBase in $candidateBases) { - $candidatePath = [System.IO.Path]::GetFullPath((Join-Path $candidateBase $trimmedPath)) - if (Test-Path $candidatePath) { - return $candidatePath - } - } - - # Preserve backward-compatible behavior when no fallback path exists. - return [System.IO.Path]::GetFullPath((Join-Path $PrimaryBasePath $trimmedPath)) - } - - $fallbackBasePaths = @() - if ($sharedSettings.PSObject.Properties.Name -contains 'srcDir' -and $sharedSettings.srcDir) { - $fallbackBasePaths += [string]$sharedSettings.srcDir - try { - $repoRoot = Split-Path -Parent ([string]$sharedSettings.srcDir) - if (-not [string]::IsNullOrWhiteSpace($repoRoot)) { - $fallbackBasePaths += $repoRoot - } - } - catch { - # Ignore invalid fallback roots and keep primary behavior. - } - } - $testProjectPaths = [System.Collections.Generic.List[string]]::new() if ($pluginSettings.PSObject.Properties.Name -contains 'projects' -and $pluginSettings.projects) { foreach ($rel in @($pluginSettings.projects)) { if ([string]::IsNullOrWhiteSpace([string]$rel)) { continue } - $resolvedPath = Resolve-PluginPath -ConfiguredPath ([string]$rel) -PrimaryBasePath $scriptDir -FallbackBasePaths $fallbackBasePaths - if ($resolvedPath) { - $testProjectPaths.Add($resolvedPath) - } + $testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $scriptDir $rel.Trim()))) } } if ($testProjectPaths.Count -eq 0 -and $pluginSettings.project) { - $resolvedPath = Resolve-PluginPath -ConfiguredPath ([string]$pluginSettings.project) -PrimaryBasePath $scriptDir -FallbackBasePaths $fallbackBasePaths - if ($resolvedPath) { - $testProjectPaths.Add($resolvedPath) - } + $testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $scriptDir $pluginSettings.project))) } if ($testProjectPaths.Count -eq 0) { throw "DotNetTest plugin requires 'project' or 'projects' in scriptSettings.json." @@ -128,6 +69,7 @@ function Invoke-Plugin { } if ($testResultsDir) { $invokeTestParams.ResultsDirectory = $testResultsDir + $invokeTestParams.KeepResults = $true } $testResult = Invoke-TestsWithCoverage @invokeTestParams @@ -136,19 +78,8 @@ function Invoke-Plugin { throw "Tests failed. $($testResult.Error)" } - $sharedSettings | Add-Member -NotePropertyName testResult -NotePropertyValue $testResult -Force - $sharedSettings | Add-Member -NotePropertyName qualityLineCoverage -NotePropertyValue $testResult.LineRate -Force - $sharedSettings | Add-Member -NotePropertyName coverageLineRate -NotePropertyValue $testResult.LineRate -Force - $sharedSettings | Add-Member -NotePropertyName coverageBranchRate -NotePropertyValue $testResult.BranchRate -Force - $sharedSettings | Add-Member -NotePropertyName coverageMethodRate -NotePropertyValue $testResult.MethodRate -Force - $sharedSettings | Add-Member -NotePropertyName coverageTotalMethods -NotePropertyValue $testResult.TotalMethods -Force - $sharedSettings | Add-Member -NotePropertyName coverageCoveredMethods -NotePropertyValue $testResult.CoveredMethods -Force - if (($testResult.PSObject.Properties.Name -contains 'ResultsDirectory') -and $testResult.ResultsDirectory) { - $sharedSettings | Add-Member -NotePropertyName testResultsDirectory -NotePropertyValue $testResult.ResultsDirectory -Force - } - if ($testResult.CoverageFiles) { - $sharedSettings | Add-Member -NotePropertyName coverageCoberturaPaths -NotePropertyValue @($testResult.CoverageFiles) -Force - } + Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Publish-CoverageMetricsToSharedContext" + Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult Write-Log -Level "OK" -Message " All tests passed!" Write-Log -Level "INFO" -Message " Line Coverage: $($testResult.LineRate)%" diff --git a/utils/plugins/Npm/NpmPack.psm1 b/utils/plugins/Npm/NpmPack.psm1 new file mode 100644 index 0000000..f8350ba --- /dev/null +++ b/utils/plugins/Npm/NpmPack.psm1 @@ -0,0 +1,137 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Packs npm workspace packages into .tgz release artifacts. + +.DESCRIPTION + Runs npm pack for each configured workspace package and publishes the + resulting tarball paths into shared release context (releaseAssetPaths) + for the GitHub release plugin. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths" + + $pluginSettings = $Settings + $shared = $Settings.context + + Assert-Command npm + + $workspaceRoot = $null + if ($pluginSettings.workspaceRoot) { + $workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir) + $workspaceRoot = $workspaceRoots[0] + } + elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) { + $workspaceRoot = [string]$shared.npmWorkspaceRoot + } + else { + throw "NpmPack plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run." + } + + $artifactsDirectory = $null + if ($pluginSettings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.artifactsDir)) { + $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $shared.scriptDir ([string]$pluginSettings.artifactsDir))) + } + elseif ($shared.PSObject.Properties['artifactsDirectory'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.artifactsDirectory)) { + $artifactsDirectory = [string]$shared.artifactsDirectory + } + else { + throw "NpmPack plugin requires release-stage artifactsDirectory." + } + + $packOrder = @() + if ($pluginSettings.publishOrder) { + if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) { + $packOrder = @($pluginSettings.publishOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.publishOrder)) { + $packOrder = @([string]$pluginSettings.publishOrder) + } + } + elseif ($pluginSettings.packOrder) { + if ($pluginSettings.packOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.packOrder -is [string])) { + $packOrder = @($pluginSettings.packOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.packOrder)) { + $packOrder = @([string]$pluginSettings.packOrder) + } + } + + if ($packOrder.Count -eq 0) { + throw "NpmPack plugin requires non-empty 'publishOrder' or 'packOrder' (workspace package names)." + } + + Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force + $useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot + if (-not $useWorkspaces -and $packOrder.Count -gt 1) { + throw "NpmPack plugin requires npm workspaces when packing more than one package." + } + + if (-not (Test-Path $artifactsDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null + } + + $releaseAssetPaths = @() + + Push-Location $workspaceRoot + try { + foreach ($packageName in $packOrder) { + Write-Log -Level "STEP" -Message "Packing npm package '$packageName'..." + if ($useWorkspaces) { + $tarballName = (npm pack -w $packageName --pack-destination $artifactsDirectory 2>$null | Select-Object -Last 1) + } + else { + Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName + $tarballName = (npm pack --pack-destination $artifactsDirectory 2>$null | Select-Object -Last 1) + } + + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace([string]$tarballName)) { + throw "npm pack failed for '$packageName'." + } + + $tarballPath = Join-Path $artifactsDirectory ([string]$tarballName).Trim() + if (-not (Test-Path $tarballPath -PathType Leaf)) { + throw "Could not locate pack output for '$packageName' at: $tarballPath" + } + + $strayTarballPath = Join-Path $workspaceRoot ([string]$tarballName).Trim() + if ((Test-Path $strayTarballPath -PathType Leaf) -and $strayTarballPath -ne $tarballPath) { + Remove-Item -LiteralPath $strayTarballPath -Force -ErrorAction SilentlyContinue + } + + Write-Log -Level "OK" -Message " Package ready: $tarballPath" + $releaseAssetPaths += $tarballPath + } + } + finally { + Pop-Location + } + + $shared | Add-Member -NotePropertyName releaseDir -NotePropertyValue $artifactsDirectory -Force + $shared | Add-Member -NotePropertyName releaseAssetPaths -NotePropertyValue $releaseAssetPaths -Force + if ($releaseAssetPaths.Count -gt 0) { + $shared | Add-Member -NotePropertyName packageFile -NotePropertyValue (Get-Item -LiteralPath $releaseAssetPaths[0]) -Force + } + + Write-Log -Level "OK" -Message " npm pack completed ($($releaseAssetPaths.Count) tarball(s))." +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/Npm/NpmPackageSupport.psm1 b/utils/plugins/Npm/NpmPackageSupport.psm1 new file mode 100644 index 0000000..4b3a903 --- /dev/null +++ b/utils/plugins/Npm/NpmPackageSupport.psm1 @@ -0,0 +1,62 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +function Get-NpmRootPackageJson { + param( + [Parameter(Mandatory = $true)] + [string]$WorkspaceRoot + ) + + $packageJsonPath = Join-Path $WorkspaceRoot 'package.json' + if (-not (Test-Path $packageJsonPath -PathType Leaf)) { + throw "package.json not found at '$packageJsonPath'." + } + + return Get-Content -Path $packageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json +} + +function Test-NpmWorkspacesConfigured { + param( + [Parameter(Mandatory = $true)] + [string]$WorkspaceRoot + ) + + $json = Get-NpmRootPackageJson -WorkspaceRoot $WorkspaceRoot + if (-not $json.PSObject.Properties['workspaces'] -or $null -eq $json.workspaces) { + return $false + } + + $workspaces = $json.workspaces + if ($workspaces -is [string]) { + return -not [string]::IsNullOrWhiteSpace($workspaces) + } + + if ($workspaces -is [System.Collections.IEnumerable]) { + $entries = @($workspaces | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + return $entries.Count -gt 0 + } + + return $false +} + +function Assert-NpmRootPackageName { + param( + [Parameter(Mandatory = $true)] + [string]$WorkspaceRoot, + + [Parameter(Mandatory = $true)] + [string]$ExpectedPackageName + ) + + $json = Get-NpmRootPackageJson -WorkspaceRoot $WorkspaceRoot + $rootPackageName = [string]$json.name + if ([string]::IsNullOrWhiteSpace($rootPackageName)) { + throw "Root package.json at '$WorkspaceRoot' is missing 'name'." + } + + if ($rootPackageName -ne $ExpectedPackageName) { + throw "publishOrder package '$ExpectedPackageName' does not match root package name '$rootPackageName' (npm workspaces not configured)." + } +} + +Export-ModuleMember -Function Test-NpmWorkspacesConfigured, Assert-NpmRootPackageName, Get-NpmRootPackageJson diff --git a/utils/plugins/Npm/NpmPublish.psm1 b/utils/plugins/Npm/NpmPublish.psm1 index 7ff8259..146fe85 100644 --- a/utils/plugins/Npm/NpmPublish.psm1 +++ b/utils/plugins/Npm/NpmPublish.psm1 @@ -6,9 +6,9 @@ Publishes npm workspace packages to the npm registry. .DESCRIPTION - Publishes packages in configured order using an API key from an environment - variable (for example NPMJS_MAKS_IT). Uses a temporary .npmrc in the - workspace root for auth and supports --skip-duplicate semantics via npm. + Publishes packages in configured order using npmSecret (logical secret name). + Pass the token via an environment variable named like the configured npm secret (e.g. Npm). + Uses a temporary .npmrc in the workspace root. #> if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { @@ -32,18 +32,10 @@ function Invoke-Plugin { $pluginSettings = $Settings $shared = $Settings.context + $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $shared + Assert-Command npm - $npmApiKeyEnvVar = $pluginSettings.npmApiKey - if ([string]::IsNullOrWhiteSpace($npmApiKeyEnvVar)) { - throw "NpmPublish plugin requires 'npmApiKey' in scriptSettings.json (environment variable name)." - } - - $npmApiKey = [System.Environment]::GetEnvironmentVariable($npmApiKeyEnvVar) - if ([string]::IsNullOrWhiteSpace($npmApiKey)) { - throw "npm API key is not set. Set '$npmApiKeyEnvVar' and rerun." - } - $workspaceRoot = $null if ($pluginSettings.workspaceRoot) { $workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir) @@ -84,11 +76,34 @@ function Invoke-Plugin { throw "NpmPublish plugin requires non-empty 'publishOrder' (workspace package names)." } + Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force + $useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot + if (-not $useWorkspaces -and $publishOrder.Count -gt 1) { + throw "NpmPublish plugin requires npm workspaces when publishing more than one package." + } + + if ($dryRun) { + foreach ($packageName in $publishOrder) { + Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry" + } + return + } + + $npmSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'npmSecret' + if ([string]::IsNullOrWhiteSpace($npmSecret)) { + throw "NpmPublish plugin requires 'npmSecret' in scriptSettings.json (logical secret name, e.g. Npm)." + } + + $npmToken = Get-SecretEnvironmentValue -Name $npmSecret + if ([string]::IsNullOrWhiteSpace($npmToken)) { + throw "npm API key is not set. Set environment variable '$npmSecret'." + } + $registryHost = ([uri]$registry).Host $tempNpmRcPath = Join-Path $workspaceRoot ".npmrc.release-temp" $npmRcContent = @" registry=$registry -//$registryHost/:_authToken=$npmApiKey +//$registryHost/:_authToken=$npmToken "@ Push-Location $workspaceRoot @@ -97,7 +112,14 @@ registry=$registry foreach ($packageName in $publishOrder) { Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..." - npm publish -w $packageName --access $access --userconfig $tempNpmRcPath + if ($useWorkspaces) { + npm publish -w $packageName --access $access --userconfig $tempNpmRcPath + } + else { + Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName + npm publish --access $access --userconfig $tempNpmRcPath + } + if ($LASTEXITCODE -ne 0) { throw "Failed to publish npm package '$packageName'." } @@ -115,4 +137,8 @@ registry=$registry } } -Export-ModuleMember -Function Invoke-Plugin +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/Platform/CleanupArtifacts.psm1 b/utils/plugins/Platform/CleanupArtifacts.psm1 new file mode 100644 index 0000000..ab145fa --- /dev/null +++ b/utils/plugins/Platform/CleanupArtifacts.psm1 @@ -0,0 +1,122 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Artifact cleanup plugin — remove files from the artifacts directory after release. + +.DESCRIPTION + Removes files from the configured artifacts directory using glob patterns. + When includePatterns is omitted, defaults to NuGet outputs (*.nupkg, *.snupkg). + Typically placed at the end of the Release stage after archive or publish plugins. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Get-CleanupPatternsInternal { + param( + [Parameter(Mandatory = $false)] + $ConfiguredPatterns + ) + + if ($null -eq $ConfiguredPatterns) { + return @('*.nupkg', '*.snupkg') + } + + if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) { + return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + + if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) { + return @('*.nupkg', '*.snupkg') + } + + return @([string]$ConfiguredPatterns) +} + +function Get-ExcludePatternsInternal { + param( + [Parameter(Mandatory = $false)] + $ConfiguredPatterns + ) + + if ($null -eq $ConfiguredPatterns) { + return @() + } + + if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) { + return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + + if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) { + return @() + } + + return @([string]$ConfiguredPatterns) +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + + $pluginSettings = $Settings + $sharedSettings = $Settings.context + $artifactsDirectory = $sharedSettings.artifactsDirectory + $patterns = Get-CleanupPatternsInternal -ConfiguredPatterns $pluginSettings.includePatterns + $excludePatterns = Get-ExcludePatternsInternal -ConfiguredPatterns $pluginSettings.excludePatterns + + if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) { + throw "CleanupArtifacts plugin requires an artifacts directory in the shared context." + } + + if (-not (Test-Path $artifactsDirectory -PathType Container)) { + Write-Log -Level "WARN" -Message " Artifacts directory not found: $artifactsDirectory" + return + } + + Write-Log -Level "STEP" -Message "Cleaning generated artifacts..." + + $itemsToRemove = @() + foreach ($pattern in $patterns) { + $matchedItems = @( + Get-ChildItem -Path $artifactsDirectory -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like $pattern } + ) + + if ($excludePatterns.Count -gt 0) { + $matchedItems = @( + $matchedItems | + Where-Object { + $item = $_ + -not ($excludePatterns | Where-Object { $item.Name -like $_ } | Select-Object -First 1) + } + ) + } + + $itemsToRemove += @($matchedItems) + } + + $itemsToRemove = @($itemsToRemove | Sort-Object FullName -Unique) + + if ($itemsToRemove.Count -eq 0) { + Write-Log -Level "INFO" -Message " No artifacts matched cleanup rules." + return + } + + foreach ($item in $itemsToRemove) { + Remove-Item -Path $item.FullName -Recurse -Force -ErrorAction SilentlyContinue + Write-Log -Level "OK" -Message " Removed: $($item.Name)" + } +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/Platform/CoverageBadges.psm1 b/utils/plugins/Platform/CoverageBadges.psm1 index 36ba26a..2187b9c 100644 --- a/utils/plugins/Platform/CoverageBadges.psm1 +++ b/utils/plugins/Platform/CoverageBadges.psm1 @@ -6,7 +6,11 @@ Coverage badge plugin for the test engine. .DESCRIPTION - Reads line/branch/method coverage from shared engine context and writes SVG badges. + Reads line/branch/method coverage from shared engine context. + + badgeFormat "svg" (default): writes SVG files under badgesDir. + badgeFormat "shields": updates readmePath (string or array) with img.shields.io markdown + (no local assets). #> if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { @@ -80,6 +84,80 @@ function New-BadgeSvgInternal { "@ } +function New-ShieldsIoBadgeUrlInternal { + param( + [Parameter(Mandatory = $true)] + [string]$Label, + + [Parameter(Mandatory = $true)] + [double]$Percentage, + + [Parameter(Mandatory = $true)] + [string]$Color + ) + + $labelToken = ($Label -replace ' ', '%20') + $valueToken = "$Percentage%25" + return "https://img.shields.io/badge/$labelToken-$valueToken-$Color" +} + +function New-ShieldsIoBadgeMarkdownInternal { + param( + [Parameter(Mandatory = $true)] + [string]$Label, + + [Parameter(Mandatory = $true)] + [double]$Percentage, + + [Parameter(Mandatory = $true)] + [string]$Color + ) + + $url = New-ShieldsIoBadgeUrlInternal -Label $Label -Percentage $Percentage -Color $Color + return "![$Label]($url)" +} + +function Update-ReadmeShieldsBadgesInternal { + param( + [Parameter(Mandatory = $true)] + [string]$ReadmePath, + + [Parameter(Mandatory = $true)] + [object[]]$Badges, + + [Parameter(Mandatory = $true)] + [hashtable]$Metrics, + + [Parameter(Mandatory = $true)] + [psobject]$Thresholds + ) + + if (-not (Test-Path -LiteralPath $ReadmePath -PathType Leaf)) { + throw "CoverageBadges readmePath not found: $ReadmePath" + } + + $content = Get-Content -LiteralPath $ReadmePath -Raw -Encoding UTF8 + + foreach ($badge in @($Badges)) { + $metricValue = $Metrics[[string]$badge.metric] + if ($null -eq $metricValue) { + throw "Unknown or missing coverage metric '$($badge.metric)' for badge label '$($badge.label)'." + } + + $color = Get-BadgeColorInternal -percentage $metricValue -thresholds $Thresholds + $markdown = New-ShieldsIoBadgeMarkdownInternal -Label $badge.label -Percentage $metricValue -Color $color + # Horizontal whitespace only; optional CR for CRLF. Do not let \s* eat the following blank line. + $pattern = "(?m)^!\[$([regex]::Escape([string]$badge.label))\]\([^)]*\)[^\S\r\n]*\r?$" + if ($content -notmatch $pattern) { + throw "README badge line not found for label '$($badge.label)' in: $ReadmePath" + } + + $content = [regex]::Replace($content, $pattern, $markdown) + } + + $content | Out-File -LiteralPath $ReadmePath -Encoding utf8NoBOM -NoNewline +} + function Get-CoverageMetricsFromSharedContext { param( [Parameter(Mandatory = $true)] @@ -131,17 +209,9 @@ function Invoke-Plugin { $scriptDir = $sharedSettings.scriptDir $metrics = Get-CoverageMetricsFromSharedContext -Shared $sharedSettings - $badgesDir = $sharedSettings.badgesDir - if ($pluginSettings.badgesDir) { - $badgesDirs = @(Resolve-RelativePaths -Value $pluginSettings.badgesDir -BasePath $scriptDir) - $badgesDir = $badgesDirs[0] - } - if ([string]::IsNullOrWhiteSpace([string]$badgesDir)) { - throw "CoverageBadges requires badgesDir in plugin settings or paths.badgesDir in scriptSettings.json." - } - - if (-not (Test-Path $badgesDir)) { - New-Item -ItemType Directory -Path $badgesDir | Out-Null + $badgeFormat = 'svg' + if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.badgeFormat)) { + $badgeFormat = [string]$pluginSettings.badgeFormat } $thresholds = $pluginSettings.colorThresholds @@ -158,10 +228,56 @@ function Invoke-Plugin { Write-Log -Level "STEP" -Message "Generating coverage badges..." + if ($badgeFormat -eq 'shields') { + $readmePathSetting = $null + if ($sharedSettings.PSObject.Properties.Name -contains 'readmePath' -and $sharedSettings.readmePath) { + $readmePathSetting = $sharedSettings.readmePath + } + if ($pluginSettings.PSObject.Properties.Name -contains 'readmePath' -and $pluginSettings.readmePath) { + $readmePathSetting = $pluginSettings.readmePath + } + + $readmePaths = @() + if ($null -ne $readmePathSetting) { + $readmePaths = @(Resolve-RelativePaths -Value $readmePathSetting -BasePath $scriptDir) + } + if ($readmePaths.Count -eq 0) { + throw "CoverageBadges badgeFormat 'shields' requires readmePath in plugin settings or paths.readmePath in scriptSettings.json." + } + + foreach ($readmePath in $readmePaths) { + Update-ReadmeShieldsBadgesInternal -ReadmePath $readmePath -Badges @($pluginSettings.badges) -Metrics $metrics -Thresholds $thresholds + Write-Log -Level "OK" -Message "README shields updated: $readmePath" + } + + foreach ($badge in @($pluginSettings.badges)) { + $metricValue = $metrics[[string]$badge.metric] + $color = Get-BadgeColorInternal -percentage $metricValue -thresholds $thresholds + Write-Log -Level "OK" -Message "$($badge.label): $metricValue% ($color)" + } + + Write-Log -Level "STEP" -Message "Commit README.md to publish badge URLs." + return + } + + $badgesDir = $sharedSettings.badgesDir + if ($pluginSettings.badgesDir) { + $badgesDirs = @(Resolve-RelativePaths -Value $pluginSettings.badgesDir -BasePath $scriptDir) + $badgesDir = $badgesDirs[0] + } + if ([string]::IsNullOrWhiteSpace([string]$badgesDir)) { + throw "CoverageBadges requires badgesDir in plugin settings or paths.badgesDir in scriptSettings.json." + } + + if (-not (Test-Path $badgesDir)) { + New-Item -ItemType Directory -Path $badgesDir | Out-Null + } + foreach ($badge in @($pluginSettings.badges)) { $metricValue = $metrics[[string]$badge.metric] if ($null -eq $metricValue) { - throw "Unknown or missing coverage metric '$($badge.metric)' for badge '$($badge.name)'." + $badgeName = if ($badge.PSObject.Properties.Name -contains 'name') { $badge.name } else { $badge.label } + throw "Unknown or missing coverage metric '$($badge.metric)' for badge '$badgeName'." } $color = Get-BadgeColorInternal -percentage $metricValue -thresholds $thresholds diff --git a/utils/plugins/Platform/FileReleaseVersion.psm1 b/utils/plugins/Platform/FileReleaseVersion.psm1 new file mode 100644 index 0000000..e0b7457 --- /dev/null +++ b/utils/plugins/Platform/FileReleaseVersion.psm1 @@ -0,0 +1,41 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Loads release version from a repo-root VERSION file into shared context. + +.DESCRIPTION + Reads a single-line semver from the configured versionFilePath (default repo-root VERSION). + Used by container-only repos without .csproj or package.json. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-FileReleaseVersion" + + $shared = $Settings.context + $resolved = Resolve-FileReleaseVersion -Plugins @($Settings) -ScriptDir $shared.scriptDir + $versionFilePaths = @(Resolve-RelativePaths -Value $Settings.versionFilePath -BasePath $shared.scriptDir) + + $shared | Add-Member -NotePropertyName version -NotePropertyValue $resolved.version -Force + if ($versionFilePaths.Count -gt 0) { + $shared | Add-Member -NotePropertyName versionFilePath -NotePropertyValue $versionFilePaths[0] -Force + } + Write-Log -Level "OK" -Message " Release version loaded by FileReleaseVersion plugin: $($shared.version)" +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/Platform/GitHub.psm1 b/utils/plugins/Platform/GitHub.psm1 index 9af816c..61773e0 100644 --- a/utils/plugins/Platform/GitHub.psm1 +++ b/utils/plugins/Platform/GitHub.psm1 @@ -95,10 +95,11 @@ function Invoke-Plugin { Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact" $pluginSettings = $Settings $sharedSettings = $Settings.context - $githubTokenEnvVar = $pluginSettings.githubToken + $githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret' $configuredRepository = $pluginSettings.repository $releaseNotesFileSetting = $pluginSettings.releaseNotesFile $releaseTitlePatternSetting = $pluginSettings.releaseTitlePattern @@ -108,15 +109,37 @@ function Invoke-Plugin { $releaseDir = $sharedSettings.releaseDir $releaseAssetPaths = @() - Assert-Command gh + $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings - if ([string]::IsNullOrWhiteSpace($githubTokenEnvVar)) { - throw "GitHub plugin requires 'githubToken' in scriptSettings.json." + if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) { + throw "GitHub plugin requires 'releaseNotesFile' in scriptSettings.json." } - $githubToken = [System.Environment]::GetEnvironmentVariable($githubTokenEnvVar) - if ([string]::IsNullOrWhiteSpace($githubToken)) { - throw "GitHub token is not set. Set '$githubTokenEnvVar' and rerun." + $releaseNotesFile = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $releaseNotesFileSetting)) + $releaseNotes = Get-ReleaseNotesInternal -ReleaseNotesFile $releaseNotesFile -Version $version + + if ($dryRun) { + $repo = Get-GitHubRepositoryInternal -ConfiguredRepository $configuredRepository + $releaseTitlePattern = if ([string]::IsNullOrWhiteSpace($releaseTitlePatternSetting)) { + "Release {version}" + } + else { + $releaseTitlePatternSetting + } + $releaseName = $releaseTitlePattern -replace '\{version\}', $version + Write-Log -Level "INFO" -Message "Dry run: would create GitHub release '$releaseName' ($tag) on $repo" + return + } + + Assert-Command gh + + if ([string]::IsNullOrWhiteSpace($githubSecret)) { + throw "GitHub plugin requires 'githubSecret' in scriptSettings.json (logical secret name, e.g. GitHub)." + } + + $ghToken = Get-SecretEnvironmentValue -Name $githubSecret + if ([string]::IsNullOrWhiteSpace($ghToken)) { + throw "GitHub token is not set. Set environment variable '$githubSecret'." } if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) { @@ -126,7 +149,26 @@ function Invoke-Plugin { $releaseNotesFile = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $releaseNotesFileSetting)) $releaseNotes = Get-ReleaseNotesInternal -ReleaseNotesFile $releaseNotesFile -Version $version - if ($sharedSettings.PSObject.Properties['releaseAssetPaths'] -and $sharedSettings.releaseAssetPaths) { + if (Get-Command Get-EngineFact -ErrorAction SilentlyContinue) { + $fromAssets = Get-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -LegacyProperty @('releaseAssetPaths') + if ($null -ne $fromAssets) { + $releaseAssetPaths = @($fromAssets) + } + else { + $packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -LegacyProperty @('packageFile') + if ($null -eq $packageFile) { + $packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'npm' -Name 'packageFile' -LegacyProperty @('packageFile') + } + if ($null -ne $packageFile) { + $releaseAssetPaths = @($packageFile.FullName) + $symbolsPackageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -LegacyProperty @('symbolsPackageFile') + if ($null -ne $symbolsPackageFile) { + $releaseAssetPaths += $symbolsPackageFile.FullName + } + } + } + } + elseif ($sharedSettings.PSObject.Properties['releaseAssetPaths'] -and $sharedSettings.releaseAssetPaths) { $releaseAssetPaths = @($sharedSettings.releaseAssetPaths) } elseif ($sharedSettings.PSObject.Properties['packageFile'] -and $sharedSettings.packageFile) { @@ -163,7 +205,7 @@ function Invoke-Plugin { Write-Log -Level "INFO" -Message " GitHub title: $releaseName" $previousGhToken = $env:GH_TOKEN - $env:GH_TOKEN = $githubToken + $env:GH_TOKEN = $ghToken try { $ghVersion = & gh --version 2>&1 @@ -171,7 +213,7 @@ function Invoke-Plugin { Write-Log -Level "INFO" -Message " gh version: $($ghVersion[0])" } - Write-Log -Level "INFO" -Message " Auth env var: $githubTokenEnvVar (set)" + Write-Log -Level "INFO" -Message " Auth secret: $githubSecret" $authArgs = @("api", "repos/$repo", "--jq", ".full_name") $authOutput = & gh @authArgs 2>&1 @@ -188,7 +230,7 @@ function Invoke-Plugin { $authStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" } } - throw "GitHub CLI authentication failed for repository '$repo'. Ensure '$githubTokenEnvVar' is valid and has access to this repository." + throw "GitHub CLI authentication failed for repository '$repo'. Ensure secret '$githubSecret' is valid and has access to this repository." } Write-Log -Level "OK" -Message " GitHub token validated for repository: $($authOutput | Select-Object -First 1)" @@ -209,6 +251,9 @@ function Invoke-Plugin { $notesFilePath = Join-Path $releaseDir ("release-notes-{0}.md" -f $version) try { + if (-not [string]::IsNullOrWhiteSpace($releaseDir) -and -not (Test-Path -LiteralPath $releaseDir -PathType Container)) { + New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null + } [System.IO.File]::WriteAllText($notesFilePath, $releaseNotes, [System.Text.UTF8Encoding]::new($false)) $createReleaseArgs = @("release", "create", $tag) + $releaseAssetPaths + @( @@ -229,7 +274,7 @@ function Invoke-Plugin { } Write-Log -Level "OK" -Message " GitHub release created successfully." - $sharedSettings | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force + Add-EnginePublishCompletion -Context $sharedSettings -Publisher 'GitHub' } finally { if ($null -ne $previousGhToken) { @@ -241,4 +286,8 @@ function Invoke-Plugin { } } -Export-ModuleMember -Function Invoke-Plugin +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/Platform/PesterTest.psm1 b/utils/plugins/Platform/PesterTest.psm1 new file mode 100644 index 0000000..1dbc1e4 --- /dev/null +++ b/utils/plugins/Platform/PesterTest.psm1 @@ -0,0 +1,238 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Pester test plugin for the RepoUtils test engine. + +.DESCRIPTION + Runs the community Pester suite and publishes normalized coverage metrics on the + shared engine context for QualityGate. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1' + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Get-JaCoCoCoverageMetrics { + param( + [Parameter(Mandatory = $true)] + [string]$ReportPath + ) + + if (-not (Test-Path -LiteralPath $ReportPath -PathType Leaf)) { + return [PSCustomObject]@{ + Success = $false + Error = "JaCoCo coverage report not found at: $ReportPath" + } + } + + [xml]$reportXml = Get-Content -LiteralPath $ReportPath -Raw -Encoding UTF8 + $reportNode = $reportXml.report + if ($null -eq $reportNode) { + return [PSCustomObject]@{ + Success = $false + Error = "Invalid JaCoCo report (missing root ): $ReportPath" + } + } + + function Get-CounterRate { + param( + [Parameter(Mandatory = $true)] + [string]$CounterType + ) + + $counter = @($reportNode.counter) | Where-Object { $_.type -eq $CounterType } | Select-Object -First 1 + if ($null -eq $counter) { + return 0.0 + } + + $missed = [long]$counter.missed + $covered = [long]$counter.covered + $total = $missed + $covered + if ($total -le 0) { + return 0.0 + } + + return [math]::Round(($covered / $total) * 100, 1) + } + + function Get-CounterTotal { + param( + [Parameter(Mandatory = $true)] + [string]$CounterType + ) + + $counter = @($reportNode.counter) | Where-Object { $_.type -eq $CounterType } | Select-Object -First 1 + if ($null -eq $counter) { + return 0 + } + + return [long]$counter.missed + [long]$counter.covered + } + + function Get-CounterCovered { + param( + [Parameter(Mandatory = $true)] + [string]$CounterType + ) + + $counter = @($reportNode.counter) | Where-Object { $_.type -eq $CounterType } | Select-Object -First 1 + if ($null -eq $counter) { + return 0 + } + + return [long]$counter.covered + } + + return [PSCustomObject]@{ + Success = $true + LineRate = Get-CounterRate -CounterType 'LINE' + BranchRate = Get-CounterRate -CounterType 'BRANCH' + MethodRate = Get-CounterRate -CounterType 'METHOD' + TotalMethods = Get-CounterTotal -CounterType 'METHOD' + CoveredMethods = Get-CounterCovered -CounterType 'METHOD' + CoverageFile = $ReportPath + CoverageFiles = @($ReportPath) + } +} + +function Import-PesterModuleIfNeeded { + if (-not (Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -ge 5 })) { + Write-Log -Level 'INFO' -Message 'Installing Pester 5...' + Install-Module Pester -MinimumVersion 5.5.0 -MaximumVersion 5.99.99 -Scope CurrentUser -Force -SkipPublisherCheck + } + + Import-Module Pester -MinimumVersion 5.5.0 -MaximumVersion 5.99.99 -Force +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log' + Import-PluginDependency -ModuleName 'EngineContext' -RequiredCommand 'Resolve-RelativePaths' + + $pluginSettings = $Settings + $sharedSettings = $Settings.context + $scriptDir = $sharedSettings.scriptDir + + if (-not $pluginSettings.testsDir) { + throw "PesterTest plugin requires 'testsDir' in scriptSettings.json." + } + + $testsDirs = @(Resolve-RelativePaths -Value $pluginSettings.testsDir -BasePath $scriptDir) + $testsDir = $testsDirs[0] + + $configFileSetting = 'pester.config.ps1' + if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.configFile)) { + $configFileSetting = [string]$pluginSettings.configFile + } + + $configPaths = @(Resolve-RelativePaths -Value $configFileSetting -BasePath $scriptDir) + $configPath = $configPaths[0] + + if (-not (Test-Path -LiteralPath $testsDir -PathType Container)) { + throw "Pester tests directory not found at: $testsDir" + } + + if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) { + throw "Pester config not found at: $configPath" + } + + Import-PesterModuleIfNeeded + + Write-Log -Level 'STEP' -Message 'Running Pester tests...' + Write-Log -Level 'INFO' -Message " Tests: $testsDir" + Write-Log -Level 'INFO' -Message " Config: $configPath" + + Push-Location $testsDir + try { + $config = & $configPath + if ($null -eq $config) { + throw "Pester config did not return a configuration object: $configPath" + } + + $config.Run.PassThru = $true + $config.Run.Exit = $false + + $resultsDirSetting = [string]$pluginSettings.resultsDir + if ([string]::IsNullOrWhiteSpace($resultsDirSetting)) { + $resultsDirSetting = '..\..\..\test-results' + } + $resultsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $resultsDirSetting)) + New-Item -ItemType Directory -Path $resultsDirectory -Force | Out-Null + + # Point Pester report outputs at the resolved results dir (overrides pester.config relative paths). + if ($null -ne $config.TestResult -and $config.TestResult.Enabled) { + $config.TestResult.OutputPath = Join-Path $resultsDirectory 'pester-results.xml' + } + if ($null -ne $config.CodeCoverage -and $config.CodeCoverage.Enabled) { + $config.CodeCoverage.OutputPath = Join-Path $resultsDirectory 'coverage.xml' + } + + Write-Log -Level 'INFO' -Message " Results: $resultsDirectory" + + $result = Invoke-Pester -Configuration $config + if ($result.FailedCount -gt 0) { + throw "Pester tests failed: $($result.FailedCount) failed, $($result.PassedCount) passed, $($result.SkippedCount) skipped." + } + + $coveragePath = Join-Path $resultsDirectory 'coverage.xml' + $coverageMetrics = $null + if (Test-Path -LiteralPath $coveragePath -PathType Leaf) { + $coverageMetrics = Get-JaCoCoCoverageMetrics -ReportPath $coveragePath + if (-not $coverageMetrics.Success) { + throw $coverageMetrics.Error + } + } + else { + $coverageMetrics = [PSCustomObject]@{ + Success = $true + LineRate = 0.0 + BranchRate = 0.0 + MethodRate = 0.0 + TotalMethods = 0 + CoveredMethods = 0 + CoverageFile = $null + CoverageFiles = @() + } + } + + $testResult = [PSCustomObject]@{ + Success = $true + LineRate = $coverageMetrics.LineRate + BranchRate = $coverageMetrics.BranchRate + MethodRate = $coverageMetrics.MethodRate + TotalMethods = $coverageMetrics.TotalMethods + CoveredMethods = $coverageMetrics.CoveredMethods + CoverageFile = $coverageMetrics.CoverageFile + CoverageFiles = @($coverageMetrics.CoverageFiles) + ResultsDirectory = $resultsDirectory + PesterResult = $result + } + + Import-PluginDependency -ModuleName 'TestRunner' -RequiredCommand 'Publish-CoverageMetricsToSharedContext' + Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult + + Write-Log -Level 'OK' -Message " All tests passed! ($($result.PassedCount) passed)" + Write-Log -Level 'INFO' -Message " Line Coverage: $($testResult.LineRate)%" + Write-Log -Level 'INFO' -Message " Branch Coverage: $($testResult.BranchRate)%" + Write-Log -Level 'INFO' -Message " Method Coverage: $($testResult.MethodRate)%" + } + finally { + Pop-Location + } +} + +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $false } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata, Get-JaCoCoCoverageMetrics diff --git a/utils/plugins/Platform/ReleasePublishGuard.psm1 b/utils/plugins/Platform/ReleasePublishGuard.psm1 index adfb262..f1eb5e3 100644 --- a/utils/plugins/Platform/ReleasePublishGuard.psm1 +++ b/utils/plugins/Platform/ReleasePublishGuard.psm1 @@ -3,7 +3,7 @@ <# .SYNOPSIS - Central gate for publish-stage plugins (DotNetDockerPush, DotNetHelmPush, GitHub, DotNetNuGet, NpmPublish). + Central gate before remote-mutation plugins (see each plugin's Get-PluginMetadata). .DESCRIPTION Place this plugin immediately before any publish plugins in scriptSettings.json. It sets @@ -86,6 +86,11 @@ function Invoke-Plugin { Write-Log -Level "STEP" -Message "Release publish guard..." + if ($shared.PSObject.Properties.Name -contains 'dryRun' -and [bool]$shared.dryRun) { + Write-Log -Level "INFO" -Message " Dry run: publish guard relaxed; publish plugins will validate only." + return + } + $allowed = @(Get-PluginBranches -Plugin $pluginSettings) if ($allowed.Count -gt 0 -and $allowed -notcontains '*' -and $allowed -notcontains $shared.currentBranch) { Invoke-NotMetInternal -Shared $shared -When $when -Reason "branch '$($shared.currentBranch)' is not in the guard branches list." diff --git a/utils/tools/Enable-ModelsNullable.ps1 b/utils/tools/Enable-ModelsNullable.ps1 deleted file mode 100644 index 1826a4a..0000000 --- a/utils/tools/Enable-ModelsNullable.ps1 +++ /dev/null @@ -1,34 +0,0 @@ -#Requires -Version 7.0 -param( - [string]$ModelsDir = (Join-Path $PSScriptRoot '..\..\src\PodmanClient\Models') -) - -$ValueTypes = [System.Collections.Generic.HashSet[string]]::new( - [string[]]@('bool', 'byte', 'sbyte', 'char', 'decimal', 'double', 'float', 'int', 'uint', 'long', 'ulong', 'short', 'ushort') -) - -function Add-NullableToProperty([string]$line) { - if ($line -notmatch '^\s*public\s+(.+?)\s+(\w+)\s*\{\s*get;\s*set;\s*\}\s*$') { return $line } - $type = $Matches[1].Trim() - $name = $Matches[2] - if ($type.EndsWith('?')) { return $line } - if ($type -match '^(bool|byte|sbyte|char|decimal|double|float|int|uint|long|ulong|short|ushort)(\?)?$') { return $line } - - $nullableType = if ($type.EndsWith('[]')) { $type + '?' } else { $type + '?' } - $indent = ($line -replace '^(\s*).*', '$1') - return "${indent}public $nullableType $name { get; set; }" -} - -Get-ChildItem -LiteralPath $ModelsDir -Recurse -Filter '*.cs' | ForEach-Object { - $lines = Get-Content -LiteralPath $_.FullName - $out = New-Object System.Collections.Generic.List[string] - foreach ($line in $lines) { - if ($line -eq '#nullable disable') { continue } - $out.Add((Add-NullableToProperty $line)) - } - $text = ($out -join "`n").TrimEnd() + "`n" - $text = $text -replace '(?m)^ \}\s*$', '}' - Set-Content -LiteralPath $_.FullName -Value $text -NoNewline -} - -Write-Host 'Models nullable annotations applied.' diff --git a/utils/tools/Polish-PodmanClientSources.ps1 b/utils/tools/Polish-PodmanClientSources.ps1 deleted file mode 100644 index 2c77b27..0000000 --- a/utils/tools/Polish-PodmanClientSources.ps1 +++ /dev/null @@ -1,117 +0,0 @@ -#Requires -Version 7.0 -param( - [string]$ProjectDir = (Join-Path $PSScriptRoot '..\..\src\PodmanClient') -) - -$ErrorActionPreference = 'Stop' -$rootNs = 'MaksIT.PodmanClientDotNet' - -function Convert-HumanName([string]$name) { - $s = $name -replace 'Dto$', '' -replace 'Request$', ' request' -replace 'Response$', ' response' - return ($s -creplace '([A-Z])', ' $1').Trim() -} - -function Get-TypeSummary([string]$typeName, [string]$kind) { - if ($typeName -match 'Request$') { return "Libpod API request body for $(Convert-HumanName $typeName)." } - if ($typeName -match 'Response$') { return "Libpod API response body for $(Convert-HumanName $typeName)." } - if ($typeName -match 'Dto$') { return "Deserialized Podman libpod API payload ($(Convert-HumanName $typeName))." } - if ($kind -eq 'model') { return "Libpod container or image specification model ($(Convert-HumanName $typeName))." } - return "Podman libpod API type ($(Convert-HumanName $typeName))." -} - -function Polish-ModelFile([string]$path) { - $text = Get-Content -LiteralPath $path -Raw - if ([string]::IsNullOrWhiteSpace($text)) { return } - - $nsMatch = [regex]::Match($text, 'namespace\s+([\w.]+)\s*\{') - if (-not $nsMatch.Success) { - if ($text -match 'namespace\s+([\w.]+)\s*;') { - $ns = $Matches[1] - $inner = $text -replace '(?s).*?namespace\s+[\w.]+\s*;\s*', '' - } else { return } - } else { - $ns = $nsMatch.Groups[1].Value - $start = $nsMatch.Index + $nsMatch.Length - $inner = $text.Substring($start) - $inner = $inner.Trim() - if ($inner.EndsWith('}')) { $inner = $inner.Substring(0, $inner.LastIndexOf('}')).Trim() } - } - - $inner = $inner -replace '(?m)^using\s+[\w.]+\s*;\s*\r?\n', '' - $inner = $inner.Trim() - - $out = New-Object System.Collections.Generic.List[string] - $out.Add("namespace $ns;") - $out.Add('') - - if ($inner -notmatch '/// ') { - $typeMatch = [regex]::Match($inner, 'public\s+(?:sealed\s+)?class\s+(\w+)') - if ($typeMatch.Success) { - $summary = Get-TypeSummary $typeMatch.Groups[1].Value 'model' - $out.Add('/// ') - $out.Add("/// $summary") - $out.Add('/// ') - $out.Add('') - } - } else { - foreach ($line in ($inner -split '\r?\n')) { $out.Add($line) } - Set-Content -LiteralPath $path -Value (($out -join "`n").TrimEnd() + "`n") -NoNewline - return - } - - $inner = $inner -replace '(?m)^\s+', ' ' - $inner = $inner -replace 'public\s+(class|sealed class)\s+(\w+)\s*\r?\n\s*\{', 'public $1 $2 {' - $inner = $inner -replace 'public\s+(class|sealed class)\s+(\w+)\s*$', 'public $1 $2 {' - - if ($inner -notmatch '\}\s*$') { $inner = $inner + "`n}" } - - foreach ($line in ($inner -split '\r?\n')) { $out.Add($line) } - - Set-Content -LiteralPath $path -Value (($out -join "`n").TrimEnd() + "`n") -NoNewline -} - -function Polish-DtoFile([string]$path) { - $text = Get-Content -LiteralPath $path -Raw - if ($text -match '/// ') { return } - - $matches = [regex]::Matches($text, 'public\s+(?:sealed\s+)?class\s+(\w+)') - foreach ($m in $matches) { - $typeName = $m.Groups[1].Value - $summary = Get-TypeSummary $typeName 'dto' - $doc = "/// `n/// $summary`n/// `n" - $text = $text -replace ( - '(?m)^(\s*)(public\s+(?:sealed\s+)?class\s+' + [regex]::Escape($typeName) + '\s*\{)' - ), "$doc`$1`$2" - } - Set-Content -LiteralPath $path -Value $text -NoNewline -} - -function Remove-RootNamespaceLine([string]$path) { - $text = Get-Content -LiteralPath $path -Raw - if ($text -notmatch "(?m)^namespace $([regex]::Escape($rootNs));\s*\r?\n") { return } - $text = $text -replace "(?m)^namespace $([regex]::Escape($rootNs));\s*\r?\n", '' - Set-Content -LiteralPath $path -Value $text -NoNewline -} - -Get-ChildItem -LiteralPath (Join-Path $ProjectDir 'Models') -Recurse -Filter '*.cs' | ForEach-Object { - Polish-ModelFile $_.FullName -} - -Get-ChildItem -LiteralPath (Join-Path $ProjectDir 'Dtos') -Recurse -Filter '*.cs' | ForEach-Object { - if ($_.FullName -notmatch '/// ') { Polish-DtoFile $_.FullName } -} - -$rootFiles = @( - 'PodmanClient.cs', 'IPodmanClient.cs', 'IPodmanClientConfiguration.cs' -) + (Get-ChildItem -LiteralPath $ProjectDir -Filter 'PodmanClient.*.cs').Name - -foreach ($name in $rootFiles) { - $path = Join-Path $ProjectDir $name - if (Test-Path -LiteralPath $path) { Remove-RootNamespaceLine $path } -} - -Get-ChildItem -LiteralPath (Join-Path $ProjectDir 'Abstractions') -Filter '*.cs' | ForEach-Object { - Remove-RootNamespaceLine $_.FullName -} - -Write-Host 'Polish complete.' diff --git a/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 b/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 index 80082c3..9548ede 100644 --- a/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 +++ b/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 @@ -248,6 +248,11 @@ try { } if (-not $dryRun) { + $backupDirectory = Split-Path -Parent $backupPath + if (-not (Test-Path -Path $backupDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $backupDirectory -Force | Out-Null + } + Copy-Item -Path $file.FullName -Destination $backupPath -Force } }